Files
makelore/tests/unit/agent-browser-core.test.ts
brother7 e97a7ce9df feat: 增加共享 Agent Browser 调试能力
需求:在 AI 编程会话中让用户与 Agent 共享同一浏览器页面,并查看控制台与网络信息。

实现:新增沙箱浏览器内核、Host API/渲染器面板、OpenCode 工具接入及安全边界测试。
2026-07-31 14:53:36 +08:00

1024 lines
32 KiB
TypeScript

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<string, unknown>;
sessionRef?: string;
}> = [];
readonly attachVersions: string[] = [];
readonly responders = new Map<string, () => Promise<unknown>>();
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<string, unknown>,
sessionRef?: string,
): Promise<unknown> {
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<void>;
async loadURL(url: string): Promise<void> {
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<void>;
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<void> {
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<void>(() => 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<void>(() => 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<void>(() => 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<void>(() => 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<void>(() => 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<void>((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<unknown>(() => 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<unknown>(() => 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<unknown>((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<void>((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);
});
});