import { beforeEach, describe, expect, it, vi } from 'vitest'; const electronMocks = vi.hoisted(() => { class MiniEmitter { private readonly listeners = new Map 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; setVisible: ReturnType; }> = []; 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 }; } ).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(); }); });