import { EventEmitter } from 'node:events'; import { describe, expect, it, vi } from 'vitest'; import type { AgentBrowserAdapter, AgentBrowserDebuggerPort, AgentBrowserNavigationPort, AgentBrowserViewPort, AgentBrowserWebContentsPort, PortListener, } from '@electron/agent-browser/adapter'; import { AgentBrowserCdpGuard } from '@electron/agent-browser/cdp-guard'; import { AgentBrowserEventBuffer } from '@electron/agent-browser/event-buffer'; import { AgentBrowserFault } from '@electron/agent-browser/fault'; import { AgentBrowserModule, agentBrowserPartition, } from '@electron/agent-browser/module'; import { AgentBrowserPayloadStore } from '@electron/agent-browser/payload-store'; class FakeDebugger implements AgentBrowserDebuggerPort { readonly events = new EventEmitter(); readonly commands: Array<{ method: string; params?: Record; sessionRef?: string; }> = []; readonly attachVersions: string[] = []; readonly responders = new Map Promise>(); attached = false; operationLog: string[] = []; attach(version: string): void { this.operationLog.push(`attach:${version}`); this.attachVersions.push(version); this.attached = true; } detach(): void { this.operationLog.push('detach'); this.attached = false; } isAttached(): boolean { return this.attached; } async sendCommand( method: string, params?: Record, sessionRef?: string, ): Promise { this.operationLog.push(`command:${method}`); this.commands.push({ method, params, sessionRef }); return await (this.responders.get(method)?.() ?? Promise.resolve({})); } on(event: 'message' | 'detach', listener: PortListener): void { this.events.on(event, listener); } removeListener(event: 'message' | 'detach', listener: PortListener): void { this.events.removeListener(event, listener); } message(method: string, params: unknown, sessionRef?: string): void { if (!this.attached) return; this.events.emit('message', {}, method, params, sessionRef); } detached(reason = 'replaced_with_devtools'): void { this.attached = false; this.events.emit('detach', {}, reason); } } class FakeNavigation implements AgentBrowserNavigationPort { back = false; forward = false; goBackCalls = 0; goForwardCalls = 0; clearCalls = 0; canGoBack(): boolean { return this.back; } canGoForward(): boolean { return this.forward; } goBack(): void { this.goBackCalls += 1; } goForward(): void { this.goForwardCalls += 1; } clear(): void { this.clearCalls += 1; this.back = false; this.forward = false; } } class FakeWebContents implements AgentBrowserWebContentsPort { readonly events = new EventEmitter(); readonly debugger = new FakeDebugger(); readonly navigationHistory = new FakeNavigation(); readonly loadCalls: string[] = []; url = ''; title = 'Student app'; destroyed = false; devToolsOpen = false; reloadCalls = 0; windowOpenDenied = false; onLoad?: () => void; loadHandler?: (url: string) => Promise; async loadURL(url: string): Promise { this.debugger.operationLog.push(`load:${url}`); this.loadCalls.push(url); this.url = url; this.onLoad?.(); await this.loadHandler?.(url); } getURL(): string { return this.url; } getTitle(): string { return this.title; } isDestroyed(): boolean { return this.destroyed; } isDevToolsOpened(): boolean { return this.devToolsOpen; } reload(): void { this.reloadCalls += 1; } denyWindowOpen(): void { this.windowOpenDenied = true; } on(event: string, listener: PortListener): void { this.events.on(event, listener); } removeListener(event: string, listener: PortListener): void { this.events.removeListener(event, listener); } emit(event: string, ...args: unknown[]): void { this.events.emit(event, ...args); } } class FakeView implements AgentBrowserViewPort { readonly webContents = new FakeWebContents(); bounds: { x: number; y: number; width: number; height: number } | null = null; visible = false; setBounds(bounds: { x: number; y: number; width: number; height: number }): void { this.bounds = bounds; } setVisible(visible: boolean): void { this.visible = visible; } } class FakeAdapter implements AgentBrowserAdapter { readonly views: FakeView[] = []; readonly partitions: string[] = []; readonly resetPartitions: string[] = []; mounted = 0; unmounted = 0; destroyed = 0; onCreate?: (view: FakeView) => void; resetHandler?: (partition: string) => Promise; createView(partition: string): AgentBrowserViewPort { const view = new FakeView(); this.views.push(view); this.partitions.push(partition); this.onCreate?.(view); return view; } mount(): void { this.mounted += 1; } unmount(): void { this.unmounted += 1; } destroy(view: AgentBrowserViewPort): void { this.destroyed += 1; (view.webContents as FakeWebContents).destroyed = true; } async resetPartition(partition: string): Promise { this.resetPartitions.push(partition); await this.resetHandler?.(partition); } } const projectPath = 'D:\\student\\clock'; async function openBrowser(adapter = new FakeAdapter()) { const module = new AgentBrowserModule(adapter); const opening = module.open({ projectId: 'clock', projectPath, url: 'http://127.0.0.1:4173', visible: true, bounds: { x: 10, y: 20, width: 800, height: 600 }, }); const snapshot = await opening; return { adapter, module, snapshot, view: adapter.views[0] }; } describe('AgentBrowserModule', () => { it('primes the renderer before attaching CDP and loading the shared page', async () => { const adapter = new FakeAdapter(); adapter.onCreate = (view) => { view.webContents.debugger.responders.set('Runtime.enable', async () => { if (view.webContents.getURL() !== 'about:blank') { throw new Error('Renderer execution context is not ready.'); } return {}; }); }; const { snapshot, view } = await openBrowser(adapter); expect(snapshot).toMatchObject({ projectId: 'clock', state: 'attached', generation: 1, visible: true, bounds: { x: 10, y: 20, width: 800, height: 600 }, }); expect(adapter.partitions[0]).toMatch(/^persist:niancode-agent-browser:[a-f0-9]{32}$/); expect(view.webContents.windowOpenDenied).toBe(true); expect(view.webContents.navigationHistory.clearCalls).toBe(1); expect(view.webContents.debugger.operationLog).toEqual([ 'load:about:blank', 'attach:1.3', 'command:Runtime.enable', 'command:Log.enable', 'command:Network.enable', 'command:Page.enable', 'command:Target.setAutoAttach', 'load:http://127.0.0.1:4173/', ]); }); it('finishes navigation when the main document is DOM-ready even if loadURL stays pending', async () => { vi.useFakeTimers(); try { const adapter = new FakeAdapter(); adapter.onCreate = (view) => { view.webContents.loadHandler = async (url) => { if (url === 'about:blank') return; await new Promise(() => undefined); }; }; const module = new AgentBrowserModule(adapter); const opening = module.open({ projectId: 'clock', projectPath, url: 'https://www.baidu.com', bounds: { x: 0, y: 0, width: 800, height: 600 }, }); const openingResult = opening.catch((error: unknown) => error); await vi.advanceTimersByTimeAsync(0); const view = adapter.views[0]; expect(view.webContents.loadCalls).toContain('https://www.baidu.com/'); view.webContents.emit( 'did-fail-load', {}, -105, 'ERR_NAME_NOT_RESOLVED', 'https://optional.example.test/image.png', false, ); view.webContents.emit('dom-ready'); await vi.advanceTimersByTimeAsync(30_000); expect(await openingResult).toMatchObject({ state: 'attached', url: 'https://www.baidu.com/', }); expect(view.webContents.events.listenerCount('dom-ready')).toBe(0); expect(view.webContents.events.listenerCount('did-fail-load')).toBe(0); } finally { vi.useRealTimers(); } }); it('ignores the previous DOM-ready load being aborted by the next navigation', async () => { vi.useFakeTimers(); const adapter = new FakeAdapter(); const module = new AgentBrowserModule(adapter); try { adapter.onCreate = (view) => { view.webContents.loadHandler = async (url) => { if (url === 'about:blank') return; await new Promise(() => undefined); }; }; const opening = module.open({ projectId: 'clock', projectPath, url: 'https://www.baidu.com', bounds: { x: 0, y: 0, width: 800, height: 600 }, }); const openingResult = opening.catch((error: unknown) => error); await vi.advanceTimersByTimeAsync(0); const view = adapter.views[0]; view.webContents.emit('dom-ready'); await vi.advanceTimersByTimeAsync(0); expect(await openingResult).toMatchObject({ state: 'attached' }); const navigating = module.navigate({ projectPath, action: 'url', url: 'https://example.com', }); const navigationResult = navigating.catch((error: unknown) => error); await vi.advanceTimersByTimeAsync(0); view.webContents.emit( 'did-fail-load', {}, -3, 'ERR_ABORTED', 'https://www.baidu.com/', true, ); view.webContents.emit('dom-ready'); await vi.advanceTimersByTimeAsync(0); expect(await navigationResult).toMatchObject({ state: 'attached', url: 'https://example.com/', }); } finally { await module.close(projectPath); vi.useRealTimers(); } }); it('cleans temporary navigation listeners when navigation times out', async () => { vi.useFakeTimers(); const { module, view } = await openBrowser(); try { view.webContents.loadHandler = async () => await new Promise(() => undefined); const navigating = module.navigate({ projectPath, action: 'url', url: 'https://slow.example.com', }); const navigationResult = navigating.catch((error: unknown) => error); await vi.advanceTimersByTimeAsync(30_000); expect(await navigationResult).toMatchObject({ code: 'CDP_TIMEOUT' }); expect(view.webContents.events.listenerCount('dom-ready')).toBe(0); expect(view.webContents.events.listenerCount('did-fail-load')).toBe(0); } finally { await module.close(projectPath); vi.useRealTimers(); } }); it('rejects a main-frame load failure and cleans temporary listeners', async () => { const { module, view } = await openBrowser(); try { view.webContents.loadHandler = async () => await new Promise(() => undefined); const navigating = module.navigate({ projectPath, action: 'url', url: 'https://missing.example.com', }); const navigationResult = navigating.catch((error: unknown) => error); await vi.waitFor(() => { expect(view.webContents.events.listenerCount('did-fail-load')).toBe(1); }); view.webContents.emit( 'did-fail-load', {}, -105, 'ERR_NAME_NOT_RESOLVED', 'https://missing.example.com/', true, ); expect(await navigationResult).toMatchObject({ code: 'CDP_PROTOCOL_ERROR', message: 'ERR_NAME_NOT_RESOLVED', }); expect(view.webContents.events.listenerCount('dom-ready')).toBe(0); expect(view.webContents.events.listenerCount('did-fail-load')).toBe(0); } finally { await module.close(projectPath); } }); it('captures console and network events emitted during the first load', async () => { const adapter = new FakeAdapter(); adapter.onCreate = (view) => { view.webContents.onLoad = () => { view.webContents.debugger.message('Runtime.consoleAPICalled', { type: 'log', args: [{ value: 'ready' }], }); view.webContents.debugger.message('Network.requestWillBeSent', { requestId: 'request-1', request: { url: 'http://localhost:3000/api/data' }, }); }; }; const module = new AgentBrowserModule(adapter); await module.open({ projectId: 'clock', projectPath, url: 'http://localhost:3000', }); await module.present({ projectPath, visible: true, bounds: { x: 0, y: 0, width: 800, height: 600 }, }); const page = await module.readEvents({ projectPath, after: 0 }); expect(page.events.map((event) => event.method)).toEqual([ 'Runtime.consoleAPICalled', 'Network.requestWillBeSent', ]); expect(page.events.every((event) => event.generation === 1)).toBe(true); }); it('keeps a view hidden until open/present receives current bounds', async () => { const adapter = new FakeAdapter(); const module = new AgentBrowserModule(adapter); const first = await module.open({ projectId: 'clock', projectPath, url: 'http://localhost:3000', visible: true, }); expect(first).toMatchObject({ visible: false, bounds: null }); expect(adapter.views[0].visible).toBe(false); await expect(module.sendCdp({ projectPath, method: 'Runtime.evaluate', })).rejects.toMatchObject({ code: 'VIEWPORT_NOT_READY' }); await expect(module.readEvents({ projectPath, after: 0, })).rejects.toMatchObject({ code: 'VIEWPORT_NOT_READY' }); await expect(module.present({ projectPath, visible: true, })).rejects.toMatchObject({ code: 'VIEWPORT_NOT_READY' }); await module.present({ projectPath, visible: true, bounds: { x: 1, y: 2, width: 640, height: 480 }, }); await expect(module.sendCdp({ projectPath, method: 'Runtime.evaluate', })).resolves.toMatchObject({ kind: 'inline' }); await module.present({ projectPath, visible: false }); await expect(module.sendCdp({ projectPath, method: 'Runtime.evaluate', })).rejects.toMatchObject({ code: 'VIEWPORT_NOT_READY' }); const reopened = await module.open({ projectId: 'clock', projectPath, url: 'http://localhost:3000', visible: true, }); expect(reopened).toMatchObject({ visible: false, bounds: { x: 1, y: 2, width: 640, height: 480 }, }); expect(adapter.views[0].visible).toBe(false); }); it('tears down the native view when initial navigation fails', async () => { const adapter = new FakeAdapter(); adapter.onCreate = (view) => { view.webContents.loadHandler = async (url) => { if (url !== 'about:blank') throw new Error('ERR_CONNECTION_REFUSED'); }; }; const module = new AgentBrowserModule(adapter); await expect(module.open({ projectId: 'clock', projectPath, url: 'http://localhost:3999', bounds: { x: 0, y: 0, width: 640, height: 480 }, })).rejects.toMatchObject({ code: 'CDP_PROTOCOL_ERROR' }); expect(adapter).toMatchObject({ unmounted: 1, destroyed: 1 }); await expect(module.getSnapshot()).resolves.toMatchObject({ state: 'closed' }); }); it('lets close preempt a never-settling initial navigation', async () => { const adapter = new FakeAdapter(); adapter.onCreate = (view) => { view.webContents.loadHandler = async (url) => { if (url !== 'about:blank') { await new Promise(() => undefined); } }; }; const module = new AgentBrowserModule(adapter); const opening = module.open({ projectId: 'clock', projectPath, url: 'http://localhost:3000', bounds: { x: 0, y: 0, width: 640, height: 480 }, }); const openingResult = opening.catch((error: unknown) => error); await vi.waitFor(() => expect(adapter.views).toHaveLength(1)); await expect(module.close(projectPath)).resolves.toMatchObject({ state: 'closed' }); expect(await openingResult).toMatchObject({ code: 'CLOSED' }); expect(adapter).toMatchObject({ unmounted: 1, destroyed: 1 }); }); it.each([ 'Runtime.evaluate', 'DOM.getDocument', 'CSS.getComputedStyleForNode', 'Network.getResponseBody', 'Performance.getMetrics', ])('allows page-scoped Full CDP command %s', async (method) => { const { module, view } = await openBrowser(); view.webContents.debugger.responders.set(method, async () => ({ method })); await expect(module.sendCdp({ projectPath, method })).resolves.toEqual({ kind: 'inline', value: { method }, }); }); it.each([ ['Browser.close', 'CDP_METHOD_BLOCKED'], ['Browser.setWindowBounds', 'CDP_METHOD_BLOCKED'], ['Page.crash', 'CDP_METHOD_BLOCKED'], ['Target.getTargets', 'TARGET_DENIED'], ['SystemInfo.getInfo', 'CDP_METHOD_BLOCKED'], ['Memory.forciblyPurgeJavaScriptMemory', 'CDP_METHOD_BLOCKED'], ['Security.setIgnoreCertificateErrors', 'CDP_METHOD_BLOCKED'], ['DOM.setFileInputFiles', 'CDP_METHOD_BLOCKED'], ['Extensions.loadUnpacked', 'CDP_METHOD_BLOCKED'], ['Tethering.bind', 'CDP_METHOD_BLOCKED'], ['DeviceAccess.enable', 'CDP_METHOD_BLOCKED'], ])('blocks host-affecting CDP command %s', async (method, code) => { const { module } = await openBrowser(); await expect(module.sendCdp({ projectPath, method })).rejects.toMatchObject({ code }); }); it.each([ ['Page.navigate', { url: 'file:///C:/Windows/win.ini' }], ['Network.loadNetworkResource', { url: 'file:///etc/passwd' }], ['Fetch.continueRequest', { requestId: 'request-1', url: 'data:text/plain,secret' }], ])('blocks non-web URLs in CDP command %s', async (method, params) => { const { module } = await openBrowser(); await expect(module.sendCdp({ projectPath, method, params, })).rejects.toMatchObject({ code: 'CDP_METHOD_BLOCKED' }); }); it('only accepts child sessions auto-attached from the current page', async () => { const { module, view } = await openBrowser(); await expect(module.sendCdp({ projectPath, method: 'Runtime.evaluate', sessionRef: 'unknown-session', })).rejects.toMatchObject({ code: 'TARGET_DENIED' }); view.webContents.debugger.message('Target.attachedToTarget', { sessionId: 'worker-session', targetInfo: { type: 'worker' }, }); await expect(module.sendCdp({ projectPath, method: 'Runtime.evaluate', params: { expression: 'self.location.href' }, sessionRef: 'worker-session', })).resolves.toMatchObject({ kind: 'inline' }); view.webContents.debugger.message('Target.detachedFromTarget', { sessionId: 'worker-session', }); await expect(module.sendCdp({ projectPath, method: 'Runtime.evaluate', sessionRef: 'worker-session', })).rejects.toMatchObject({ code: 'TARGET_DENIED' }); }); it.each(['targetId', 'browserContextId'])( 'rejects a foreign CDP scope supplied through params.%s', async (field) => { const { module } = await openBrowser(); await expect(module.sendCdp({ projectPath, method: 'Runtime.evaluate', params: { expression: '1', [field]: 'foreign-target' }, })).rejects.toMatchObject({ code: 'TARGET_DENIED' }); }, ); it('moves oversized events and command results into bounded payload handles', async () => { const { module, view } = await openBrowser(); const largeText = 'x'.repeat(70 * 1024); view.webContents.debugger.message('Network.dataReceived', { data: largeText }); const page = await module.readEvents({ projectPath, after: 0 }); expect(page.events[0]).toMatchObject({ method: 'Network.dataReceived', payload: { kind: 'payload', contentType: 'application/json' }, }); expect(page.events[0]).not.toHaveProperty('params'); const eventPayload = page.events[0].payload; expect(eventPayload).toBeDefined(); const eventChunk = await module.readPayload({ projectPath, handle: eventPayload!.handle, maxBytes: 1024 * 1024, }); expect(JSON.parse(eventChunk.data)).toEqual({ data: largeText }); view.webContents.debugger.responders.set('Network.getResponseBody', async () => ({ body: largeText, base64Encoded: false, })); const result = await module.sendCdp({ projectPath, method: 'Network.getResponseBody', params: { requestId: 'request-1' }, }); expect(result).toMatchObject({ kind: 'payload' }); if (result.kind !== 'payload') throw new Error('Expected a payload result.'); const resultChunk = await module.readPayload({ projectPath, handle: result.handle, maxBytes: 1024 * 1024, }); expect(JSON.parse(resultChunk.data)).toEqual({ body: largeText, base64Encoded: false, }); }); it('reports a debugger gap and reattaches with a new generation after DevTools closes', async () => { const { module, view } = await openBrowser(); view.webContents.debugger.message('Runtime.consoleAPICalled', { type: 'log' }); const before = await module.readEvents({ projectPath, after: 0 }); view.webContents.devToolsOpen = true; view.webContents.emit('devtools-opened'); view.webContents.debugger.detached(); await expect(module.sendCdp({ projectPath, method: 'Runtime.evaluate', })).rejects.toMatchObject({ code: 'DEVTOOLS_CONFLICT' }); view.webContents.devToolsOpen = false; view.webContents.emit('devtools-closed'); await vi.waitFor(async () => { const snapshot = await module.getSnapshot(projectPath); expect(snapshot).toMatchObject({ state: 'attached', generation: 2 }); }); view.webContents.debugger.message('Runtime.consoleAPICalled', { type: 'info' }); const after = await module.readEvents({ projectPath, after: before.nextCursor, }); expect(after.gap?.reason).toBe('debugger-detached'); expect(after.events[0]).toMatchObject({ generation: 2, method: 'Runtime.consoleAPICalled', }); expect(view.webContents.debugger.attachVersions).toEqual(['1.3', '1.3']); }); it('keeps a late DevTools reattach from reviving a closed browser', async () => { const { module, view } = await openBrowser(); view.webContents.devToolsOpen = true; view.webContents.emit('devtools-opened'); view.webContents.debugger.detached(); let finishAttach: (() => void) | undefined; view.webContents.debugger.responders.set( 'Runtime.enable', async () => await new Promise((resolvePromise) => { finishAttach = resolvePromise; }), ); view.webContents.devToolsOpen = false; view.webContents.emit('devtools-closed'); await vi.waitFor(() => { expect(view.webContents.debugger.attachVersions).toEqual(['1.3', '1.3']); }); await module.close(projectPath); finishAttach?.(); await Promise.resolve(); await Promise.resolve(); await expect(module.getSnapshot()).resolves.toMatchObject({ state: 'closed' }); }); it('closes the browser and rejects queued commands when active CDP times out', async () => { vi.useFakeTimers(); try { const { module, view } = await openBrowser(); view.webContents.debugger.responders.set( 'Runtime.evaluate', async () => await new Promise(() => undefined), ); view.webContents.debugger.responders.set('Performance.getMetrics', async () => ({ second: true, })); const first = module.sendCdp({ projectPath, method: 'Runtime.evaluate', timeoutMs: 10, }).catch((error: unknown) => error); const second = module.sendCdp({ projectPath, method: 'Performance.getMetrics', }).catch((error: unknown) => error); const navigation = module.navigate({ projectPath, action: 'reload', }).catch((error: unknown) => error); await vi.advanceTimersByTimeAsync(10); expect(await first).toMatchObject({ code: 'CDP_TIMEOUT', outcome: 'unknown', }); expect(await second).toMatchObject({ code: 'CLOSED' }); expect(await navigation).toMatchObject({ code: 'CLOSED' }); expect(view.webContents.debugger.commands.some( (command) => command.method === 'Performance.getMetrics', )).toBe(false); await expect(module.getSnapshot()).resolves.toMatchObject({ state: 'closed' }); } finally { vi.useRealTimers(); } }); it('times out a CDP command while it is queued behind a hung command', async () => { vi.useFakeTimers(); try { const { module, view } = await openBrowser(); view.webContents.debugger.responders.set( 'Runtime.evaluate', async () => await new Promise(() => undefined), ); const first = module.sendCdp({ projectPath, method: 'Runtime.evaluate', timeoutMs: 100, }).catch((error: unknown) => error); const queued = module.sendCdp({ projectPath, method: 'Performance.getMetrics', timeoutMs: 20, }).catch((error: unknown) => error); await vi.advanceTimersByTimeAsync(20); await expect(first).resolves.toMatchObject({ code: 'CLOSED' }); await expect(queued).resolves.toMatchObject({ code: 'CDP_TIMEOUT', outcome: 'unknown', }); expect(view.webContents.debugger.commands.some( (command) => command.method === 'Performance.getMetrics', )).toBe(false); await expect(module.getSnapshot()).resolves.toMatchObject({ state: 'closed' }); } finally { vi.useRealTimers(); } }); it('preemptively closes a timed-out command and allows a new generation to open', async () => { vi.useFakeTimers(); try { const { adapter, module, view } = await openBrowser(); let resolveLate: ((value: unknown) => void) | undefined; view.webContents.debugger.responders.set( 'Runtime.evaluate', async () => await new Promise((resolvePromise) => { resolveLate = resolvePromise; }), ); const command = module.sendCdp({ projectPath, method: 'Runtime.evaluate', timeoutMs: 10, }); const commandResult = command.catch((error: unknown) => error); await vi.advanceTimersByTimeAsync(10); expect(await commandResult).toMatchObject({ code: 'CDP_TIMEOUT' }); await expect(module.getSnapshot()).resolves.toMatchObject({ state: 'closed' }); const reopened = module.open({ projectId: 'clock', projectPath, url: 'http://localhost:3001', bounds: { x: 0, y: 0, width: 800, height: 600 }, }); await expect(reopened).resolves.toMatchObject({ state: 'attached', generation: 2, }); expect(adapter.views).toHaveLength(2); resolveLate?.({ result: { value: 'late' } }); await vi.advanceTimersByTimeAsync(0); await expect(module.getSnapshot(projectPath)).resolves.toMatchObject({ state: 'attached', generation: 2, url: 'http://localhost:3001/', }); } finally { vi.useRealTimers(); } }); it('cleans up the debugger, view, payloads, and profile on close/reset', async () => { const { adapter, module, view } = await openBrowser(); await module.close(projectPath); expect(view.webContents.debugger.attached).toBe(false); expect(adapter).toMatchObject({ unmounted: 1, destroyed: 1 }); await expect(module.getSnapshot()).resolves.toMatchObject({ state: 'closed' }); await module.resetProfile(projectPath); expect(adapter.resetPartitions).toEqual([agentBrowserPartition(projectPath)]); }); it('does not reopen a project partition until profile reset has finished', async () => { const adapter = new FakeAdapter(); let finishReset: (() => void) | undefined; adapter.resetHandler = async () => await new Promise((resolvePromise) => { finishReset = resolvePromise; }); const { module } = await openBrowser(adapter); const resetting = module.resetProfile(projectPath); await vi.waitFor(() => expect(adapter.resetPartitions).toHaveLength(1)); const reopening = module.open({ projectId: 'clock', projectPath, url: 'http://localhost:3001', bounds: { x: 0, y: 0, width: 800, height: 600 }, }); await Promise.resolve(); expect(adapter.views).toHaveLength(1); finishReset?.(); await expect(resetting).resolves.toMatchObject({ state: 'closed' }); await expect(reopening).resolves.toMatchObject({ state: 'attached', url: 'http://localhost:3001/', }); expect(adapter.views).toHaveLength(2); }); it('surfaces renderer crashes and rejects access from a different project', async () => { const { module, view } = await openBrowser(); await expect(module.getSnapshot('D:\\student\\other')).rejects.toMatchObject({ code: 'PROJECT_MISMATCH', }); view.webContents.emit('render-process-gone', {}, { reason: 'crashed' }); await expect(module.getSnapshot(projectPath)).resolves.toMatchObject({ state: 'crashed', error: { code: 'RENDERER_CRASHED' }, }); await expect(module.sendCdp({ projectPath, method: 'Runtime.evaluate', })).rejects.toMatchObject({ code: 'RENDERER_CRASHED' }); }); }); describe('AgentBrowserEventBuffer', () => { it('bounds retained events and reports an explicit eviction gap', () => { const buffer = new AgentBrowserEventBuffer({ maxEvents: 2, maxBytes: 1024 }); for (let index = 0; index < 3; index += 1) { buffer.push({ generation: 1, timestamp: index, method: `Test.event${index}`, }); } expect(buffer.read(0)).toMatchObject({ events: [ { sequence: 2, method: 'Test.event1' }, { sequence: 3, method: 'Test.event2' }, ], nextCursor: 3, gap: { reason: 'evicted', oldestAvailable: 2 }, }); }); it('advances independent filtered readers without consuming shared events', () => { const buffer = new AgentBrowserEventBuffer(); buffer.push({ generation: 1, timestamp: 1, method: 'Runtime.one' }); buffer.push({ generation: 1, timestamp: 2, method: 'Network.one' }); const runtime = buffer.read(0, ['Runtime.one']); const network = buffer.read(0, ['Network.one']); expect(runtime.events.map((event) => event.method)).toEqual(['Runtime.one']); expect(network.events.map((event) => event.method)).toEqual(['Network.one']); expect(runtime.nextCursor).toBe(2); expect(network.nextCursor).toBe(2); }); }); describe('AgentBrowserPayloadStore', () => { it('expires payloads and keeps UTF-8 chunk boundaries valid', () => { let now = 1_000; const store = new AgentBrowserPayloadStore({ ttlMs: 100, maxBytes: 1024, maxEntryBytes: 1024, now: () => now, }); const payload = store.put(Buffer.from('你好吗'), 'text/plain'); const tiny = store.read(payload.handle, 0, 1); expect(tiny).toMatchObject({ data: '你', nextOffset: 3, done: false, }); const first = store.read(payload.handle, 0, 4); expect(first).toMatchObject({ data: '你', encoding: 'utf8', nextOffset: 3, done: false, }); expect(() => store.read(payload.handle, 1, 3)).toThrowError( expect.objectContaining({ code: 'INVALID_REQUEST' }), ); now = 1_101; expect(() => store.read(payload.handle)).toThrowError( expect.objectContaining({ code: 'PAYLOAD_NOT_FOUND' }), ); }); it('evicts old payloads to honor the memory cap and rejects oversized entries', () => { const store = new AgentBrowserPayloadStore({ maxBytes: 10, maxEntryBytes: 10, }); const first = store.put(Buffer.from('123456'), 'text/plain'); const second = store.put(Buffer.from('abcdef'), 'text/plain'); expect(() => store.read(first.handle)).toThrowError( expect.objectContaining({ code: 'PAYLOAD_NOT_FOUND' }), ); expect(store.read(second.handle).data).toBe('abcdef'); expect(() => store.put(Buffer.from('12345678901'), 'text/plain')).toThrowError( expect.objectContaining({ code: 'PAYLOAD_TOO_LARGE' }), ); }); }); describe('AgentBrowserCdpGuard', () => { it('only permits IO handles issued by the current CDP session', () => { const guard = new AgentBrowserCdpGuard(); expect(() => guard.assertAllowed( 'IO.read', { handle: 'owned' }, undefined, new Set(), new Set(['owned']), )).not.toThrow(); expect(() => guard.assertAllowed( 'IO.read', { handle: 'foreign' }, undefined, new Set(), new Set(['owned']), )).toThrowError(AgentBrowserFault); }); });