import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { useState } from 'react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { AgentBrowserPanel } from '@/pages/Chat/AgentBrowserPanel'; import type { 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); }, })); class MockResizeObserver { constructor(private readonly callback: ResizeObserverCallback) {} observe(element: Element) { this.callback([{ target: element } as ResizeObserverEntry], this as unknown as ResizeObserver); } disconnect() {} unobserve() {} } function snapshot(overrides: Partial = {}): AgentBrowserSnapshot { return { browserId: 'browser-a', projectId: 'project-a', projectPath: 'D:/repo', state: 'attached', generation: 1, url: 'http://127.0.0.1:4173/', title: 'App', visible: false, bounds: null, canGoBack: false, canGoForward: false, eventCursor: 0, ...overrides, }; } function body(init?: RequestInit): Record { return JSON.parse(String(init?.body ?? '{}')) as Record; } function latestPresentation() { const request = hostApiFetchMock.mock.calls.filter(([path]) => path === '/api/agent-browser/present').at(-1); return request ? body(request[1] as RequestInit) : null; } function Harness({ initialOpen = false }: { initialOpen?: boolean }) { const [open, setOpen] = useState(initialOpen); return ( ); } describe('AgentBrowserPanel', () => { beforeEach(() => { hostApiFetchMock.mockReset(); hostEventListeners.clear(); Object.defineProperty(globalThis, 'ResizeObserver', { configurable: true, value: MockResizeObserver, }); vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({ x: 600, y: 80, left: 600, top: 80, right: 1000, bottom: 680, width: 400, height: 600, toJSON: () => ({}), } as DOMRect); hostApiFetchMock.mockImplementation(async (path: string, init?: RequestInit) => { if (path.startsWith('/api/agent-browser/state?')) { return { success: true, browser: snapshot() }; } if (path === '/api/agent-browser/present') { return { success: true, browser: snapshot({ visible: body(init).visible === true, bounds: body(init).bounds as AgentBrowserSnapshot['bounds'], }), }; } if (path === '/api/agent-browser/diagnostics') { return { success: true, browser: snapshot({ visible: true }) }; } if (path === '/api/agent-browser/close') { return { success: true, browser: snapshot({ browserId: null, state: 'closed', visible: false }), }; } if (path === '/api/agent-browser/cdp/events') { return { success: true, page: { events: [], nextCursor: 0, hasMore: false } }; } throw new Error(`Unexpected request: ${path}`); }); }); afterEach(() => { vi.restoreAllMocks(); }); it('does no browser API work while closed and presents an agent-opened snapshot', async () => { render(); expect(screen.queryByTestId('agent-browser-panel')).not.toBeInTheDocument(); expect(hostApiFetchMock).not.toHaveBeenCalled(); act(() => { hostEventListeners.get('agent-browser:show')?.(snapshot()); }); expect(await screen.findByTestId('agent-browser-panel')).toBeInTheDocument(); await waitFor(() => { expect(hostApiFetchMock).toHaveBeenCalledWith( '/api/agent-browser/present', expect.anything(), ); }); const presentation = hostApiFetchMock.mock.calls.find( ([path, init]) => path === '/api/agent-browser/present' && body(init as RequestInit).visible === true, ); expect(body(presentation?.[1] as RequestInit)).toMatchObject({ visible: true, bounds: { x: 600, y: 80, width: 400, height: 600 }, }); }); it('enables Renderer diagnostics only after the drawer is explicitly expanded', async () => { render(); await screen.findByTestId('agent-browser-panel'); await waitFor(() => expect(hostApiFetchMock.mock.calls.some( ([path]) => String(path).startsWith('/api/agent-browser/state?'), )).toBe(true)); expect(hostApiFetchMock.mock.calls.some(([path]) => path === '/api/agent-browser/diagnostics')).toBe(false); fireEvent.click(screen.getByRole('button', { name: '展开调试面板' })); await waitFor(() => { const request = hostApiFetchMock.mock.calls.find(([path]) => path === '/api/agent-browser/diagnostics'); expect(body(request?.[1] as RequestInit)).toMatchObject({ enabled: true }); }); }); it('refreshes browser state once when the app regains focus', async () => { render(); await waitFor(() => expect(hostApiFetchMock.mock.calls.filter( ([path]) => String(path).startsWith('/api/agent-browser/state?'), )).toHaveLength(1)); hostApiFetchMock.mockClear(); act(() => window.dispatchEvent(new Event('focus'))); await waitFor(() => expect(hostApiFetchMock.mock.calls.filter( ([path]) => String(path).startsWith('/api/agent-browser/state?'), )).toHaveLength(1)); }); it('hides an embedded work page between tabs and reopens it without destroying or navigating it', async () => { const onOpenChange = vi.fn(); const view = render(); await waitFor(() => expect(latestPresentation()).toMatchObject({ project_id: 'project-a', visible: true })); expect(screen.getByRole('textbox', { name: '网页地址' })).toHaveValue('http://127.0.0.1:4173/'); view.rerender(); await waitFor(() => expect(latestPresentation()).toMatchObject({ visible: false })); expect(screen.queryByTestId('agent-browser-panel')).not.toBeInTheDocument(); expect(hostApiFetchMock.mock.calls.some(([path]) => path === '/api/agent-browser/close')).toBe(false); view.rerender(); await waitFor(() => expect(latestPresentation()).toMatchObject({ visible: true })); expect(screen.getByRole('textbox', { name: '网页地址' })).toHaveValue('http://127.0.0.1:4173/'); expect(screen.getByRole('button', { name: '刷新网页' })).toBeEnabled(); expect(hostApiFetchMock.mock.calls.some(([path]) => ['/api/agent-browser/close', '/api/agent-browser/open', '/api/agent-browser/navigate'].includes(String(path)))).toBe(false); view.unmount(); expect(hostApiFetchMock.mock.calls.filter(([path]) => path === '/api/agent-browser/close')).toHaveLength(1); }); it.each(['absolute', 'fixed'])('hides the native work page under a %s consultation and restores it beside a relative sidebar', async (position) => { const onOpenChange = vi.fn(); const content = (dockPosition: string) => <> ; const view = render(content('relative')); await waitFor(() => expect(latestPresentation()).toMatchObject({ visible: true })); view.rerender(content(position)); await waitFor(() => expect(latestPresentation()).toMatchObject({ visible: false })); expect(screen.getByText('老师咨询')).toBeVisible(); expect(hostApiFetchMock.mock.calls.some(([path]) => path === '/api/agent-browser/close')).toBe(false); view.rerender(content('relative')); await waitFor(() => expect(latestPresentation()).toMatchObject({ visible: true })); expect(hostApiFetchMock.mock.calls.some(([path]) => path === '/api/agent-browser/close')).toBe(false); }); it('rechecks consultation occlusion after a responsive resize without needing a DOM change', async () => { const originalGetComputedStyle = window.getComputedStyle.bind(window); let consultationPosition = 'relative'; vi.spyOn(window, 'getComputedStyle').mockImplementation((element, pseudoElement) => { const computed = originalGetComputedStyle(element, pseudoElement); if (element.id !== 'coding-consultation-dock') return computed; // jsdom does not evaluate responsive media queries; simulate their computed position. return new Proxy(computed, { get: (target, property) => property === 'position' ? consultationPosition : Reflect.get(target, property), }); }); render(<> ); await waitFor(() => expect(latestPresentation()).toMatchObject({ visible: true })); consultationPosition = 'absolute'; act(() => window.dispatchEvent(new Event('resize'))); await waitFor(() => expect(latestPresentation()).toMatchObject({ visible: false })); consultationPosition = 'relative'; act(() => window.dispatchEvent(new Event('resize'))); await waitFor(() => expect(latestPresentation()).toMatchObject({ visible: true })); expect(hostApiFetchMock.mock.calls.some(([path]) => path === '/api/agent-browser/close')).toBe(false); }); });