import { beforeEach, describe, expect, it, vi } from 'vitest'; import { SDK } from './sdk'; import type { Plugin, PluginFunction } from './types'; describe('SDK', () => { let sdk: SDK; beforeEach(() => { sdk = new SDK(); }); describe('constructor', () => { it('should create SDK with empty config', () => { const sdk = new SDK(); expect(sdk).toBeDefined(); expect(sdk.isReady()).toBe(false); }); it('should accept initial config', () => { const sdk = new SDK({ name: 'test-sdk', version: '1.0.0' }); expect(sdk.get('name')).toBe('test-sdk'); expect(sdk.get('version')).toBe('1.0.0'); }); it('should accept nested config', () => { const sdk = new SDK({ api: { endpoint: 'https://api.example.com', timeout: 5000, }, }); expect(sdk.get('api.endpoint')).toBe('https://api.example.com'); expect(sdk.get('api.timeout')).toBe(5000); }); }); describe('use()', () => { it('should register a plugin', () => { const pluginFn = vi.fn(); sdk.use(pluginFn); expect(pluginFn).toHaveBeenCalledOnce(); }); it('should inject capabilities into plugin', () => { let capturedPlugin: Plugin | undefined; sdk.use((plugin) => { capturedPlugin = plugin; }); expect(capturedPlugin).toBeDefined(); expect(capturedPlugin?.ns).toBeTypeOf('function'); expect(capturedPlugin?.defaults).toBeTypeOf('function'); expect(capturedPlugin?.on).toBeTypeOf('function'); expect(capturedPlugin?.off).toBeTypeOf('function'); expect(capturedPlugin?.emit).toBeTypeOf('function'); expect(capturedPlugin?.expose).toBeTypeOf('function'); expect(capturedPlugin?.mustEnable).toBeTypeOf('function'); }); it('should pass SDK instance to plugin', () => { let capturedInstance: SDK | undefined; sdk.use((plugin, instance) => { capturedInstance = instance; }); expect(capturedInstance).toBe(sdk); }); it('should pass config to plugin', () => { const sdk = new SDK({ test: 'value' }); let capturedConfigValue: any; sdk.use((plugin, instance, config) => { capturedConfigValue = config.get('test'); }); expect(capturedConfigValue).toBe('value'); }); it('should support method chaining', () => { const plugin1 = vi.fn(); const plugin2 = vi.fn(); const plugin3 = vi.fn(); const result = sdk.use(plugin1).use(plugin2).use(plugin3); expect(result).toBe(sdk); expect(plugin1).toHaveBeenCalled(); expect(plugin2).toHaveBeenCalled(); expect(plugin3).toHaveBeenCalled(); }); it('should allow plugin to set namespace', () => { sdk.use((plugin) => { plugin.ns('test.plugin'); }); // No error means namespace was set successfully expect(true).toBe(true); }); it('should allow plugin to set defaults', () => { sdk.use((plugin) => { plugin.ns('test'); plugin.defaults({ test: { value: 42 } }); }); expect(sdk.get('test.value')).toBe(42); }); it('should allow plugin to expose methods', () => { sdk.use((plugin) => { plugin.ns('test'); plugin.expose({ testMethod() { return 'test result'; }, }); }); expect((sdk as any).testMethod()).toBe('test result'); }); it('should allow plugin to listen to events', () => { const handler = vi.fn(); sdk.use((plugin) => { plugin.ns('test'); plugin.on('test:event', handler); }); sdk.emit('test:event', 'data'); expect(handler).toHaveBeenCalledWith('data'); }); it('should allow plugin to emit events', () => { const handler = vi.fn(); sdk.on('custom:event', handler); sdk.use((plugin) => { plugin.ns('test'); plugin.emit('custom:event', 'from plugin'); }); expect(handler).toHaveBeenCalledWith('from plugin'); }); }); describe('multiple plugins', () => { it('should register multiple plugins', () => { const plugin1: PluginFunction = (plugin) => { plugin.ns('plugin.one'); }; const plugin2: PluginFunction = (plugin) => { plugin.ns('plugin.two'); }; sdk.use(plugin1).use(plugin2); // Both plugins registered successfully expect(true).toBe(true); }); it('should allow plugins to work together', () => { sdk.use((plugin) => { plugin.ns('analytics'); plugin.expose({ track(event: string) { plugin.emit('track:event', event); }, }); }); const trackHandler = vi.fn(); sdk.use((plugin) => { plugin.ns('logger'); plugin.on('track:event', trackHandler); }); (sdk as any).track('page_view'); expect(trackHandler).toHaveBeenCalledWith('page_view'); }); it('should merge defaults from multiple plugins', () => { sdk.use((plugin) => { plugin.ns('plugin1'); plugin.defaults({ plugin1: { setting: 'value1' } }); }); sdk.use((plugin) => { plugin.ns('plugin2'); plugin.defaults({ plugin2: { setting: 'value2' } }); }); expect(sdk.get('plugin1.setting')).toBe('value1'); expect(sdk.get('plugin2.setting')).toBe('value2'); }); it('should expose methods from multiple plugins', () => { sdk.use((plugin) => { plugin.ns('plugin1'); plugin.expose({ method1() { return 'result1'; }, }); }); sdk.use((plugin) => { plugin.ns('plugin2'); plugin.expose({ method2() { return 'result2'; }, }); }); expect((sdk as any).method1()).toBe('result1'); expect((sdk as any).method2()).toBe('result2'); }); }); describe('init()', () => { it('should initialize SDK', async () => { await sdk.init(); expect(sdk.isReady()).toBe(true); }); it('should emit sdk:init event', async () => { const handler = vi.fn(); sdk.on('sdk:init', handler); await sdk.init(); expect(handler).toHaveBeenCalled(); }); it('should emit sdk:ready event', async () => { const handler = vi.fn(); sdk.on('sdk:ready', handler); await sdk.init(); expect(handler).toHaveBeenCalled(); }); it('should emit events in correct order', async () => { const events: string[] = []; sdk.on('sdk:init', () => events.push('init')); sdk.on('sdk:ready', () => events.push('ready')); await sdk.init(); expect(events).toEqual(['init', 'ready']); }); it('should be idempotent', async () => { const handler = vi.fn(); sdk.on('sdk:ready', handler); await sdk.init(); await sdk.init(); await sdk.init(); expect(handler).toHaveBeenCalledOnce(); }); it('should warn on multiple init calls', async () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); await sdk.init(); await sdk.init(); expect(warnSpy).toHaveBeenCalledWith('SDK already initialized'); warnSpy.mockRestore(); }); it('should allow plugins to listen for init', async () => { const initHandler = vi.fn(); sdk.use((plugin, instance) => { plugin.ns('test'); instance.on('sdk:init', initHandler); }); await sdk.init(); expect(initHandler).toHaveBeenCalled(); }); it('should allow plugins to listen for ready', async () => { const readyHandler = vi.fn(); sdk.use((plugin, instance) => { plugin.ns('test'); instance.on('sdk:ready', readyHandler); }); await sdk.init(); expect(readyHandler).toHaveBeenCalled(); }); }); describe('destroy()', () => { it('should destroy SDK', async () => { await sdk.init(); await sdk.destroy(); expect(sdk.isReady()).toBe(false); }); it('should emit sdk:destroy event', async () => { const handler = vi.fn(); sdk.on('sdk:destroy', handler); await sdk.destroy(); expect(handler).toHaveBeenCalled(); }); it('should remove all event listeners', async () => { const handler = vi.fn(); sdk.on('test:event', handler); await sdk.destroy(); sdk.emit('test:event'); expect(handler).not.toHaveBeenCalled(); }); it('should allow re-initialization after destroy', async () => { await sdk.init(); expect(sdk.isReady()).toBe(true); await sdk.destroy(); expect(sdk.isReady()).toBe(false); await sdk.init(); expect(sdk.isReady()).toBe(true); }); it('should allow plugins to cleanup on destroy', async () => { const cleanupHandler = vi.fn(); sdk.use((plugin, instance) => { plugin.ns('test'); instance.on('sdk:destroy', cleanupHandler); }); await sdk.destroy(); expect(cleanupHandler).toHaveBeenCalled(); }); }); describe('get()', () => { it('should get config value by path', () => { const sdk = new SDK({ api: { timeout: 5000 } }); expect(sdk.get('api.timeout')).toBe(5000); }); it('should return undefined for missing path', () => { expect(sdk.get('missing.path')).toBeUndefined(); }); it('should get nested values', () => { const sdk = new SDK({ deeply: { nested: { value: 42, }, }, }); expect(sdk.get('deeply.nested.value')).toBe(42); }); }); describe('set()', () => { it('should set config value by path', () => { sdk.set('api.timeout', 10000); expect(sdk.get('api.timeout')).toBe(10000); }); it('should set nested values', () => { sdk.set('deeply.nested.value', 'test'); expect(sdk.get('deeply.nested.value')).toBe('test'); }); it('should create intermediate objects', () => { sdk.set('new.nested.path', 'value'); expect(sdk.get('new.nested.path')).toBe('value'); expect(sdk.get('new.nested')).toEqual({ path: 'value' }); }); }); describe('on() / off() / emit()', () => { it('should subscribe to events', () => { const handler = vi.fn(); sdk.on('test:event', handler); sdk.emit('test:event', 'data'); expect(handler).toHaveBeenCalledWith('data'); }); it('should unsubscribe from events', () => { const handler = vi.fn(); sdk.on('test:event', handler); sdk.off('test:event', handler); sdk.emit('test:event'); expect(handler).not.toHaveBeenCalled(); }); it('should return unsubscribe function from on()', () => { const handler = vi.fn(); const unsubscribe = sdk.on('test:event', handler); unsubscribe(); sdk.emit('test:event'); expect(handler).not.toHaveBeenCalled(); }); it('should support wildcard patterns', () => { const handler = vi.fn(); sdk.on('track:*', handler); sdk.emit('track:page'); sdk.emit('track:event'); expect(handler).toHaveBeenCalledTimes(2); }); it('should pass multiple arguments to handlers', () => { const handler = vi.fn(); sdk.on('test:event', handler); sdk.emit('test:event', 'arg1', 'arg2', 'arg3'); expect(handler).toHaveBeenCalledWith('arg1', 'arg2', 'arg3'); }); }); describe('getAll()', () => { it('should return all config', () => { const config = { api: { timeout: 5000 }, name: 'test' }; const sdk = new SDK(config); const allConfig = sdk.getAll(); expect(allConfig).toEqual(config); }); it('should return immutable copy', () => { const sdk = new SDK({ api: { timeout: 5000 } }); const allConfig = sdk.getAll(); allConfig.api.timeout = 10000; expect(sdk.get('api.timeout')).toBe(5000); }); }); describe('isReady()', () => { it('should return false before init', () => { expect(sdk.isReady()).toBe(false); }); it('should return true after init', async () => { await sdk.init(); expect(sdk.isReady()).toBe(true); }); it('should return false after destroy', async () => { await sdk.init(); await sdk.destroy(); expect(sdk.isReady()).toBe(false); }); }); describe('mustEnable()', () => { it('should mark plugin as required', () => { sdk.use((plugin) => { plugin.ns('required.plugin'); plugin.mustEnable(); }); // Plugin is marked as required (no error thrown) expect(true).toBe(true); }); it('should allow init when required plugin is registered', async () => { sdk.use((plugin) => { plugin.ns('required.plugin'); plugin.mustEnable(); }); await expect(sdk.init()).resolves.toBeUndefined(); }); it('should not throw if plugin without namespace calls mustEnable', () => { sdk.use((plugin) => { // Don't set namespace plugin.mustEnable(); }); // No error thrown expect(true).toBe(true); }); }); describe('integration scenarios', () => { it('should support full plugin lifecycle', async () => { const events: string[] = []; sdk.use((plugin, instance) => { plugin.ns('analytics'); plugin.defaults({ analytics: { endpoint: 'https://api.example.com', }, }); plugin.expose({ track(event: string) { events.push(`track: ${event}`); plugin.emit('analytics:track', event); }, }); instance.on('sdk:ready', () => { events.push('plugin ready'); }); instance.on('sdk:destroy', () => { events.push('plugin cleanup'); }); }); await sdk.init(); (sdk as any).track('page_view'); await sdk.destroy(); expect(events).toEqual(['plugin ready', 'track: page_view', 'plugin cleanup']); }); it('should support plugin communication via events', async () => { const messages: string[] = []; // Publisher plugin sdk.use((plugin) => { plugin.ns('publisher'); plugin.expose({ publish(msg: string) { plugin.emit('message:published', msg); }, }); }); // Subscriber plugin sdk.use((plugin, instance) => { plugin.ns('subscriber'); instance.on('message:published', (msg: string) => { messages.push(`received: ${msg}`); }); }); (sdk as any).publish('hello'); (sdk as any).publish('world'); expect(messages).toEqual(['received: hello', 'received: world']); }); it('should support config sharing between plugins', () => { sdk.use((plugin) => { plugin.ns('config.provider'); plugin.defaults({ shared: { apiKey: 'abc123', }, }); }); let capturedApiKey: string | undefined; sdk.use((plugin, instance, config) => { plugin.ns('config.consumer'); capturedApiKey = config.get('shared.apiKey'); }); expect(capturedApiKey).toBe('abc123'); }); }); describe('Plugin Extensibility (Phase 3)', () => { describe('hold()', () => { it('should register capabilities on Plugin prototype', () => { sdk.use((plugin) => { plugin.ns('provider'); plugin.hold({ testCapability() { return 'test-value'; }, }); }); sdk.use((plugin) => { plugin.ns('consumer'); // @ts-expect-error - Dynamic capability, TypeScript doesn't know about it yet expect(typeof plugin.testCapability).toBe('function'); // @ts-expect-error - Dynamic capability expect(plugin.testCapability()).toBe('test-value'); }); }); it('should bind held methods to calling plugin instance', () => { sdk.use((plugin) => { plugin.ns('provider'); plugin.hold({ getNamespace() { // @ts-expect-error - this.namespace exists at runtime return this.namespace; }, }); }); let capturedNamespace: string | undefined; sdk.use((plugin) => { plugin.ns('consumer'); // @ts-expect-error - Dynamic capability capturedNamespace = plugin.getNamespace(); }); expect(capturedNamespace).toBe('consumer'); }); it('should warn when capability conflicts', () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); sdk.use((plugin) => { plugin.ns('first'); plugin.hold({ duplicate() { return 'first'; }, }); }); sdk.use((plugin) => { plugin.ns('second'); plugin.hold({ duplicate() { return 'second'; }, }); }); expect(warnSpy).toHaveBeenCalledWith( expect.stringContaining('Capability "duplicate" already registered') ); warnSpy.mockRestore(); }); it('should allow multiple methods in one hold() call', () => { sdk.use((plugin) => { plugin.ns('provider'); plugin.hold({ method1() { return 'value1'; }, method2() { return 'value2'; }, method3() { return 'value3'; }, }); }); sdk.use((plugin) => { plugin.ns('consumer'); // @ts-expect-error - Dynamic capabilities expect(plugin.method1()).toBe('value1'); // @ts-expect-error - Dynamic capabilities expect(plugin.method2()).toBe('value2'); // @ts-expect-error - Dynamic capabilities expect(plugin.method3()).toBe('value3'); }); }); it('should allow held methods to accept arguments', () => { sdk.use((plugin) => { plugin.ns('provider'); plugin.hold({ add(a: number, b: number) { return a + b; }, greet(name: string) { return `Hello, ${name}!`; }, }); }); sdk.use((plugin) => { plugin.ns('consumer'); // @ts-expect-error - Dynamic capabilities expect(plugin.add(2, 3)).toBe(5); // @ts-expect-error - Dynamic capabilities expect(plugin.greet('World')).toBe('Hello, World!'); }); }); it('should preserve namespace context across multiple calls', () => { sdk.use((plugin) => { plugin.ns('provider'); plugin.hold({ getInfo(prefix: string) { // @ts-expect-error - this.namespace exists at runtime return `${prefix}: ${this.namespace}`; }, }); }); let info1: string | undefined; let info2: string | undefined; sdk.use((plugin) => { plugin.ns('consumer1'); // @ts-expect-error - Dynamic capability info1 = plugin.getInfo('Plugin'); }); sdk.use((plugin) => { plugin.ns('consumer2'); // @ts-expect-error - Dynamic capability info2 = plugin.getInfo('Plugin'); }); expect(info1).toBe('Plugin: consumer1'); expect(info2).toBe('Plugin: consumer2'); }); }); describe('hasCapability()', () => { it('should return true for registered capabilities', () => { sdk.use((plugin) => { plugin.ns('provider'); plugin.hold({ exists() { return true; }, }); }); sdk.use((plugin) => { plugin.ns('consumer'); expect(plugin.hasCapability('exists')).toBe(true); }); }); it('should return false for unregistered capabilities', () => { sdk.use((plugin) => { plugin.ns('consumer'); expect(plugin.hasCapability('nonexistent')).toBe(false); }); }); it('should work before and after capability registration', () => { let beforeRegistration = false; let afterRegistration = false; sdk.use((plugin) => { plugin.ns('early'); beforeRegistration = plugin.hasCapability('log'); }); sdk.use((plugin) => { plugin.ns('provider'); plugin.hold({ log() { return 'logged'; }, }); }); sdk.use((plugin) => { plugin.ns('late'); afterRegistration = plugin.hasCapability('log'); }); expect(beforeRegistration).toBe(false); expect(afterRegistration).toBe(true); }); it('should detect multiple capabilities', () => { sdk.use((plugin) => { plugin.ns('provider'); plugin.hold({ capability1() {}, capability2() {}, capability3() {}, }); }); sdk.use((plugin) => { plugin.ns('consumer'); expect(plugin.hasCapability('capability1')).toBe(true); expect(plugin.hasCapability('capability2')).toBe(true); expect(plugin.hasCapability('capability3')).toBe(true); expect(plugin.hasCapability('capability4')).toBe(false); }); }); }); describe('Load order', () => { it('should require capability to be registered before use', () => { let hasCapabilityEarly = false; let hasCapabilityLate = false; // Consumer loads first sdk.use((plugin) => { plugin.ns('consumer'); hasCapabilityEarly = plugin.hasCapability('log'); // @ts-expect-error - Dynamic capability expect(typeof plugin.log).toBe('undefined'); }); // Provider loads after sdk.use((plugin) => { plugin.ns('provider'); plugin.hold({ log() { return 'logged'; }, }); }); // Later consumer can use it sdk.use((plugin) => { plugin.ns('late-consumer'); hasCapabilityLate = plugin.hasCapability('log'); // @ts-expect-error - Dynamic capability expect(plugin.log()).toBe('logged'); }); expect(hasCapabilityEarly).toBe(false); expect(hasCapabilityLate).toBe(true); }); it('should allow checking before use for graceful degradation', () => { const operations: string[] = []; sdk.use((plugin) => { plugin.ns('consumer'); if (!plugin.hasCapability('log')) { operations.push('no-logging'); } else { operations.push('with-logging'); } }); sdk.use((plugin) => { plugin.ns('provider'); plugin.hold({ log() { operations.push('logged'); }, }); }); sdk.use((plugin) => { plugin.ns('late-consumer'); if (plugin.hasCapability('log')) { // @ts-expect-error - Dynamic capability plugin.log(); } }); expect(operations).toEqual(['no-logging', 'logged']); }); }); describe('Real-world scenarios', () => { it('should support logging plugin pattern', () => { const logs: string[] = []; // Logging plugin provides capability sdk.use((plugin) => { plugin.ns('logging'); plugin.hold({ log(message: string) { // @ts-expect-error - this.namespace exists at runtime logs.push(`[${this.namespace}] ${message}`); }, }); }); // Storage plugin uses logging sdk.use((plugin) => { plugin.ns('storage'); if (plugin.hasCapability('log')) { // @ts-expect-error - Dynamic capability plugin.log('Storage initialized'); } }); // Transport plugin uses logging sdk.use((plugin) => { plugin.ns('transport'); if (plugin.hasCapability('log')) { // @ts-expect-error - Dynamic capability plugin.log('Transport initialized'); } }); expect(logs).toEqual([ '[storage] Storage initialized', '[transport] Transport initialized', ]); }); it('should support validation plugin pattern', () => { // Validation plugin provides capability sdk.use((plugin) => { plugin.ns('validation'); plugin.hold({ validate(schema: Record, data: any) { const errors: string[] = []; for (const [key, type] of Object.entries(schema)) { if (typeof data[key] !== type) { errors.push(`${key}: expected ${type}, got ${typeof data[key]}`); } } return { valid: errors.length === 0, errors }; }, }); }); let validationResult: any; // Transport plugin uses validation sdk.use((plugin) => { plugin.ns('transport'); if (plugin.hasCapability('validate')) { // @ts-expect-error - Dynamic capability validationResult = plugin.validate( { url: 'string', method: 'string' }, { url: 'https://api.example.com', method: 'POST' } ); } }); expect(validationResult).toEqual({ valid: true, errors: [] }); }); it('should support multiple plugins using same capability', () => { const operations: string[] = []; // Provider sdk.use((plugin) => { plugin.ns('provider'); plugin.hold({ track(event: string) { operations.push(event); }, }); }); // Consumer 1 sdk.use((plugin) => { plugin.ns('plugin1'); // @ts-expect-error - Dynamic capability plugin.track('plugin1-initialized'); }); // Consumer 2 sdk.use((plugin) => { plugin.ns('plugin2'); // @ts-expect-error - Dynamic capability plugin.track('plugin2-initialized'); }); // Consumer 3 sdk.use((plugin) => { plugin.ns('plugin3'); // @ts-expect-error - Dynamic capability plugin.track('plugin3-initialized'); }); expect(operations).toEqual([ 'plugin1-initialized', 'plugin2-initialized', 'plugin3-initialized', ]); }); }); }); });