Files
makelore/tests/unit/agent-browser-panel.test.tsx

334 lines
16 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 === '/api/agent-browser/ensure-work') return { success: true, status: 'ready', browser: snapshot() };
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('offers login recovery instead of repeatedly trying to start the page', async () => {
const original = hostApiFetchMock.getMockImplementation()!;
hostApiFetchMock.mockImplementation(async (path: string, init?: RequestInit) => path === '/api/agent-browser/ensure-work'
? { status: 'failed', errorCode: 'CODING_PROVIDER_AUTH_REQUIRED', message: '登录已失效' } : original(path, init));
const onRecovery = vi.fn();
render(<AgentBrowserPanel projectId="project-a" embedded open onOpenChange={vi.fn()} onRecovery={onRecovery} />);
fireEvent.click(await screen.findByRole('button', { name: '登录并继续' }));
expect(onRecovery).toHaveBeenCalledWith('login', true);
expect(hostApiFetchMock.mock.calls.filter(([path]) => path === '/api/agent-browser/ensure-work')).toHaveLength(1);
});
it('shows preparation and a retryable failure without covering it with the native page', async () => {
const original = hostApiFetchMock.getMockImplementation()!;
hostApiFetchMock.mockImplementation(async (path: string, init?: RequestInit) => {
if (path === '/api/agent-browser/ensure-work') return { status: 'failed', message: '当前项目还没有可打开的页面。' };
return original(path, init);
});
const onOpenChange = vi.fn();
render(<AgentBrowserPanel projectId="project-a" conversationId="c1" embedded open onOpenChange={onOpenChange} />);
await screen.findByText('当前项目还没有可打开的页面。');
expect(latestPresentation()).toMatchObject({ visible: false });
const first = hostApiFetchMock.mock.calls.find(([path]) => path === '/api/agent-browser/ensure-work');
fireEvent.click(screen.getByRole('button', { name: '帮我检查并打开' }));
await waitFor(() => expect(hostApiFetchMock.mock.calls.filter(([path]) => path === '/api/agent-browser/ensure-work')).toHaveLength(2));
const last = hostApiFetchMock.mock.calls.filter(([path]) => path === '/api/agent-browser/ensure-work').at(-1);
expect(body(first![1]).request_id).not.toBe(body(last![1]).request_id);
fireEvent.click(screen.getByRole('button', { name: '回到操作对话' }));
expect(onOpenChange).toHaveBeenCalledWith(false);
});
it('ignores a stale closed snapshot arriving after foreground restoration', async () => {
let resolveState!: (value: unknown) => void;
const original = hostApiFetchMock.getMockImplementation()!;
hostApiFetchMock.mockImplementation(async (path: string, init?: RequestInit) => {
if (path.startsWith('/api/agent-browser/state?')) return new Promise((resolve) => { resolveState = resolve; });
if (path === '/api/agent-browser/ensure-work') return { status: 'ready', browser: snapshot({ generation: 2 }) };
if (path === '/api/agent-browser/present') return { success: true, browser: snapshot({ generation: 2, visible: body(init).visible === true }) };
return original(path, init);
});
render(<AgentBrowserPanel projectId="project-a" embedded open onOpenChange={vi.fn()} />);
await waitFor(() => expect(latestPresentation()).toMatchObject({ visible: true }));
await act(async () => resolveState({ success: true, browser: snapshot({ generation: 1, browserId: null, state: 'closed', url: '' }) }));
expect(screen.queryByText('正在打开你的作品…')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: '刷新网页' })).toBeEnabled();
expect(latestPresentation()).toMatchObject({ visible: true });
});
it('recovers a closed view without waiting for another focus event', async () => {
render(<AgentBrowserPanel projectId="project-a" embedded open onOpenChange={vi.fn()} />);
await waitFor(() => expect(latestPresentation()).toMatchObject({ visible: true }));
act(() => hostEventListeners.get('agent-browser:state')?.(snapshot({ browserId: null, state: 'closed', url: '' })));
await waitFor(() => expect(hostApiFetchMock.mock.calls.filter(([path]) => path === '/api/agent-browser/ensure-work')).toHaveLength(2));
await waitFor(() => expect(screen.getByRole('button', { name: '刷新网页' })).toBeEnabled());
});
it('recovers a presentation-close race and removes the internal error', async () => {
const original = hostApiFetchMock.getMockImplementation()!;
let failures = 0;
hostApiFetchMock.mockImplementation(async (path: string, init?: RequestInit) => {
if (path === '/api/agent-browser/present' && body(init).visible && failures++ === 0) throw new Error('开发浏览器已关闭。');
return original(path, init);
});
render(<AgentBrowserPanel projectId="project-a" embedded open onOpenChange={vi.fn()} />);
await waitFor(() => expect(failures).toBe(2));
expect(screen.queryByText('开发浏览器已关闭。')).not.toBeInTheDocument();
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});
it('bounds repeated presentation failures and offers a working retry', async () => {
const original = hostApiFetchMock.getMockImplementation()!;
let unavailable = true;
let failures = 0;
hostApiFetchMock.mockImplementation(async (path: string, init?: RequestInit) => {
if (path === '/api/agent-browser/present' && body(init).visible && unavailable) {
failures++;
throw new Error('开发浏览器已关闭。');
}
return original(path, init);
});
render(<AgentBrowserPanel projectId="project-a" embedded open onOpenChange={vi.fn()} />);
const retry = await screen.findByRole('button', { name: '重新打开作品' });
expect(failures).toBe(3);
expect(screen.queryByText('开发浏览器已关闭。')).not.toBeInTheDocument();
unavailable = false;
fireEvent.click(retry);
await waitFor(() => expect(latestPresentation()).toMatchObject({ visible: true }));
await waitFor(() => expect(screen.queryByText('作品画面暂时没能显示')).not.toBeInTheDocument());
});
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(screen.queryByTestId('agent-browser-diagnostics')).not.toBeInTheDocument();
expect(screen.queryByRole('tab', { name: /Console|Network/ })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: '展开调试面板' })).not.toBeInTheDocument();
expect(hostApiFetchMock.mock.calls.some(([path]) => ['/api/agent-browser/diagnostics', '/api/agent-browser/cdp/events'].includes(String(path)))).toBe(false);
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);
});
});