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 { TitleBar } from '@/components/layout/TitleBar'; 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 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 { 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 { return JSON.parse(String(init?.body ?? '{}')) as Record; } 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(document, 'elementsFromPoint', { configurable: true, value: vi.fn().mockReturnValue([]), }); 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(); expect(screen.queryByTestId('agent-browser-panel')).not.toBeInTheDocument(); expect(screen.getByRole('button', { name: '打开开发浏览器' })).toBeDisabled(); }); it('renders the collapsed toggle in the fixed titlebar layer', async () => { installBrowserApi(snapshot()); render(); const titlebarToggle = await screen.findByTestId('agent-browser-titlebar-toggle'); const button = titlebarToggle.querySelector('[data-testid="agent-browser-panel-open"]') as HTMLElement; expect(button).toHaveAttribute('aria-label', '打开开发浏览器'); expect(button).toHaveClass('no-drag'); expect(button).toHaveClass('bg-transparent'); expect(button).not.toHaveClass('rounded-full'); expect(screen.queryByTestId('agent-browser-panel')).not.toBeInTheDocument(); fireEvent.click(button); expect(await screen.findByTestId('agent-browser-panel')).toBeInTheDocument(); fireEvent.click(titlebarToggle.querySelector('[data-testid="agent-browser-panel-open"]') as HTMLElement); await waitFor(() => { expect(screen.queryByTestId('agent-browser-panel')).not.toBeInTheDocument(); }); }); it('keeps the active browser indicator inside the titlebar toggle bounds', async () => { installBrowserApi(snapshot('attached')); render(); const titlebarToggle = await screen.findByTestId('agent-browser-titlebar-toggle'); await waitFor(() => { expect(titlebarToggle.querySelector('span[aria-hidden="true"]')).toBeInTheDocument(); }); const indicator = titlebarToggle.querySelector('span[aria-hidden="true"]'); expect(indicator).toHaveClass('absolute', 'right-2', 'top-2'); expect(indicator).not.toHaveClass('right-0.5', 'top-0.5'); }); it('keeps the toggle visible when mounted with the integrated titlebar', async () => { window.electron.platform = 'darwin'; installBrowserApi(snapshot()); render( <> , ); const titlebarToggle = await screen.findByTestId('agent-browser-titlebar-toggle'); await waitFor(() => { expect(titlebarToggle.querySelector('[data-testid="agent-browser-panel-open"]')).toBeInTheDocument(); }); expect(titlebarToggle).toHaveClass('fixed', 'top-0', 'right-0', 'z-[100]', 'pointer-events-auto'); expect(titlebarToggle).toHaveStyle({ right: '0px' }); expect(document.documentElement.style.getPropertyValue('--agent-browser-titlebar-logo-gap')).toBe(''); expect(screen.queryByTestId('agent-browser-panel-close')).not.toBeInTheDocument(); fireEvent.click(titlebarToggle.querySelector('[data-testid="agent-browser-panel-open"]') as HTMLElement); await waitFor(() => { expect(document.documentElement.style.getPropertyValue('--agent-browser-titlebar-logo-gap')).toBe('4px'); }); }); it('starts the browser at the same baseline width as the other workspace columns', async () => { installBrowserApi(snapshot()); render(
, ); vi.spyOn(screen.getByTestId('opencode-chat-layout'), 'getBoundingClientRect') .mockReturnValue({ width: 960 } as DOMRect); fireEvent.click(screen.getByTestId('agent-browser-panel-open')); expect(await screen.findByTestId('agent-browser-panel')).toHaveStyle({ width: '320px' }); expect(screen.getByTestId('agent-browser-panel-spacer')).toHaveStyle({ width: '320px' }); }); it('keeps diagnostics collapsed and expands Console or Network upward from the bottom', async () => { installBrowserApi(snapshot()); render(); fireEvent.click(screen.getByTestId('agent-browser-panel-open')); const diagnostics = await screen.findByTestId('agent-browser-diagnostics'); const consoleTab = screen.getByRole('tab', { name: /Console/ }); expect(screen.queryByText('尚未开启')).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: '开启 AI 调试' })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: '打开' })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: '清空调试记录' })).not.toBeInTheDocument(); expect(diagnostics).toHaveAttribute('data-state', 'closed'); expect(diagnostics).toHaveStyle({ height: '42px' }); expect(consoleTab).toHaveAttribute('aria-expanded', 'false'); expect(screen.queryByTestId('agent-browser-diagnostics-resize-handle')).not.toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: '展开调试面板' })); expect(diagnostics).toHaveAttribute('data-state', 'open'); fireEvent.click(screen.getByRole('button', { name: '收起调试面板' })); expect(diagnostics).toHaveAttribute('data-state', 'closed'); fireEvent.click(consoleTab); expect(diagnostics).toHaveAttribute('data-state', 'open'); expect(diagnostics).toHaveStyle({ height: '260px' }); expect(consoleTab).toHaveAttribute('aria-expanded', 'true'); expect(screen.getByTestId('agent-browser-diagnostics-resize-handle')).toBeInTheDocument(); fireEvent.click(screen.getByRole('tab', { name: /Network/ })); expect(screen.getByLabelText('Network 请求')).toBeInTheDocument(); const resizeHandle = screen.getByTestId('agent-browser-diagnostics-resize-handle'); fireEvent.pointerDown(resizeHandle, { button: 0, clientY: 500, pointerId: 1 }); fireEvent.pointerMove(resizeHandle, { clientY: 420, pointerId: 1 }); expect(diagnostics).toHaveStyle({ height: '340px' }); fireEvent.pointerUp(resizeHandle, { pointerId: 1 }); fireEvent.click(screen.getByRole('button', { name: '收起调试面板' })); expect(diagnostics).toHaveAttribute('data-state', 'closed'); expect(diagnostics).toHaveStyle({ height: '42px' }); }); 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(); 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.submit(screen.getByRole('textbox', { name: '网页地址' }).closest('form') as HTMLFormElement); 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('does not invent a URL when the address field is empty', async () => { installBrowserApi(snapshot()); render(); fireEvent.click(screen.getByTestId('agent-browser-panel-open')); const addressInput = screen.getByRole('textbox', { name: '网页地址' }); expect(addressInput).toHaveValue(''); fireEvent.submit(addressInput.closest('form') as HTMLFormElement); await new Promise((resolve) => window.setTimeout(resolve, 0)); expect(hostApiFetchMock.mock.calls.some(([path]) => path === '/api/agent-browser/open')).toBe(false); }); it('keeps the panel collapsed when the agent opens the shared browser', async () => { installBrowserApi(snapshot('attached')); render(); expect(screen.queryByTestId('agent-browser-panel')).not.toBeInTheDocument(); act(() => { hostEventListeners.get('agent-browser:show')?.(snapshot('attached')); }); expect(screen.queryByTestId('agent-browser-panel')).not.toBeInTheDocument(); expect(screen.getByTestId('agent-browser-panel-open')).toHaveAttribute( 'title', '开发浏览器已收起,AI 调试已暂停', ); fireEvent.click(screen.getByTestId('agent-browser-panel-open')); expect(await screen.findByTestId('agent-browser-panel')).toBeInTheDocument(); }); it('ignores a delayed show event from the previous project', () => { installBrowserApi(snapshot()); render(); 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 without expanding the panel', async () => { installBrowserApi(snapshot('attached')); render(); expect(await screen.findByTestId('agent-browser-panel-open')).toBeInTheDocument(); expect(screen.queryByTestId('agent-browser-panel')).not.toBeInTheDocument(); fireEvent.click(screen.getByTestId('agent-browser-panel-open')); expect(await screen.findByTestId('agent-browser-panel')).toBeInTheDocument(); }); it('updates native view bounds and hides it when collapsed', async () => { installBrowserApi(snapshot('attached')); render(); fireEvent.click(screen.getByRole('button', { name: '打开开发浏览器' })); 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('resizes the panel from its left edge and auto-collapses below the threshold', async () => { installBrowserApi(snapshot('attached')); render(); fireEvent.click(screen.getByTestId('agent-browser-panel-open')); const panel = await screen.findByTestId('agent-browser-panel'); const handle = screen.getByTestId('agent-browser-panel-resize-handle'); const panelRect = vi.spyOn(panel, 'getBoundingClientRect'); panelRect.mockReturnValue({ width: 440 } as DOMRect); vi.spyOn(panel.parentElement as HTMLElement, 'getBoundingClientRect').mockReturnValue({ width: 1200, } as DOMRect); fireEvent.pointerDown(handle, { button: 0, clientX: 600, pointerId: 1, }); fireEvent.pointerMove(handle, { clientX: 440, pointerId: 1, }); expect(panel).toHaveStyle({ width: '600px' }); panelRect.mockReturnValue({ width: 600 } as DOMRect); fireEvent.pointerDown(handle, { button: 0, clientX: 600, pointerId: 2, }); fireEvent.pointerMove(handle, { clientX: 950, pointerId: 2, }); expect(screen.getByTestId('agent-browser-panel')).toHaveAttribute('data-state', 'closed'); await waitFor(() => expect(screen.queryByTestId('agent-browser-panel')).not.toBeInTheDocument()); expect(screen.getByTestId('agent-browser-panel-open')).toBeInTheDocument(); }); it('hides the native view while a renderer modal is open', async () => { installBrowserApi(snapshot('attached')); render(); fireEvent.click(screen.getByTestId('agent-browser-panel-open')); 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(); fireEvent.click(screen.getByRole('button', { name: '打开开发浏览器' })); fireEvent.click(screen.getByRole('tab', { name: /Console/ })); 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(); }); });