merge: 集成共享 Agent Browser 调试能力
Some checks failed
Electron E2E / Electron E2E (macos-latest) (push) Has been cancelled
Electron E2E / Electron E2E (ubuntu-latest) (push) Has been cancelled
Electron E2E / Electron E2E (windows-latest) (push) Has been cancelled

需求:将 Agent Browser 独立功能提交合并到包含 AI 设计 Workspace 的最新主线。

实现:保留双方模块初始化、Host API 路由和 README 产品说明。

# Conflicts:
#	electron/api/context.ts
This commit is contained in:
2026-07-31 14:56:45 +08:00
39 changed files with 6734 additions and 9 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,211 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const electronMocks = vi.hoisted(() => {
class MiniEmitter {
private readonly listeners = new Map<string, Set<(...args: unknown[]) => void>>();
on(event: string, listener: (...args: unknown[]) => void): this {
const listeners = this.listeners.get(event) ?? new Set();
listeners.add(listener);
this.listeners.set(event, listeners);
return this;
}
removeListener(event: string, listener: (...args: unknown[]) => void): this {
this.listeners.get(event)?.delete(listener);
return this;
}
removeAllListeners(): this {
this.listeners.clear();
return this;
}
emit(event: string, ...args: unknown[]): boolean {
for (const listener of this.listeners.get(event) ?? []) listener(...args);
return true;
}
}
const nativeViews: Array<{
options: unknown;
webContents: unknown;
setBounds: ReturnType<typeof vi.fn>;
setVisible: ReturnType<typeof vi.fn>;
}> = [];
const permissionHandler = vi.fn();
const permissionCheckHandler = vi.fn();
const browserSession = Object.assign(new MiniEmitter(), {
setPermissionRequestHandler: permissionHandler,
setPermissionCheckHandler: permissionCheckHandler,
clearStorageData: vi.fn().mockResolvedValue(undefined),
clearCache: vi.fn().mockResolvedValue(undefined),
});
class MockWebContentsView {
readonly webContents = Object.assign(new MiniEmitter(), {
debugger: Object.assign(new MiniEmitter(), {
attach: vi.fn(),
detach: vi.fn(),
isAttached: vi.fn().mockReturnValue(false),
sendCommand: vi.fn().mockResolvedValue({}),
}),
navigationHistory: {
canGoBack: vi.fn().mockReturnValue(false),
canGoForward: vi.fn().mockReturnValue(false),
goBack: vi.fn(),
goForward: vi.fn(),
clear: vi.fn(),
},
session: browserSession,
loadURL: vi.fn().mockResolvedValue(undefined),
getURL: vi.fn().mockReturnValue(''),
getTitle: vi.fn().mockReturnValue(''),
isDestroyed: vi.fn().mockReturnValue(false),
isDevToolsOpened: vi.fn().mockReturnValue(false),
reload: vi.fn(),
close: vi.fn(),
setWindowOpenHandler: vi.fn(),
});
readonly setBounds = vi.fn();
readonly setVisible = vi.fn();
constructor(readonly options: unknown) {
nativeViews.push(this);
}
}
return {
MockWebContentsView,
nativeViews,
browserSession,
permissionHandler,
permissionCheckHandler,
fromPartition: vi.fn(() => browserSession),
};
});
vi.mock('electron', () => ({
WebContentsView: electronMocks.MockWebContentsView,
session: { fromPartition: electronMocks.fromPartition },
}));
import { ElectronAgentBrowserAdapter } from '@electron/agent-browser/electron-adapter';
describe('ElectronAgentBrowserAdapter', () => {
beforeEach(() => {
electronMocks.nativeViews.length = 0;
electronMocks.permissionHandler.mockClear();
electronMocks.permissionCheckHandler.mockClear();
electronMocks.browserSession.removeAllListeners();
});
it('creates an isolated sandboxed WebContentsView and mounts it in Main', () => {
const addChildView = vi.fn();
const removeChildView = vi.fn();
const mainWindow = {
isDestroyed: vi.fn().mockReturnValue(false),
contentView: { addChildView, removeChildView },
webContents: { getZoomFactor: vi.fn().mockReturnValue(1.25) },
getContentBounds: vi.fn().mockReturnValue({
x: 0,
y: 0,
width: 900,
height: 650,
}),
};
const adapter = new ElectronAgentBrowserAdapter(mainWindow as never);
const view = adapter.createView('persist:niancode-agent-browser:test');
adapter.mount(view);
expect(electronMocks.nativeViews[0].options).toEqual({
webPreferences: {
partition: 'persist:niancode-agent-browser:test',
sandbox: true,
contextIsolation: true,
nodeIntegration: false,
webSecurity: true,
allowRunningInsecureContent: false,
},
});
expect(electronMocks.nativeViews[0].webContents).toMatchObject({
setWindowOpenHandler: expect.any(Function),
});
expect(electronMocks.permissionHandler).toHaveBeenCalledOnce();
expect(electronMocks.permissionCheckHandler).toHaveBeenCalledOnce();
expect(addChildView).toHaveBeenCalledWith(electronMocks.nativeViews[0]);
view.setBounds({ x: 100, y: 80, width: 800, height: 600 });
view.setVisible(true);
view.webContents.navigationHistory.clear();
expect(electronMocks.nativeViews[0].setBounds).toHaveBeenCalledWith({
x: 125,
y: 100,
width: 775,
height: 550,
});
expect(electronMocks.nativeViews[0].setVisible).toHaveBeenCalledWith(true);
expect(
(
electronMocks.nativeViews[0].webContents as {
navigationHistory: { clear: ReturnType<typeof vi.fn> };
}
).navigationHistory.clear,
).toHaveBeenCalledOnce();
});
it('denies downloads and clears only the requested project partition', async () => {
const adapter = new ElectronAgentBrowserAdapter({
isDestroyed: vi.fn().mockReturnValue(false),
contentView: { addChildView: vi.fn(), removeChildView: vi.fn() },
webContents: { getZoomFactor: vi.fn().mockReturnValue(1) },
getContentBounds: vi.fn().mockReturnValue({
x: 0,
y: 0,
width: 1024,
height: 768,
}),
} as never);
adapter.createView('persist:niancode-agent-browser:test');
const downloadEvent = { preventDefault: vi.fn() };
electronMocks.browserSession.emit('will-download', downloadEvent);
expect(downloadEvent.preventDefault).toHaveBeenCalledOnce();
await adapter.resetPartition('persist:niancode-agent-browser:test');
expect(electronMocks.fromPartition).toHaveBeenCalledWith(
'persist:niancode-agent-browser:test',
);
expect(electronMocks.browserSession.clearStorageData).toHaveBeenCalled();
expect(electronMocks.browserSession.clearCache).toHaveBeenCalled();
});
it('prevents top-level navigation and redirects to non-web protocols', () => {
const adapter = new ElectronAgentBrowserAdapter({
isDestroyed: vi.fn().mockReturnValue(false),
contentView: { addChildView: vi.fn(), removeChildView: vi.fn() },
webContents: { getZoomFactor: vi.fn().mockReturnValue(1) },
getContentBounds: vi.fn().mockReturnValue({
x: 0,
y: 0,
width: 1024,
height: 768,
}),
} as never);
adapter.createView('persist:niancode-agent-browser:test');
const contents = electronMocks.nativeViews[0].webContents as {
emit(event: string, ...args: unknown[]): boolean;
};
const fileNavigation = { preventDefault: vi.fn() };
const customRedirect = { preventDefault: vi.fn() };
const webNavigation = { preventDefault: vi.fn() };
contents.emit('will-navigate', fileNavigation, 'file:///C:/Windows/win.ini');
contents.emit('will-redirect', customRedirect, 'niancode://settings');
contents.emit('will-navigate', webNavigation, 'http://127.0.0.1:5173');
expect(fileNavigation.preventDefault).toHaveBeenCalledOnce();
expect(customRedirect.preventDefault).toHaveBeenCalledOnce();
expect(webNavigation.preventDefault).not.toHaveBeenCalled();
});
});

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();
});
});

