merge: 集成共享 Agent Browser 调试能力
需求:将 Agent Browser 独立功能提交合并到包含 AI 设计 Workspace 的最新主线。 实现:保留双方模块初始化、Host API 路由和 README 产品说明。 # Conflicts: # electron/api/context.ts
This commit is contained in:
1023
tests/unit/agent-browser-core.test.ts
Normal file
1023
tests/unit/agent-browser-core.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
211
tests/unit/agent-browser-electron-adapter.test.ts
Normal file
211
tests/unit/agent-browser-electron-adapter.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
430
tests/unit/agent-browser-panel.test.tsx
Normal file
430
tests/unit/agent-browser-panel.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
124
tests/unit/agent-browser-plugin.test.ts
Normal file
124
tests/unit/agent-browser-plugin.test.ts
Normal 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'],
|
||||
});
|
||||
});
|
||||
});
|
||||
348
tests/unit/agent-browser-routes.test.ts
Normal file
348
tests/unit/agent-browser-routes.test.ts
Normal 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(),
|
||||
}));
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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('运营宣传角色');
|
||||
|
||||
@@ -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-'));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user