feat: 增加共享 Agent Browser 调试能力

需求:在 AI 编程会话中让用户与 Agent 共享同一浏览器页面,并查看控制台与网络信息。

实现:新增沙箱浏览器内核、Host API/渲染器面板、OpenCode 工具接入及安全边界测试。
This commit is contained in:
2026-07-31 14:53:36 +08:00
parent 80e8386fa6
commit e97a7ce9df
39 changed files with 6734 additions and 9 deletions

View File

@@ -0,0 +1,430 @@
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { AgentBrowserPanel } from '@/pages/Chat/AgentBrowserPanel';
import { deriveAgentBrowserDiagnostics } from '@/lib/agent-browser';
import type {
AgentBrowserCdpEvent,
AgentBrowserSnapshot,
} from '../../shared/agent-browser';
const hostApiFetchMock = vi.hoisted(() => vi.fn());
const hostEventListeners = vi.hoisted(
() => new Map<string, (payload: unknown) => void>(),
);
vi.mock('@/lib/host-api', () => ({
hostApiFetch: (...args: unknown[]) => hostApiFetchMock(...args),
}));
vi.mock('@/lib/host-events', () => ({
subscribeHostEvent: (
eventName: string,
handler: (payload: unknown) => void,
) => {
hostEventListeners.set(eventName, handler);
return () => {
hostEventListeners.delete(eventName);
};
},
}));
let resizeCallback: ResizeObserverCallback | null = null;
class MockResizeObserver {
constructor(callback: ResizeObserverCallback) {
resizeCallback = callback;
}
observe() {}
unobserve() {}
disconnect() {}
}
function snapshot(
state: AgentBrowserSnapshot['state'] = 'closed',
overrides: Partial<AgentBrowserSnapshot> = {},
): AgentBrowserSnapshot {
const active = state !== 'closed' && state !== 'closing';
return {
browserId: active ? 'browser-1' : null,
projectId: 'project-1',
projectPath: 'D:/repo',
state,
generation: active ? 1 : 0,
url: active ? 'http://localhost:4173/' : '',
title: active ? 'Student app' : '',
visible: active,
bounds: null,
canGoBack: false,
canGoForward: false,
eventCursor: 0,
...overrides,
};
}
function parseBody(init?: RequestInit): Record<string, unknown> {
return JSON.parse(String(init?.body ?? '{}')) as Record<string, unknown>;
}
function installBrowserApi(
initial: AgentBrowserSnapshot,
events: AgentBrowserCdpEvent[] = [],
) {
let current = initial;
let eventsReturned = false;
hostApiFetchMock.mockImplementation(async (path: string, init?: RequestInit) => {
if (path.startsWith('/api/agent-browser/state?')) {
return { success: true, browser: current };
}
if (path === '/api/agent-browser/open') {
const body = parseBody(init);
current = snapshot('attached', {
url: String(body.url),
visible: true,
bounds: body.bounds as AgentBrowserSnapshot['bounds'],
});
return { success: true, browser: current };
}
if (path === '/api/agent-browser/present') {
const body = parseBody(init);
current = {
...current,
visible: body.visible === true,
bounds: (body.bounds as AgentBrowserSnapshot['bounds']) ?? current.bounds,
};
return { success: true, browser: current };
}
if (path === '/api/agent-browser/navigate') {
return { success: true, browser: current };
}
if (path === '/api/agent-browser/close') {
current = snapshot();
return { success: true, browser: current };
}
if (path === '/api/agent-browser/cdp/events') {
const pageEvents = eventsReturned ? [] : events;
eventsReturned = true;
return {
success: true,
page: {
events: pageEvents,
nextCursor: pageEvents.at(-1)?.sequence ?? current.eventCursor,
hasMore: false,
},
};
}
throw new Error(`Unexpected Agent Browser request: ${path}`);
});
}
describe('AgentBrowserPanel', () => {
beforeEach(() => {
hostEventListeners.clear();
resizeCallback = null;
Object.defineProperty(window, 'ResizeObserver', {
configurable: true,
value: MockResizeObserver,
});
Object.defineProperty(globalThis, 'ResizeObserver', {
configurable: true,
value: MockResizeObserver,
});
Object.defineProperty(window, 'devicePixelRatio', {
configurable: true,
value: 2,
});
});
it('starts collapsed and cannot open without an active project', () => {
render(<AgentBrowserPanel projectPath={null} />);
expect(screen.queryByTestId('agent-browser-panel')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: '打开开发浏览器' })).toBeDisabled();
});
it('keeps bounded placeholders for oversized Console and Network events', () => {
const diagnostics = deriveAgentBrowserDiagnostics([
{
sequence: 1,
generation: 1,
timestamp: 1_000,
method: 'Runtime.consoleAPICalled',
payload: {
kind: 'payload',
handle: 'console-large',
byteLength: 70_000,
contentType: 'application/json',
expiresAt: '2026-07-28T00:00:00.000Z',
},
},
{
sequence: 2,
generation: 1,
timestamp: 1_010,
method: 'Network.requestWillBeSent',
payload: {
kind: 'payload',
handle: 'network-large',
byteLength: 80_000,
contentType: 'application/json',
expiresAt: '2026-07-28T00:00:00.000Z',
},
},
]);
expect(diagnostics.console[0]?.text).toContain('70000 字节');
expect(diagnostics.network[0]?.url).toContain('80000 字节');
});
it('keeps child-target Network requests separate when request ids collide', () => {
const diagnostics = deriveAgentBrowserDiagnostics([
{
sequence: 1,
generation: 1,
timestamp: 1_000,
method: 'Network.requestWillBeSent',
params: {
requestId: 'shared-id',
request: { method: 'GET', url: 'http://localhost/root' },
},
},
{
sequence: 2,
generation: 1,
timestamp: 1_001,
method: 'Network.requestWillBeSent',
sessionRef: 'worker-1',
params: {
requestId: 'shared-id',
request: { method: 'POST', url: 'http://localhost/worker' },
},
},
]);
expect(diagnostics.network).toHaveLength(2);
expect(diagnostics.network.map((entry) => entry.url)).toEqual([
'http://localhost/root',
'http://localhost/worker',
]);
});
it('opens a local URL with viewport bounds expressed in DIP', async () => {
installBrowserApi(snapshot());
render(<AgentBrowserPanel projectPath="D:/repo" />);
fireEvent.click(screen.getByRole('button', { name: '打开开发浏览器' }));
const viewport = await screen.findByTestId('agent-browser-viewport');
vi.spyOn(viewport, 'getBoundingClientRect').mockReturnValue({
x: 120,
y: 80,
left: 120,
top: 80,
right: 540,
bottom: 400,
width: 420,
height: 320,
toJSON: () => ({}),
});
fireEvent.change(screen.getByRole('textbox', { name: '网页地址' }), {
target: { value: 'localhost:4173' },
});
fireEvent.click(screen.getByRole('button', { name: '打开' }));
await waitFor(() => {
const call = hostApiFetchMock.mock.calls.find(
([path]) => path === '/api/agent-browser/open',
);
expect(call).toBeDefined();
expect(parseBody(call?.[1] as RequestInit)).toEqual({
project_path: 'D:/repo',
url: 'http://localhost:4173',
visible: true,
bounds: { x: 120, y: 80, width: 420, height: 320 },
});
});
expect(window.devicePixelRatio).toBe(2);
});
it('expands when the agent opens the shared browser', async () => {
installBrowserApi(snapshot('attached'));
render(<AgentBrowserPanel projectId="project-1" projectPath="D:/repo" />);
expect(screen.queryByTestId('agent-browser-panel')).not.toBeInTheDocument();
act(() => {
hostEventListeners.get('agent-browser:show')?.(snapshot('attached'));
});
expect(await screen.findByTestId('agent-browser-panel')).toBeInTheDocument();
expect(screen.getByTestId('agent-browser-debug-status')).toHaveTextContent('AI 可调试');
});
it('ignores a delayed show event from the previous project', () => {
installBrowserApi(snapshot());
render(<AgentBrowserPanel projectId="project-2" projectPath="D:/repo-2" />);
act(() => {
hostEventListeners.get('agent-browser:show')?.(
snapshot('attached', {
projectId: 'project-1',
projectPath: 'D:/repo-1',
}),
);
});
expect(screen.queryByTestId('agent-browser-panel')).not.toBeInTheDocument();
});
it('restores an already active shared browser after remounting', async () => {
installBrowserApi(snapshot('attached'));
render(<AgentBrowserPanel projectId="project-1" projectPath="D:/repo" />);
expect(await screen.findByTestId('agent-browser-panel')).toBeInTheDocument();
expect(screen.getByTestId('agent-browser-debug-status')).toHaveTextContent('AI 可调试');
});
it('updates native view bounds and hides it when collapsed', async () => {
installBrowserApi(snapshot('attached'));
render(<AgentBrowserPanel projectPath="D:/repo" />);
fireEvent.click(screen.getByRole('button', { name: '打开开发浏览器' }));
expect(await screen.findByText('AI 可调试')).toBeInTheDocument();
const viewport = screen.getByTestId('agent-browser-viewport');
vi.spyOn(viewport, 'getBoundingClientRect').mockReturnValue({
x: 240,
y: 96,
left: 240,
top: 96,
right: 640,
bottom: 396,
width: 400,
height: 300,
toJSON: () => ({}),
});
resizeCallback?.([], {} as ResizeObserver);
await waitFor(() => {
expect(hostApiFetchMock.mock.calls.some(([path, init]) => (
path === '/api/agent-browser/present'
&& parseBody(init as RequestInit).visible === true
&& JSON.stringify(parseBody(init as RequestInit).bounds)
=== JSON.stringify({ x: 240, y: 96, width: 400, height: 300 })
))).toBe(true);
});
fireEvent.click(screen.getByRole('button', { name: '收起开发浏览器' }));
await waitFor(() => {
expect(hostApiFetchMock.mock.calls.some(([path, init]) => (
path === '/api/agent-browser/present'
&& parseBody(init as RequestInit).visible === false
))).toBe(true);
});
});
it('hides the native view while a renderer modal is open', async () => {
installBrowserApi(snapshot('attached'));
render(<AgentBrowserPanel projectPath="D:/repo" />);
const viewport = await screen.findByTestId('agent-browser-viewport');
vi.spyOn(viewport, 'getBoundingClientRect').mockReturnValue({
x: 240,
y: 96,
left: 240,
top: 96,
right: 640,
bottom: 396,
width: 400,
height: 300,
toJSON: () => ({}),
});
resizeCallback?.([], {} as ResizeObserver);
await waitFor(() => {
expect(hostApiFetchMock.mock.calls.some(([path, init]) => (
path === '/api/agent-browser/present'
&& parseBody(init as RequestInit).visible === true
))).toBe(true);
});
const beforeModal = hostApiFetchMock.mock.calls.length;
const dialog = document.createElement('div');
dialog.setAttribute('role', 'dialog');
dialog.setAttribute('data-state', 'open');
act(() => document.body.append(dialog));
await waitFor(() => {
expect(hostApiFetchMock.mock.calls.slice(beforeModal).some(([path, init]) => (
path === '/api/agent-browser/present'
&& parseBody(init as RequestInit).visible === false
))).toBe(true);
});
const beforeClose = hostApiFetchMock.mock.calls.length;
act(() => dialog.remove());
await waitFor(() => {
expect(hostApiFetchMock.mock.calls.slice(beforeClose).some(([path, init]) => (
path === '/api/agent-browser/present'
&& parseBody(init as RequestInit).visible === true
))).toBe(true);
});
});
it('derives Console and Network panels from the same CDP event page', async () => {
installBrowserApi(snapshot('attached'), [
{
sequence: 1,
generation: 1,
timestamp: 1_000,
method: 'Runtime.consoleAPICalled',
params: {
type: 'log',
args: [{ type: 'string', value: 'ready' }],
},
},
{
sequence: 2,
generation: 1,
timestamp: 1_010,
method: 'Network.requestWillBeSent',
params: {
requestId: 'req-1',
type: 'Fetch',
request: { method: 'GET', url: 'http://localhost:4173/api/data' },
},
},
{
sequence: 3,
generation: 1,
timestamp: 1_030,
method: 'Network.responseReceived',
params: {
requestId: 'req-1',
type: 'Fetch',
response: {
url: 'http://localhost:4173/api/data',
status: 200,
statusText: 'OK',
mimeType: 'application/json',
},
},
},
{
sequence: 4,
generation: 1,
timestamp: 1_060,
method: 'Network.loadingFinished',
params: { requestId: 'req-1', encodedDataLength: 42 },
},
]);
render(<AgentBrowserPanel projectPath="D:/repo" />);
fireEvent.click(screen.getByRole('button', { name: '打开开发浏览器' }));
expect(await screen.findByText('ready')).toBeInTheDocument();
fireEvent.click(screen.getByRole('tab', { name: /Network/ }));
expect(await screen.findByText('http://localhost:4173/api/data')).toBeInTheDocument();
expect(screen.getByText('200')).toBeInTheDocument();
expect(screen.getByText('50ms')).toBeInTheDocument();
});
});