Files
makelore/tests/unit/agent-browser-electron-adapter.test.ts
brother7 e97a7ce9df feat: 增加共享 Agent Browser 调试能力
需求:在 AI 编程会话中让用户与 Agent 共享同一浏览器页面,并查看控制台与网络信息。

实现:新增沙箱浏览器内核、Host API/渲染器面板、OpenCode 工具接入及安全边界测试。
2026-07-31 14:53:36 +08:00

212 lines
7.2 KiB
TypeScript

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