240 lines
9.9 KiB
TypeScript
240 lines
9.9 KiB
TypeScript
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<string, (payload: unknown) => 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> = {}): 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<string, unknown> {
|
|
return JSON.parse(String(init?.body ?? '{}')) as Record<string, unknown>;
|
|
}
|
|
|
|
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 (
|
|
<AgentBrowserPanel
|
|
projectId="project-a"
|
|
open={open}
|
|
onOpenChange={setOpen}
|
|
/>
|
|
);
|
|
}
|
|
|
|
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(<Harness />);
|
|
|
|
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(<Harness initialOpen />);
|
|
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(<Harness initialOpen />);
|
|
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(<AgentBrowserPanel projectId="project-a" open embedded onOpenChange={onOpenChange} />);
|
|
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(<AgentBrowserPanel projectId="project-a" open={false} embedded onOpenChange={onOpenChange} />);
|
|
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(<AgentBrowserPanel projectId="project-a" open embedded onOpenChange={onOpenChange} />);
|
|
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) => <>
|
|
<AgentBrowserPanel projectId="project-a" open embedded onOpenChange={onOpenChange} />
|
|
<aside id="coding-consultation-dock" style={{ position: dockPosition as 'relative' | 'absolute' | 'fixed' }}>老师咨询</aside>
|
|
</>;
|
|
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(<>
|
|
<AgentBrowserPanel projectId="project-a" open embedded onOpenChange={vi.fn()} />
|
|
<aside id="coding-consultation-dock">朋友咨询</aside>
|
|
</>);
|
|
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);
|
|
});
|
|
});
|