import { EventEmitter } from 'node:events'; import path from 'node:path'; import { runInNewContext } from 'node:vm'; 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'; import { createStaticArtifactSnapshot } from '@electron/services/static-release-server'; 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; evaluateHandler?: (code: 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; } async executeJavaScript(code: string): Promise { const overridden = this.debugger.commands.findLast( (command) => command.method === 'Emulation.setDeviceMetricsOverride', )?.params; return await (this.evaluateHandler?.(code) ?? Promise.resolve({ readyState: 'complete', visible: true, viewport: { width: overridden?.width, height: overridden?.height }, })); } 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; destroyHandler?: () => void; readonly restrictedOrigins: Array<{ partition: string; origin: string; released: boolean }> = []; 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; this.destroyHandler?.(); } async resetPartition(partition: string): Promise { this.resetPartitions.push(partition); await this.resetHandler?.(partition); } restrictPartitionToOrigin(partition: string, origin: string): () => void { const record = { partition, origin, released: false }; this.restrictedOrigins.push(record); return () => { record.released = true; }; } } class FakePreviewDataSession { readonly opens: Array<{ projectPath: string; origin: string; browserGeneration: number }> = []; readonly invalidations: string[] = []; private current: { projectPath: string; projectId: string; origin: string; browserGeneration: number; createdAt: number; } | null = null; private token = 'preview-token'; private readonly listeners = new Set<(reason: string) => void>(); openHandler?: (input: { projectPath: string; origin: string; browserGeneration: number }) => Promise; async open(input: { projectPath: string; origin: string; browserGeneration: number }) { this.opens.push(input); await this.openHandler?.(input); this.current = { ...input, projectId: 'clock', createdAt: 1, }; return this.current; } getInjectionValue() { if (!this.current) return null; return { endpoint: 'http://127.0.0.1:13210/api/runtime/data/v1', token: this.token, contractVersion: 1 as const, }; } invalidate(reason = 'manual'): void { this.invalidations.push(reason); this.current = null; for (const listener of this.listeners) listener(reason); } subscribeInvalidation(listener: (reason: string) => void): () => void { this.listeners.add(listener); return () => this.listeners.delete(listener); } } const projectPath = path.resolve('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('preflights a Main-owned static artifact without requiring or mutating a browser record', async () => { const artifact = createStaticArtifactSnapshot([{ path: 'index.html', bytes: Buffer.from('
ok
') }]); const adapter = new FakeAdapter(); const module = new AgentBrowserModule(adapter); try { await expect(module.preflightStaticArtifact(artifact)).resolves.toEqual({ ok: true }); expect(await module.getSnapshot()).toMatchObject({ state: 'closed', browserId: null }); expect(adapter.views.map((view) => view.bounds)).toEqual([ { x: 0, y: 0, width: 1280, height: 720 }, { x: 0, y: 0, width: 390, height: 844 }, ]); expect(adapter.destroyed).toBe(2); expect(adapter.resetPartitions).toEqual(adapter.partitions); } finally { await module.dispose(); } }); it('reports forged artifact snapshots as infrastructure unavailable', async () => { const module = new AgentBrowserModule(new FakeAdapter()); await expect(module.preflightStaticArtifact({} as never)).rejects.toMatchObject({ code: 'PUBLISH_PREFLIGHT_UNAVAILABLE', message: '暂时无法启动作品检查,请稍后重试。', }); }); it('preflights desktop and mobile viewports in temporary non-persistent profiles', async () => { const adapter = new FakeAdapter(); adapter.onCreate = (view) => { view.webContents.evaluateHandler = async () => { const metrics = view.webContents.debugger.commands.findLast( (command) => command.method === 'Emulation.setDeviceMetricsOverride', )?.params; return { readyState: 'complete', visible: true, viewport: { width: metrics?.width, height: metrics?.height }, }; }; }; const module = new AgentBrowserModule(adapter); await module.open({ projectId: 'clock', projectPath, url: 'http://127.0.0.1:4173/' }); await expect(module.preflightCurrentProject(projectPath)).resolves.toEqual({ ok: true }); expect(adapter.partitions).toHaveLength(3); expect(adapter.partitions.slice(1).every((partition) => partition.startsWith('niancode-publish-preflight:'))).toBe(true); expect(adapter.partitions.slice(1).every((partition) => !partition.startsWith('persist:'))).toBe(true); expect(adapter.views.slice(1).map((view) => view.bounds)).toEqual([ { x: 0, y: 0, width: 1280, height: 720 }, { x: 0, y: 0, width: 390, height: 844 }, ]); expect(adapter.mounted).toBe(1); expect(adapter.destroyed).toBe(2); expect(adapter.resetPartitions).toEqual(adapter.partitions.slice(1)); expect(adapter.restrictedOrigins).toEqual(adapter.partitions.slice(1).map((partition) => ({ partition, origin: 'http://127.0.0.1:4173', released: true, }))); }); it.each([ ['console error', (view: FakeView) => view.webContents.debugger.message('Runtime.consoleAPICalled', { type: 'error' })], ['page exception', (view: FakeView) => view.webContents.debugger.message('Runtime.exceptionThrown', { exceptionDetails: {} })], ['log error', (view: FakeView) => view.webContents.debugger.message('Log.entryAdded', { entry: { level: 'error' } })], ['failed response', (view: FakeView) => view.webContents.debugger.message('Network.responseReceived', { response: { status: 404 } })], ['external request', (view: FakeView) => view.webContents.debugger.message('Network.requestWillBeSent', { request: { url: 'https://evil.example/track' } })], ])('fails and cleans up a publish preflight on %s', async (_case, trigger) => { const adapter = new FakeAdapter(); adapter.onCreate = (view) => { view.webContents.evaluateHandler = async () => { trigger(view); const metrics = view.webContents.debugger.commands.findLast( (command) => command.method === 'Emulation.setDeviceMetricsOverride', )?.params; return { readyState: 'complete', visible: true, viewport: { width: metrics?.width, height: metrics?.height } }; }; }; const module = new AgentBrowserModule(adapter); await module.open({ projectId: 'clock', projectPath, url: 'http://127.0.0.1:4173/' }); await expect(module.preflightCurrentProject(projectPath)).rejects.toMatchObject({ code: 'PUBLISH_PREFLIGHT_RUNTIME_ERROR', }); expect(adapter.destroyed).toBe(1); expect(adapter.resetPartitions).toEqual(adapter.partitions.slice(1)); }); it('ignores a cancelled navigation resource instead of reporting a runtime error', async () => { const adapter = new FakeAdapter(); adapter.onCreate = (view) => { if (adapter.views.length === 1) return; view.webContents.evaluateHandler = async () => { view.webContents.debugger.message('Network.loadingFailed', { canceled: true, errorText: 'net::ERR_ABORTED', }); const metrics = view.webContents.debugger.commands.findLast( (command) => command.method === 'Emulation.setDeviceMetricsOverride', )?.params; return { readyState: 'complete', visible: true, viewport: { width: metrics?.width, height: metrics?.height } }; }; }; const module = new AgentBrowserModule(adapter); await module.open({ projectId: 'clock', projectPath, url: 'http://127.0.0.1:4173/' }); await expect(module.preflightCurrentProject(projectPath)).resolves.toEqual({ ok: true }); }); it('rejects a top-level redirect to another origin', async () => { const adapter = new FakeAdapter(); adapter.onCreate = (view) => { if (adapter.views.length === 1) return; view.webContents.evaluateHandler = async () => { view.webContents.url = 'http://127.0.0.1:4174/redirected'; return { readyState: 'complete', visible: true }; }; }; const module = new AgentBrowserModule(adapter); await module.open({ projectId: 'clock', projectPath, url: 'http://127.0.0.1:4173/' }); await expect(module.preflightCurrentProject(projectPath)).rejects.toMatchObject({ code: 'PUBLISH_PREFLIGHT_LOAD_FAILED', }); }); it('rejects a white screen without exposing page content and still cleans up', async () => { const adapter = new FakeAdapter(); adapter.onCreate = (view) => { view.webContents.evaluateHandler = async () => ({ readyState: 'complete', visible: false, privateText: 'C:\\private\\student-project', }); }; const module = new AgentBrowserModule(adapter); await module.open({ projectId: 'clock', projectPath, url: 'http://127.0.0.1:4173/' }); await expect(module.preflightCurrentProject(projectPath)).rejects.toMatchObject({ code: 'PUBLISH_PREFLIGHT_BLANK', message: '作品打开后没有可见内容。', }); expect(adapter.destroyed).toBe(1); expect(adapter.resetPartitions).toEqual(adapter.partitions.slice(1)); }); it('continues cleanup when destroy and origin-release fail', async () => { const adapter = new FakeAdapter(); adapter.destroyHandler = () => { throw new Error('destroy failed'); }; adapter.onCreate = (view) => { view.webContents.evaluateHandler = async () => ({ readyState: 'complete', visible: false }); }; const originalRestrict = adapter.restrictPartitionToOrigin.bind(adapter); adapter.restrictPartitionToOrigin = (partition, origin) => { originalRestrict(partition, origin); return () => { throw new Error('release failed'); }; }; const module = new AgentBrowserModule(adapter); await module.open({ projectId: 'clock', projectPath, url: 'http://127.0.0.1:4173/' }); await expect(module.preflightCurrentProject(projectPath)).rejects.toMatchObject({ code: 'PUBLISH_PREFLIGHT_BLANK', }); expect(adapter.destroyed).toBe(1); expect(adapter.resetPartitions).toEqual(adapter.partitions.slice(1)); }); it.each([ 'https://example.com:443/', 'https://localhost:4173/', 'http://localhost/', 'http://user:secret@127.0.0.1:4173/', ])('requires an explicit credential-free loopback preview URL: %s', async (url) => { const adapter = new FakeAdapter(); const module = new AgentBrowserModule(adapter); await module.open({ projectId: 'clock', projectPath, url }); await expect(module.preflightCurrentProject(projectPath)).rejects.toMatchObject({ code: 'PREVIEW_REQUIRED', }); expect(adapter.views).toHaveLength(1); }); it('requires the current project preview and leaves the user browser record untouched', async () => { const { adapter, module, snapshot, view } = await openBrowser(); const before = await module.getSnapshot(projectPath); await expect(module.preflightCurrentProject('D:\\student\\other')).rejects.toMatchObject({ code: 'PREVIEW_REQUIRED', }); expect(await module.getSnapshot(projectPath)).toEqual(before); expect(view.webContents.getURL()).toBe(snapshot.url); expect(adapter.views).toHaveLength(1); }); 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:Target.setAutoAttach', 'load:http://127.0.0.1:4173/', ]); }); it('rejects data-enabled non-loopback targets before creating a view or loading a page', async () => { const adapter = new FakeAdapter(); const preview = new FakePreviewDataSession(); const module = new AgentBrowserModule(adapter, { previewDataSession: preview }); await expect(module.open({ projectId: 'clock', projectPath, url: 'https://example.com/app', injectProjectData: true, })).rejects.toMatchObject({ code: 'TARGET_DENIED' }); expect(adapter.views).toHaveLength(0); expect(preview.opens).toHaveLength(0); }); it('installs serialized preview data after attach and before the first target document load', async () => { const adapter = new FakeAdapter(); const preview = new FakePreviewDataSession(); adapter.onCreate = (view) => { view.webContents.debugger.responders.set('Page.addScriptToEvaluateOnNewDocument', async () => ({ identifier: 'script-root', })); }; const module = new AgentBrowserModule(adapter, { previewDataSession: preview }); const snapshot = await module.open({ projectId: 'clock', projectPath, url: 'http://127.0.0.1:4173/', injectProjectData: true, visible: true, bounds: { x: 10, y: 20, width: 800, height: 600 }, }); const view = adapter.views[0]; const addScript = view.webContents.debugger.commands.find( (command) => command.method === 'Page.addScriptToEvaluateOnNewDocument', ); expect(snapshot).toMatchObject({ state: 'attached', url: 'http://127.0.0.1:4173/' }); expect(preview.opens).toEqual([{ projectPath, origin: 'http://127.0.0.1:4173', browserGeneration: 1, }]); expect(view.webContents.debugger.operationLog).toEqual([ 'load:about:blank', 'attach:1.3', 'command:Target.setAutoAttach', 'command:Page.enable', 'command:Page.addScriptToEvaluateOnNewDocument', 'load:http://127.0.0.1:4173/', ]); expect(view.webContents.debugger.operationLog.indexOf('command:Page.enable')) .toBeLessThan(view.webContents.debugger.operationLog.indexOf( 'command:Page.addScriptToEvaluateOnNewDocument', )); expect(view.webContents.debugger.commands.find((command) => command.method === 'Page.enable')?.sessionRef) .toBeUndefined(); expect(addScript?.params?.sessionRef).toBeUndefined(); expect(addScript?.params?.source).toContain('globalThis.location?.origin'); expect(addScript?.params?.source).toContain('http://127.0.0.1:13210/api/runtime/data/v1'); expect(addScript?.params?.source).toContain('preview-token'); expect(addScript?.params?.source).toContain('contractVersion'); const source = addScript?.params?.source; expect(typeof source).toBe('string'); const sameOriginGlobal: Record = { location: { origin: 'http://127.0.0.1:4173' }, }; runInNewContext(source as string, sameOriginGlobal); expect(sameOriginGlobal.__MAKELORE_DATA__).toEqual({ endpoint: 'http://127.0.0.1:13210/api/runtime/data/v1', token: 'preview-token', contractVersion: 1, }); const descriptor = Object.getOwnPropertyDescriptor(sameOriginGlobal, '__MAKELORE_DATA__'); expect(descriptor).toMatchObject({ enumerable: false, configurable: false }); expect(descriptor?.get).toEqual(expect.any(Function)); expect(descriptor).not.toHaveProperty('value'); const externalGlobal: Record = { location: { origin: 'https://external.example' }, }; runInNewContext(source as string, externalGlobal); expect(externalGlobal.__MAKELORE_DATA__).toBeUndefined(); }); it('resolves preview data after an early unstable Origin settles', async () => { const adapter = new FakeAdapter(); const preview = new FakePreviewDataSession(); adapter.onCreate = (view) => { view.webContents.debugger.responders.set('Page.addScriptToEvaluateOnNewDocument', async () => ({ identifier: 'script-root', })); }; const module = new AgentBrowserModule(adapter, { previewDataSession: preview }); await module.open({ projectId: 'clock', projectPath, url: 'http://127.0.0.1:4173/', injectProjectData: true, }); const source = adapter.views[0].webContents.debugger.commands.find( (command) => command.method === 'Page.addScriptToEvaluateOnNewDocument', )?.params?.source; expect(typeof source).toBe('string'); const changingGlobal: { location: { origin: string }; __MAKELORE_DATA__?: unknown } = { location: { origin: 'null' }, }; runInNewContext(source as string, changingGlobal); expect(changingGlobal.__MAKELORE_DATA__).toBeUndefined(); changingGlobal.location.origin = 'http://127.0.0.1:4173'; expect(changingGlobal.__MAKELORE_DATA__).toEqual({ endpoint: 'http://127.0.0.1:13210/api/runtime/data/v1', token: 'preview-token', contractVersion: 1, }); const descriptor = Object.getOwnPropertyDescriptor(changingGlobal, '__MAKELORE_DATA__'); expect(descriptor).toMatchObject({ enumerable: false, configurable: false }); expect(descriptor?.get).toEqual(expect.any(Function)); expect(String(descriptor?.get)).not.toContain('preview-token'); changingGlobal.location.origin = 'https://external.example'; expect(changingGlobal.__MAKELORE_DATA__).toBeUndefined(); }); it('uses the target Origin when the prime navigation reports about:blank', async () => { const adapter = new FakeAdapter(); const preview = new FakePreviewDataSession(); adapter.onCreate = (view) => { view.webContents.loadHandler = async (url) => { view.webContents.emit('did-navigate', {}, url); }; view.webContents.debugger.responders.set('Page.addScriptToEvaluateOnNewDocument', async () => ({ identifier: 'script-root', })); }; const module = new AgentBrowserModule(adapter, { previewDataSession: preview }); await module.open({ projectId: 'clock', projectPath, url: 'http://127.0.0.1:4173/', injectProjectData: true, }); expect(preview.opens).toEqual([{ projectPath, origin: 'http://127.0.0.1:4173', browserGeneration: 1, }]); }); it('fails and tears down before target load when preview session setup fails', async () => { const adapter = new FakeAdapter(); const preview = new FakePreviewDataSession(); preview.openHandler = async () => { throw new Error('preview session unavailable'); }; const module = new AgentBrowserModule(adapter, { previewDataSession: preview }); await expect(module.open({ projectId: 'clock', projectPath, url: 'http://127.0.0.1:4173/', injectProjectData: true, })).rejects.toMatchObject({ code: 'ATTACH_FAILED' }); expect(adapter.views[0].webContents.loadCalls).toEqual(['about:blank']); expect(adapter).toMatchObject({ unmounted: 1, destroyed: 1 }); }); it('fails and tears down before target load when script installation fails', async () => { const adapter = new FakeAdapter(); const preview = new FakePreviewDataSession(); adapter.onCreate = (view) => { view.webContents.debugger.responders.set('Page.addScriptToEvaluateOnNewDocument', async () => { throw new Error('CDP script registration failed'); }); }; const module = new AgentBrowserModule(adapter, { previewDataSession: preview }); await expect(module.open({ projectId: 'clock', projectPath, url: 'http://127.0.0.1:4173/', injectProjectData: true, })).rejects.toMatchObject({ code: 'ATTACH_FAILED' }); expect(adapter.views[0].webContents.loadCalls).toEqual(['about:blank']); expect(adapter).toMatchObject({ unmounted: 1, destroyed: 1 }); expect(preview.invalidations).toContain('manual'); }); it('installs the same exact-Origin script for a document child session and resumes it after setup', async () => { const adapter = new FakeAdapter(); const preview = new FakePreviewDataSession(); let scriptNumber = 0; adapter.onCreate = (view) => { view.webContents.debugger.responders.set('Page.addScriptToEvaluateOnNewDocument', async () => ({ identifier: `script-${scriptNumber++}`, })); }; const module = new AgentBrowserModule(adapter, { previewDataSession: preview }); await module.open({ projectId: 'clock', projectPath, url: 'http://127.0.0.1:4173/', injectProjectData: true, visible: true, bounds: { x: 10, y: 20, width: 800, height: 600 }, }); const view = adapter.views[0]; view.webContents.debugger.message('Target.attachedToTarget', { sessionId: 'child-session', waitingForDebugger: true, targetInfo: { type: 'iframe' }, }); await vi.waitFor(() => { expect(view.webContents.debugger.commands).toEqual(expect.arrayContaining([ expect.objectContaining({ method: 'Page.enable', sessionRef: 'child-session', }), expect.objectContaining({ method: 'Page.addScriptToEvaluateOnNewDocument', sessionRef: 'child-session', }), expect.objectContaining({ method: 'Runtime.runIfWaitingForDebugger', sessionRef: 'child-session', }), ])); const childEnableIndex = view.webContents.debugger.commands.findIndex( (command) => command.method === 'Page.enable' && command.sessionRef === 'child-session', ); const childAddScriptIndex = view.webContents.debugger.commands.findIndex( (command) => command.method === 'Page.addScriptToEvaluateOnNewDocument' && command.sessionRef === 'child-session', ); expect(childEnableIndex).toBeGreaterThanOrEqual(0); expect(childEnableIndex).toBeLessThan(childAddScriptIndex); }); }); it('removes preview scripts before a requested cross-Origin navigation and invalidates the token', async () => { const adapter = new FakeAdapter(); const preview = new FakePreviewDataSession(); adapter.onCreate = (view) => { view.webContents.debugger.responders.set('Page.addScriptToEvaluateOnNewDocument', async () => ({ identifier: 'script-root', })); }; const module = new AgentBrowserModule(adapter, { previewDataSession: preview }); await module.open({ projectId: 'clock', projectPath, url: 'http://127.0.0.1:4173/', injectProjectData: true, visible: true, bounds: { x: 10, y: 20, width: 800, height: 600 }, }); const view = adapter.views[0]; const initialCommands = view.webContents.debugger.commands.length; await module.navigate({ projectPath, action: 'url', url: 'https://example.com/app', }); expect(preview.invalidations).toContain('cross_origin_navigation'); expect(view.webContents.debugger.commands.slice(initialCommands)).toEqual([ expect.objectContaining({ method: 'Page.removeScriptToEvaluateOnNewDocument', params: { identifier: 'script-root' }, }), expect.objectContaining({ method: 'Target.setAutoAttach', params: { autoAttach: true, waitForDebuggerOnStart: false, flatten: true }, }), ]); expect(view.webContents.loadCalls.at(-1)).toBe('https://example.com/app'); }); it('retains the preview session and new-document script across same-Origin navigation', async () => { const adapter = new FakeAdapter(); const preview = new FakePreviewDataSession(); adapter.onCreate = (view) => { view.webContents.debugger.responders.set('Page.addScriptToEvaluateOnNewDocument', async () => ({ identifier: 'script-root', })); }; const module = new AgentBrowserModule(adapter, { previewDataSession: preview }); await module.open({ projectId: 'clock', projectPath, url: 'http://127.0.0.1:4173/', injectProjectData: true, visible: true, bounds: { x: 0, y: 0, width: 800, height: 600 }, }); await module.navigate({ projectPath, action: 'url', url: 'http://127.0.0.1:4173/next', }); expect(preview.invalidations).toHaveLength(0); expect(adapter.views[0].webContents.debugger.commands).not.toEqual(expect.arrayContaining([ expect.objectContaining({ method: 'Page.removeScriptToEvaluateOnNewDocument' }), ])); }); it('blocks a cross-Origin main-frame redirect until script cleanup completes but ignores cross-Origin frames', async () => { const adapter = new FakeAdapter(); const preview = new FakePreviewDataSession(); adapter.onCreate = (view) => { view.webContents.debugger.responders.set('Page.addScriptToEvaluateOnNewDocument', async () => ({ identifier: 'script-root', })); }; const module = new AgentBrowserModule(adapter, { previewDataSession: preview }); await module.open({ projectId: 'clock', projectPath, url: 'http://127.0.0.1:4173/', injectProjectData: true, }); const view = adapter.views[0]; const preventDefault = vi.fn(); view.webContents.emit( 'will-navigate', { preventDefault }, 'https://external.example/app', false, false, ); expect(preventDefault).not.toHaveBeenCalled(); expect(preview.invalidations).toHaveLength(0); view.webContents.emit( 'will-redirect', { preventDefault }, 'https://external.example/app', false, true, ); await vi.waitFor(() => { expect(preventDefault).toHaveBeenCalledTimes(1); expect(preview.invalidations).toContain('cross_origin_navigation'); expect(view.webContents.debugger.commands).toEqual(expect.arrayContaining([ expect.objectContaining({ method: 'Page.removeScriptToEvaluateOnNewDocument', params: { identifier: 'script-root' }, }), ])); }); await vi.waitFor(() => { expect(view.webContents.loadCalls.some((url) => url.startsWith('https://external.example/app'))).toBe(true); }); }); it('removes scripts when the session manager invalidates externally and requires an explicit reopen', async () => { const adapter = new FakeAdapter(); const preview = new FakePreviewDataSession(); let scriptNumber = 0; adapter.onCreate = (view) => { view.webContents.debugger.responders.set('Page.addScriptToEvaluateOnNewDocument', async () => ({ identifier: `script-${scriptNumber++}`, })); }; const module = new AgentBrowserModule(adapter, { previewDataSession: preview }); await module.open({ projectId: 'clock', projectPath, url: 'http://127.0.0.1:4173/', injectProjectData: true, }); preview.invalidate('session_cleared'); await vi.waitFor(() => expect(adapter.views[0].webContents.debugger.commands).toEqual(expect.arrayContaining([ expect.objectContaining({ method: 'Page.removeScriptToEvaluateOnNewDocument', params: { identifier: 'script-0' }, }), ]))); await module.open({ projectId: 'clock', projectPath, url: 'http://127.0.0.1:4173/', injectProjectData: true, }); expect(preview.opens).toHaveLength(2); expect(adapter.views).toHaveLength(2); }); it('bounds crashed preview cleanup so a subsequent data-enabled open completes', async () => { vi.useFakeTimers(); try { const adapter = new FakeAdapter(); const preview = new FakePreviewDataSession(); adapter.onCreate = (view) => { view.webContents.debugger.responders.set('Page.addScriptToEvaluateOnNewDocument', async () => ({ identifier: 'script-root', })); }; const module = new AgentBrowserModule(adapter, { previewDataSession: preview }); await module.open({ projectId: 'clock', projectPath, url: 'http://127.0.0.1:4173/', injectProjectData: true, }); const crashedView = adapter.views[0]; crashedView.webContents.debugger.responders.set( 'Page.removeScriptToEvaluateOnNewDocument', async () => await new Promise(() => undefined), ); crashedView.webContents.emit('render-process-gone', {}, { reason: 'crashed' }); expect(preview.invalidations).toContain('browser_crashed'); expect(preview.getInjectionValue()).toBeNull(); const reopening = module.open({ projectId: 'clock', projectPath, url: 'http://127.0.0.1:4173/', injectProjectData: true, }); await vi.advanceTimersByTimeAsync(1_000); await expect(reopening).resolves.toMatchObject({ state: 'attached', generation: 2, }); expect(preview.invalidations).toContain('browser_crashed'); expect(preview.opens).toHaveLength(2); expect(adapter.views).toHaveLength(2); } finally { vi.useRealTimers(); } }); it('replaces a data-enabled browser when a later ordinary open omits the opt-in', async () => { const adapter = new FakeAdapter(); const preview = new FakePreviewDataSession(); let scriptNumber = 0; adapter.onCreate = (view) => { view.webContents.debugger.responders.set('Page.addScriptToEvaluateOnNewDocument', async () => ({ identifier: `script-${scriptNumber++}`, })); }; const module = new AgentBrowserModule(adapter, { previewDataSession: preview }); await module.open({ projectId: 'clock', projectPath, url: 'http://127.0.0.1:4173/', injectProjectData: true, }); await module.open({ projectId: 'clock', projectPath, url: 'https://example.com/', }); expect(preview.invalidations).toContain('preview_closed'); expect(adapter.views).toHaveLength(2); expect(adapter.views[1].webContents.debugger.commands).not.toEqual(expect.arrayContaining([ expect.objectContaining({ method: 'Page.addScriptToEvaluateOnNewDocument' }), ])); }); it.each([ ['close', async (module: AgentBrowserModule, view: FakeView) => { await module.close(projectPath); return view.webContents.debugger.commands; }, 'preview_closed'], ['detach', async (_module: AgentBrowserModule, view: FakeView) => { view.webContents.debugger.detached(); await vi.waitFor(() => expect(view.webContents.debugger.commands).toEqual(expect.arrayContaining([ expect.objectContaining({ method: 'Page.removeScriptToEvaluateOnNewDocument' }), ]))); return view.webContents.debugger.commands; }, 'browser_detached'], ['crash', async (_module: AgentBrowserModule, view: FakeView) => { view.webContents.emit('render-process-gone', {}, { reason: 'crashed' }); await vi.waitFor(() => expect(view.webContents.debugger.commands).toEqual(expect.arrayContaining([ expect.objectContaining({ method: 'Page.removeScriptToEvaluateOnNewDocument' }), ]))); return view.webContents.debugger.commands; }, 'browser_crashed'], ])('invalidates and removes preview scripts on browser %s', async (_name, action, reason) => { const adapter = new FakeAdapter(); const preview = new FakePreviewDataSession(); adapter.onCreate = (view) => { view.webContents.debugger.responders.set('Page.addScriptToEvaluateOnNewDocument', async () => ({ identifier: 'script-root', })); }; const module = new AgentBrowserModule(adapter, { previewDataSession: preview }); await module.open({ projectId: 'clock', projectPath, url: 'http://127.0.0.1:4173/', injectProjectData: true, }); const view = adapter.views[0]; await action(module, view); expect(preview.invalidations).toContain(reason); expect(view.webContents.debugger.commands).toEqual(expect.arrayContaining([ expect.objectContaining({ method: 'Page.removeScriptToEvaluateOnNewDocument', params: { identifier: 'script-root' }, }), ])); }); 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', }); const view = adapter.views[0]; if (!view) throw new Error('browser view was not created'); await module.present({ projectPath, visible: true, bounds: { x: 0, y: 0, width: 800, height: 600 }, }); await module.setDiagnostics({ projectPath, enabled: true }); 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 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(); await module.setDiagnostics({ projectPath, enabled: true }); 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(); await module.setDiagnostics({ projectPath, enabled: true }); 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); }); });