feat: 增加共享 Agent Browser 调试能力
需求:在 AI 编程会话中让用户与 Agent 共享同一浏览器页面,并查看控制台与网络信息。 实现:新增沙箱浏览器内核、Host API/渲染器面板、OpenCode 工具接入及安全边界测试。
This commit is contained in:
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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user