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 { 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(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('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.click(screen.getByRole('button', { name: '打开' })); 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('expands 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(await screen.findByTestId('agent-browser-panel')).toBeInTheDocument(); expect(screen.getByTestId('agent-browser-debug-status')).toHaveTextContent('AI 可调试'); }); 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 after remounting', async () => { installBrowserApi(snapshot('attached')); render(); expect(await screen.findByTestId('agent-browser-panel')).toBeInTheDocument(); expect(screen.getByTestId('agent-browser-debug-status')).toHaveTextContent('AI 可调试'); }); it('updates native view bounds and hides it when collapsed', async () => { installBrowserApi(snapshot('attached')); render(); fireEvent.click(screen.getByRole('button', { name: '打开开发浏览器' })); expect(await screen.findByText('AI 可调试')).toBeInTheDocument(); 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('hides the native view while a renderer modal is open', async () => { installBrowserApi(snapshot('attached')); render(); 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: '打开开发浏览器' })); 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(); }); });