View File

@@ -0,0 +1,124 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { ToolContext } from '@opencode-ai/plugin/tool';
import { NianCodeAgentBrowserPlugin } from '../../.opencode/plugins/agent-browser-tools';
const originalBaseUrl = process.env.NIANCODE_HOST_API_BASE_URL;
const originalToken = process.env.NIANCODE_HOST_API_TOKEN;
function toolContext(directory: string): ToolContext {
return {
sessionID: 'session-1',
messageID: 'message-1',
agent: 'build',
directory,
worktree: directory,
abort: new AbortController().signal,
metadata: vi.fn(),
ask: vi.fn(),
};
}
function response(payload: Record<string, unknown>, status = 200): Response {
return new Response(JSON.stringify(payload), {
status,
headers: { 'Content-Type': 'application/json' },
});
}
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
if (originalBaseUrl === undefined) delete process.env.NIANCODE_HOST_API_BASE_URL;
else process.env.NIANCODE_HOST_API_BASE_URL = originalBaseUrl;
if (originalToken === undefined) delete process.env.NIANCODE_HOST_API_TOKEN;
else process.env.NIANCODE_HOST_API_TOKEN = originalToken;
});
describe('Agent Browser OpenCode plugin', () => {
it('loads the bundled runtime plugin artifact', async () => {
const bundled = await import(
'../../.opencode/skills/agent-browser/.opencode/plugins/niancode-agent-browser.js'
);
expect(bundled.NianCodeAgentBrowserPlugin).toBeTypeOf('function');
});
it('binds lifecycle requests to context.directory without exposing model-selected ids', async () => {
process.env.NIANCODE_HOST_API_BASE_URL = 'http://127.0.0.1:43210/';
process.env.NIANCODE_HOST_API_TOKEN = 'host-token';
const fetchMock = vi.fn(async () => response({
success: true,
browser: { state: 'attached', url: 'http://localhost:5173' },
}));
vi.stubGlobal('fetch', fetchMock);
const plugin = await NianCodeAgentBrowserPlugin();
await plugin.tool.browser_context.execute(
{ action: 'open', url: 'http://localhost:5173' },
toolContext('D:\\Students\\demo'),
);
expect(fetchMock).toHaveBeenCalledOnce();
expect(fetchMock.mock.calls[0][0]).toBe('http://127.0.0.1:43210/api/agent-browser/open');
const init = fetchMock.mock.calls[0][1] as RequestInit;
expect(init.headers).toMatchObject({ Authorization: 'Bearer host-token' });
expect(JSON.parse(String(init.body))).toEqual({
project_path: 'D:\\Students\\demo',
url: 'http://localhost:5173',
});
expect(String(init.body)).not.toMatch(/projectId|tabId|webContentsId/);
});
it('returns Full CDP results and event pages without stripping nested data', async () => {
process.env.NIANCODE_HOST_API_BASE_URL = 'http://127.0.0.1:43210';
process.env.NIANCODE_HOST_API_TOKEN = 'host-token';
const fullResult = {
kind: 'inline',
value: {
result: {
type: 'object',
value: { headers: { authorization: 'page-owned-value' }, nested: [1, 2, 3] },
},
},
};
const fullPage = {
events: [{
sequence: 41,
method: 'Network.responseReceived',
params: { response: { headers: { 'x-debug': 'complete' } } },
}],
nextCursor: 41,
hasMore: false,
};
const fetchMock = vi.fn()
.mockResolvedValueOnce(response({ success: true, result: fullResult }))
.mockResolvedValueOnce(response({ success: true, page: fullPage }));
vi.stubGlobal('fetch', fetchMock);
const plugin = await NianCodeAgentBrowserPlugin();
const context = toolContext('D:\\Students\\demo');
const sendResult = await plugin.tool.browser_cdp.execute({
action: 'send',
method: 'Runtime.evaluate',
params: { expression: 'window.__debugState' },
}, context);
const eventsResult = await plugin.tool.browser_cdp.execute({
action: 'read_events',
after: 30,
methods: ['Network.responseReceived'],
}, context);
expect(sendResult).toMatchObject({ output: JSON.stringify(fullResult, null, 2) });
expect(eventsResult).toMatchObject({ output: JSON.stringify(fullPage, null, 2) });
expect(JSON.parse(String((fetchMock.mock.calls[0][1] as RequestInit).body))).toEqual({
project_path: 'D:\\Students\\demo',
method: 'Runtime.evaluate',
params: { expression: 'window.__debugState' },
});
expect(JSON.parse(String((fetchMock.mock.calls[1][1] as RequestInit).body))).toEqual({
project_path: 'D:\\Students\\demo',
after: 30,
methods: ['Network.responseReceived'],
});
});
});

