需求:在 AI 编程会话中让用户与 Agent 共享同一浏览器页面,并查看控制台与网络信息。 实现:新增沙箱浏览器内核、Host API/渲染器面板、OpenCode 工具接入及安全边界测试。
1024 lines
32 KiB
TypeScript
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);
|
|
});
|
|
});
|