需求:在 AI 编程会话中让用户与 Agent 共享同一浏览器页面,并查看控制台与网络信息。 实现:新增沙箱浏览器内核、Host API/渲染器面板、OpenCode 工具接入及安全边界测试。
212 lines
7.2 KiB
TypeScript
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();
|
|
});
|
|
});
|