import { afterEach, describe, expect, it, vi } from 'vitest'; import { createHostBridge } from './host'; import { createPlugin } from './plugin'; import { createRpcEndpoint, RpcErrorObject, type RpcMessage, type RpcTransport } from './rpc'; /** Wraps one end of a `MessageChannel` as an RPC transport. A `MessagePort` * dispatches queued messages only after `start()`, which `addEventListener` * does not call implicitly. */ function portTransport(port: MessagePort): RpcTransport { port.start(); return { post: (message) => port.postMessage(message), listen: (onMessage) => { const listener = (event: MessageEvent) => onMessage(event.data as RpcMessage); port.addEventListener('message', listener); return () => port.removeEventListener('message', listener); }, }; } const disposers: Array<() => void> = []; afterEach(() => { while (disposers.length) disposers.pop()?.(); }); /** Builds a connected host+plugin pair over a fresh channel. */ function connect(options: { permissions: string[]; capabilities: Record unknown>; lifecycle?: Omit[0], 'transport' | 'timeoutMs'>; }) { const channel = new MessageChannel(); const host = createHostBridge({ manifest: { permissions: options.permissions as never }, capabilities: options.capabilities, transport: portTransport(channel.port1), timeoutMs: 1000, }); const plugin = createPlugin({ ...options.lifecycle, transport: portTransport(channel.port2), timeoutMs: 1000, }); disposers.push( () => host.dispose(), () => plugin.dispose(), ); return { host, plugin, channel }; } describe('RPC roundtrip (plugin → host)', () => { it('resolves a declared, implemented capability call', async () => { const { plugin } = connect({ permissions: ['readCurrentPage'], capabilities: { getContent: () => '# Hello', getMeta: () => ({ id: 'p1', title: 'Hi', pondId: 'pond', slug: 'hi' }), }, }); await expect(plugin.host.readCurrentPage.getContent()).resolves.toBe('# Hello'); await expect(plugin.host.readCurrentPage.getMeta()).resolves.toMatchObject({ id: 'p1' }); }); it('passes arguments through as params', async () => { const getPageContent = vi.fn((pageId: unknown) => `content of ${String(pageId)}`); const { plugin } = connect({ permissions: ['readPond'], capabilities: { getPageContent }, }); await expect(plugin.host.readPond.getPageContent('page-9')).resolves.toBe('content of page-9'); expect(getPageContent).toHaveBeenCalledWith('page-9'); }); it('rejects a call whose capability the manifest did not declare', async () => { const { plugin } = connect({ permissions: ['readCurrentPage'], capabilities: { listPages: () => [] }, }); await expect(plugin.host.readPond.listPages()).rejects.toMatchObject({ code: 'capability_not_permitted', }); }); it('rejects a declared capability the host does not implement', async () => { const { plugin } = connect({ permissions: ['readCurrentPage'], capabilities: { getContent: () => '' }, }); await expect(plugin.host.readCurrentPage.getOutline()).rejects.toMatchObject({ code: 'unknown_method', }); }); it('surfaces a handler error as handler_error', async () => { const { plugin } = connect({ permissions: ['readCurrentPage'], capabilities: { getContent: () => { throw new Error('boom'); }, }, }); await expect(plugin.host.readCurrentPage.getContent()).rejects.toMatchObject({ code: 'handler_error', message: 'boom', }); }); }); describe('RPC roundtrip (host → plugin lifecycle)', () => { it('invokes the plugin render handler with context', async () => { const onRender = vi.fn(); const { host } = connect({ permissions: [], capabilities: {}, lifecycle: { onRender }, }); await host.invoke('render', { extensionPointId: 'toc', locale: 'de' }); expect(onRender).toHaveBeenCalledWith({ extensionPointId: 'toc', locale: 'de' }); }); it('rejects a non-lifecycle host invocation', async () => { const { host } = connect({ permissions: [], capabilities: {} }); await expect(host.invoke('nope' as never)).rejects.toBeInstanceOf(RpcErrorObject); }); }); describe('RPC timeouts and disposal', () => { it('rejects with a timeout when the peer never answers', async () => { const channel = new MessageChannel(); // Only one side exists: the request is never answered. const endpoint = createRpcEndpoint({ ...portTransport(channel.port1), timeoutMs: 20 }); disposers.push(() => endpoint.dispose()); await expect(endpoint.request('getContent')).rejects.toMatchObject({ code: 'timeout' }); }); it('answers unknown methods with unknown_method', async () => { const channel = new MessageChannel(); createRpcEndpoint({ ...portTransport(channel.port1), handlers: {} }); const caller = createRpcEndpoint({ ...portTransport(channel.port2), timeoutMs: 200 }); disposers.push(() => caller.dispose()); await expect(caller.request('doesNotExist')).rejects.toMatchObject({ code: 'unknown_method', }); }); it('rejects in-flight requests when disposed', async () => { const channel = new MessageChannel(); const endpoint = createRpcEndpoint({ ...portTransport(channel.port1), timeoutMs: 1000 }); const pending = endpoint.request('getContent'); endpoint.dispose(); await expect(pending).rejects.toMatchObject({ code: 'endpoint_disposed' }); }); });