View File

@@ -0,0 +1,348 @@
import { EventEmitter } from 'node:events';
import type { IncomingMessage, ServerResponse } from 'node:http';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { handleAgentBrowserRoutes } from '@electron/api/routes/agent-browser';
import {
getRendererCapability,
RENDERER_CAPABILITY_HEADER,
rotateRendererCapability,
} from '@electron/api/renderer-capability';
function createRequest(
method: string,
body?: unknown,
headers: Record<string, string> = {},
): IncomingMessage {
const req = new EventEmitter();
Object.assign(req, {
method,
headers: {
...(body === undefined ? {} : { 'content-type': 'application/json' }),
...headers,
},
[Symbol.asyncIterator]: async function* () {
if (body !== undefined) yield Buffer.from(JSON.stringify(body));
},
});
return req as IncomingMessage;
}
function createResponse() {
const chunks: string[] = [];
const res = {
statusCode: 0,
setHeader: vi.fn(),
end: vi.fn((chunk?: string) => {
if (chunk) chunks.push(chunk);
}),
} as unknown as ServerResponse;
return {
res,
json: () => JSON.parse(chunks.join('')) as Record<string, unknown>,
};
}
function snapshot(projectPath: string) {
return {
browserId: 'browser-1',
projectId: 'project-1',
projectPath,
state: 'attached' as const,
generation: 1,
url: 'http://127.0.0.1:5173',
title: 'Example',
visible: true,
bounds: { x: 0, y: 0, width: 640, height: 480 },
canGoBack: false,
canGoForward: false,
eventCursor: 0,
};
}
function context(projectPath: string, browser: Record<string, unknown>) {
return {
opencodeProjectStore: {
getActiveProject: vi.fn().mockResolvedValue({
id: 'project-1',
path: projectPath,
name: 'project',
}),
},
eventBus: { emit: vi.fn() },
mainWindow: null,
agentBrowser: browser,
} as never;
}
describe('Agent Browser Host API routes', () => {
const temporaryDirectories: string[] = [];
beforeEach(() => {
rotateRendererCapability();
});
afterEach(async () => {
await Promise.all(
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })),
);
});
async function temporaryProject(prefix: string): Promise<string> {
const directory = await mkdtemp(join(tmpdir(), prefix));
temporaryDirectories.push(directory);
return directory;
}
it('opens the browser for the active project and emits a visible hint', async () => {
const projectPath = await temporaryProject('niancode-agent-browser-route-');
const open = vi.fn().mockResolvedValue(snapshot(projectPath));
const ctx = context(projectPath, { open });
const response = createResponse();
const handled = await handleAgentBrowserRoutes(
createRequest('POST', {
project_path: projectPath,
url: 'http://127.0.0.1:5173',
bounds: { x: 10, y: 20, width: 640, height: 480 },
}, {
[RENDERER_CAPABILITY_HEADER]: getRendererCapability(),
}),
response.res,
new URL('http://127.0.0.1/api/agent-browser/open'),
ctx,
);
expect(handled).toBe(true);
expect(response.res.statusCode).toBe(200);
expect(response.json()).toMatchObject({ success: true, browser: { state: 'attached' } });
expect(open).toHaveBeenCalledWith(expect.objectContaining({
projectId: 'project-1',
projectPath,
url: 'http://127.0.0.1:5173',
bounds: { x: 10, y: 20, width: 640, height: 480 },
}));
expect((ctx as never as { eventBus: { emit: ReturnType<typeof vi.fn> } }).eventBus.emit)
.toHaveBeenCalledWith('agent-browser:show', expect.objectContaining({ browserId: 'browser-1' }));
});
it('rejects viewport bounds from an agent-only Host API request', async () => {
const projectPath = await temporaryProject('niancode-agent-browser-untrusted-bounds-');
const open = vi.fn();
const response = createResponse();
await handleAgentBrowserRoutes(
createRequest('POST', {
project_path: projectPath,
url: 'http://127.0.0.1:5173',
bounds: { x: 0, y: 0, width: 1920, height: 1080 },
}),
response.res,
new URL('http://127.0.0.1/api/agent-browser/open'),
context(projectPath, { open }),
);
expect(response.res.statusCode).toBe(403);
expect(response.json()).toMatchObject({
success: false,
code: 'TARGET_DENIED',
});
expect(open).not.toHaveBeenCalled();
});
it('forces an agent-only open request to remain hidden until Renderer presents it', async () => {
const projectPath = await temporaryProject('niancode-agent-browser-agent-open-');
const open = vi.fn().mockResolvedValue(snapshot(projectPath));
const response = createResponse();
await handleAgentBrowserRoutes(
createRequest('POST', {
project_path: projectPath,
url: 'http://127.0.0.1:5173',
visible: true,
}),
response.res,
new URL('http://127.0.0.1/api/agent-browser/open'),
context(projectPath, { open }),
);
expect(response.res.statusCode).toBe(200);
expect(open).toHaveBeenCalledWith(expect.objectContaining({
bounds: undefined,
visible: false,
}));
});
it('forwards full CDP commands without stripping params', async () => {
const projectPath = await temporaryProject('niancode-agent-browser-cdp-');
const sendCdp = vi.fn().mockResolvedValue({
kind: 'inline',
value: { result: { value: 'secret page value' } },
});
const response = createResponse();
await handleAgentBrowserRoutes(
createRequest('POST', {
project_path: projectPath,
method: 'Runtime.evaluate',
params: {
expression: 'window.localStorage.getItem("token")',
returnByValue: true,
},
}),
response.res,
new URL('http://127.0.0.1/api/agent-browser/cdp/send'),
context(projectPath, { sendCdp }),
);
expect(response.res.statusCode).toBe(200);
expect(sendCdp).toHaveBeenCalledWith(expect.objectContaining({
projectPath,
method: 'Runtime.evaluate',
params: {
expression: 'window.localStorage.getItem("token")',
returnByValue: true,
},
}));
expect(response.json()).toMatchObject({
success: true,
result: { kind: 'inline', value: { result: { value: 'secret page value' } } },
});
});
it('rejects a project path that is not the active project', async () => {
const activePath = await temporaryProject('niancode-agent-browser-active-');
const otherPath = await temporaryProject('niancode-agent-browser-other-');
const open = vi.fn();
const response = createResponse();
await handleAgentBrowserRoutes(
createRequest('POST', {
project_path: otherPath,
url: 'http://127.0.0.1:5173',
}),
response.res,
new URL('http://127.0.0.1/api/agent-browser/open'),
context(activePath, { open }),
);
expect(response.res.statusCode).toBe(403);
expect(response.json()).toMatchObject({
success: false,
code: 'PROJECT_MISMATCH',
});
expect(open).not.toHaveBeenCalled();
});
it('requires the calling agent to identify its project path', async () => {
const projectPath = await temporaryProject('niancode-agent-browser-required-path-');
const open = vi.fn();
const response = createResponse();
await handleAgentBrowserRoutes(
createRequest('POST', {
url: 'http://127.0.0.1:5173',
}),
response.res,
new URL('http://127.0.0.1/api/agent-browser/open'),
context(projectPath, { open }),
);
expect(response.res.statusCode).toBe(400);
expect(response.json()).toMatchObject({
success: false,
code: 'INVALID_REQUEST',
});
expect(open).not.toHaveBeenCalled();
});
it('closes a stale browser when the active project changes during open', async () => {
const projectPath = await temporaryProject('niancode-agent-browser-race-old-');
const nextProjectPath = await temporaryProject('niancode-agent-browser-race-new-');
const open = vi.fn().mockResolvedValue(snapshot(projectPath));
const close = vi.fn().mockResolvedValue(snapshot(projectPath));
const getActiveProject = vi.fn()
.mockResolvedValueOnce({ id: 'project-1', path: projectPath, name: 'old' })
.mockResolvedValueOnce({ id: 'project-2', path: nextProjectPath, name: 'new' });
const response = createResponse();
await handleAgentBrowserRoutes(
createRequest('POST', {
project_path: projectPath,
url: 'http://127.0.0.1:5173',
}),
response.res,
new URL('http://127.0.0.1/api/agent-browser/open'),
{
opencodeProjectStore: { getActiveProject },
eventBus: { emit: vi.fn() },
mainWindow: null,
agentBrowser: { open, close },
} as never,
);
expect(response.res.statusCode).toBe(403);
expect(response.json()).toMatchObject({
success: false,
code: 'PROJECT_MISMATCH',
});
expect(close).toHaveBeenCalledWith(projectPath);
});
it('rejects stale results when the active project keeps its id but changes path', async () => {
const projectPath = await temporaryProject('niancode-agent-browser-path-old-');
const nextProjectPath = await temporaryProject('niancode-agent-browser-path-new-');
const sendCdp = vi.fn().mockResolvedValue({ kind: 'inline', value: { result: 42 } });
const close = vi.fn().mockResolvedValue(snapshot(projectPath));
const getActiveProject = vi.fn()
.mockResolvedValueOnce({ id: 'project-1', path: projectPath, name: 'old' })
.mockResolvedValueOnce({ id: 'project-1', path: nextProjectPath, name: 'moved' });
const response = createResponse();
await handleAgentBrowserRoutes(
createRequest('POST', {
project_path: projectPath,
method: 'Runtime.evaluate',
params: { expression: '42' },
}),
response.res,
new URL('http://127.0.0.1/api/agent-browser/cdp/send'),
{
opencodeProjectStore: { getActiveProject },
eventBus: { emit: vi.fn() },
mainWindow: null,
agentBrowser: { sendCdp, close },
} as never,
);
expect(response.res.statusCode).toBe(403);
expect(response.json()).toMatchObject({
success: false,
code: 'PROJECT_MISMATCH',
});
expect(close).toHaveBeenCalledWith(projectPath);
});
it('does not expose an arbitrary target id in the route contract', async () => {
const projectPath = await temporaryProject('niancode-agent-browser-target-');
const sendCdp = vi.fn().mockResolvedValue({ kind: 'inline', value: {} });
const response = createResponse();
await handleAgentBrowserRoutes(
createRequest('POST', {
project_path: projectPath,
method: 'DOM.getDocument',
targetId: 'another-electron-tab',
}),
response.res,
new URL('http://127.0.0.1/api/agent-browser/cdp/send'),
context(projectPath, { sendCdp }),
);
expect(sendCdp).toHaveBeenCalledWith(expect.not.objectContaining({
targetId: expect.anything(),
}));
});
});

