Files
makelore/tests/unit/agent-browser-core.test.ts

1886 lines
66 KiB
TypeScript

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<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>;
evaluateHandler?: (code: string) => Promise<unknown>;
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;
}
async executeJavaScript(code: string): Promise<unknown> {
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<void>;
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<void> {
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<void>;
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('<main>ok</main>') }]);
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<string, unknown> = {
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<string, unknown> = {
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<unknown>(() => 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<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',
});
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('waits for the matching browser generation to be presented without polling', async () => {
const adapter = new FakeAdapter();
const module = new AgentBrowserModule(adapter);
const opened = await module.open({
projectId: 'clock',
projectPath,
url: 'http://localhost:3000',
visible: false,
});
let settled = false;
const presented = module.waitForPresentation({
projectPath,
generation: opened.generation,
timeoutMs: 1_000,
}).finally(() => {
settled = true;
});
await Promise.resolve();
expect(settled).toBe(false);
await module.present({
projectPath,
visible: true,
bounds: { x: 1, y: 2, width: 640, height: 480 },
});
await expect(presented).resolves.toMatchObject({
state: 'attached',
generation: opened.generation,
visible: true,
bounds: { x: 1, y: 2, width: 640, height: 480 },
});
});
it('keeps diagnostics enabled until every explicit owner releases them', async () => {
const adapter = new FakeAdapter();
const module = new AgentBrowserModule(adapter);
await module.open({
projectId: 'clock',
projectPath,
url: 'http://localhost:3000',
diagnosticsOwner: 'agent:conversation-a:run-a',
visible: true,
bounds: { x: 0, y: 0, width: 800, height: 600 },
});
const commands = adapter.views[0].webContents.debugger.commands;
expect(commands.map(({ method }) => method)).toEqual(expect.arrayContaining([
'Runtime.enable',
'Log.enable',
'Network.enable',
'Page.enable',
]));
await module.setDiagnostics({ projectPath, enabled: true, owner: 'renderer' });
await module.setDiagnostics({
projectPath,
enabled: false,
owner: 'agent:conversation-a:run-a',
});
expect(commands.some(({ method }) => method === 'Runtime.disable')).toBe(false);
await module.setDiagnostics({ projectPath, enabled: false, owner: 'renderer' });
expect(commands.map(({ method }) => method)).toEqual(expect.arrayContaining([
'Page.disable',
'Network.disable',
'Log.disable',
'Runtime.disable',
]));
});
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();
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<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);
});
});