View File

@@ -14,6 +14,11 @@ vi.mock('../../electron/api/server', () => ({
getHostApiToken: () => 'host-token',
}));
vi.mock('../../electron/api/renderer-capability', () => ({
getRendererCapability: () => 'renderer-token',
RENDERER_CAPABILITY_HEADER: 'x-niancode-renderer-capability',
}));
describe('Host API IPC proxy', () => {
beforeEach(() => {
vi.resetModules();
@@ -47,6 +52,7 @@ describe('Host API IPC proxy', () => {
headers: expect.objectContaining({
Authorization: 'Bearer host-token',
'Content-Type': 'application/json',
'x-niancode-renderer-capability': 'renderer-token',
}),
}),
);

View File

@@ -44,6 +44,23 @@ describe('host-events', () => {
expect(cleanupSpy).toHaveBeenCalledTimes(1);
});
it.each(['agent-browser:show', 'agent-browser:state'])(
'maps %s to the preload IPC bridge',
async (eventName) => {
const onMock = vi.mocked(window.electron.ipcRenderer.on);
const cleanupSpy = vi.fn();
onMock.mockReturnValue(cleanupSpy);
const { subscribeHostEvent } = await import('@/lib/host-events');
const unsubscribe = subscribeHostEvent(eventName, vi.fn());
expect(onMock).toHaveBeenCalledWith(eventName, expect.any(Function));
expect(createHostEventSourceMock).not.toHaveBeenCalled();
unsubscribe();
expect(cleanupSpy).toHaveBeenCalledTimes(1);
},
);
it('does not use SSE fallback by default for unknown events', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const { subscribeHostEvent } = await import('@/lib/host-events');

View File

@@ -954,6 +954,7 @@ describe('OpencodeChatPanel', () => {
expect(agentSidebar.querySelector('.overflow-y-auto')).toBeInTheDocument();
expect(chatLayout).toHaveClass('flex-row');
expect(chatLayout.firstElementChild).toBe(agentSidebar);
expect(screen.getByRole('button', { name: '打开开发浏览器' })).toBeInTheDocument();
expect(screen.getByTestId('opencode-chat-panel')).not.toHaveClass('pt-4', 'sm:pt-5');
expect(screen.getAllByTestId(/^project-agent-chat-/)).toHaveLength(5);
expect(screen.getByTestId('project-agent-chat-game-promotion')).toHaveTextContent('运营宣传角色');

View File

@@ -15,6 +15,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
import { OpencodeManager } from '@electron/opencode/manager';
import {
ensureBundledSuperpowersPlugin,
resolveBundledAgentBrowserPluginPath,
resolveBundledSuperpowersDir,
} from '@electron/opencode/superpowers';
import { logger } from '@electron/utils/logger';
@@ -82,6 +83,23 @@ describe('OpencodeManager', () => {
})).toBe(join(resourcesPath, 'resources', 'skills', 'superpowers'));
});
it('resolves the bundled Agent Browser plugin from the course skills bundle', () => {
const resourcesPath = 'C:\\Program Files\\Makelore\\resources';
expect(resolveBundledAgentBrowserPluginPath({
isPackaged: true,
resourcesPath,
appPath: 'C:\\Program Files\\Makelore\\resources\\app.asar',
})).toBe(join(
resourcesPath,
'course-skills',
'agent-browser',
'.opencode',
'plugins',
'niancode-agent-browser.js',
));
});
it('spawns the bundled opencode server and becomes running after listening output', async () => {
const { children, calls, spawn } = createSpawnHarness();
const manager = new OpencodeManager({
@@ -858,6 +876,50 @@ describe('OpencodeManager', () => {
}
});
it('installs the bundled Agent Browser tool plugin before spawning', async () => {
const userDataDir = mkdtempSync(join(tmpdir(), 'niancode-opencode-manager-'));
const sourceDir = mkdtempSync(join(tmpdir(), 'niancode-agent-browser-plugin-source-'));
const sourcePluginPath = join(sourceDir, 'niancode-agent-browser.js');
try {
writeFileSync(
sourcePluginPath,
'export const NianCodeAgentBrowserPlugin = async () => ({});\n',
);
const { children, spawn } = createSpawnHarness();
const manager = new OpencodeManager({
port: 4337,
binPath: '/opt/opencode',
userDataDir,
bundledAgentBrowserPluginPath: sourcePluginPath,
runtimeConfigProvider: () => ({ config: {}, env: {} }),
spawn,
});
const startPromise = manager.start();
children[0].stdout.emit(
'data',
Buffer.from('opencode server listening on http://127.0.0.1:4337\n'),
);
await startPromise;
const installedPluginPath = join(
userDataDir,
'opencode',
'niancode-config',
'plugins',
'niancode-agent-browser.js',
);
expect(existsSync(installedPluginPath)).toBe(true);
expect(readFileSync(installedPluginPath, 'utf8')).toContain(
'NianCodeAgentBrowserPlugin',
);
} finally {
rmSync(userDataDir, { recursive: true, force: true });
rmSync(sourceDir, { recursive: true, force: true });
}
});
it('removes retired bundled skills from the managed config before spawning', async () => {
const userDataDir = mkdtempSync(join(tmpdir(), 'niancode-opencode-manager-'));
const bundledCourseSkillsDir = mkdtempSync(join(tmpdir(), 'niancode-course-skills-source-'));

View File

@@ -677,6 +677,58 @@ describe('opencode host api routes', () => {
}
});
it('closes the shared browser before and after switching projects', async () => {
const projectPath = await mkdtemp(join(tmpdir(), 'niancode-switch-active-'));
try {
await writeValidProjectConfig(projectPath);
const response = createResponse();
const operations: string[] = [];
const currentProject = {
id: 'prj_current',
path: join(projectPath, 'current'),
name: 'current',
};
const nextProject = {
id: 'prj_next',
path: projectPath,
name: 'next',
};
const closeBrowser = vi.fn(async () => {
operations.push('close-browser');
return {};
});
let activeProject = currentProject;
const handled = await handleOpencodeRoutes(
createRequest('POST', { projectId: nextProject.id }),
response.res,
new URL('http://127.0.0.1/api/opencode/projects/active'),
{
opencodeProjectStore: {
setActiveProject: vi.fn(async () => {
operations.push('set-active');
activeProject = nextProject;
return nextProject;
}),
listProjects: vi.fn(async () => [currentProject, nextProject]),
getActiveProject: vi.fn(async () => activeProject),
},
agentBrowser: {
close: closeBrowser,
},
} as never,
);
expect(handled).toBe(true);
expect(response.statusCode).toBe(200);
expect(closeBrowser).toHaveBeenCalledTimes(2);
expect(closeBrowser).toHaveBeenCalledWith(currentProject.path);
expect(operations).toEqual(['close-browser', 'set-active', 'close-browser']);
} finally {
await rm(projectPath, { recursive: true, force: true });
}
});
it('rejects active project selection when the project configuration is missing', async () => {
const projectPath = await mkdtemp(join(tmpdir(), 'niancode-set-active-missing-'));
try {
@@ -790,6 +842,51 @@ describe('opencode host api routes', () => {
}
});
it('closes the shared browser before and after removing the active project', async () => {
const projectPath = await mkdtemp(join(tmpdir(), 'niancode-remove-browser-'));
try {
const response = createResponse();
const activeProject = {
id: 'prj_removed',
path: projectPath,
name: 'removed',
};
const operations: string[] = [];
const closeBrowser = vi.fn(async () => {
operations.push('close-browser');
return {};
});
let storedActiveProject: typeof activeProject | null = activeProject;
const handled = await handleOpencodeRoutes(
createRequest('POST', { projectId: activeProject.id }),
response.res,
new URL('http://127.0.0.1/api/opencode/projects/remove'),
{
opencodeProjectStore: {
removeProject: vi.fn(async () => {
operations.push('remove-project');
storedActiveProject = null;
}),
listProjects: vi.fn(async () => []),
getActiveProject: vi.fn(async () => storedActiveProject),
},
agentBrowser: {
close: closeBrowser,
},
} as never,
);
expect(handled).toBe(true);
expect(response.statusCode).toBe(200);
expect(closeBrowser).toHaveBeenCalledTimes(2);
expect(closeBrowser).toHaveBeenCalledWith(activeProject.path);
expect(operations).toEqual(['close-browser', 'remove-project', 'close-browser']);
} finally {
await rm(projectPath, { recursive: true, force: true });
}
});
it('reports a missing root works-publish.json for a known project', async () => {
const projectPath = await mkdtemp(join(tmpdir(), 'niancode-works-publish-missing-'));
try {