Files
makelore/tests/unit/agent-browser-panel.test.tsx
inman f4113a872f
Some checks failed
Electron E2E / Electron E2E (macos-latest) (push) Has been cancelled
Electron E2E / Electron E2E (ubuntu-latest) (push) Has been cancelled
Electron E2E / Electron E2E (windows-latest) (push) Has been cancelled
收口 Makelore 客户端变更
2026-08-12 22:43:09 +08:00

617 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<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);
};
},
}));
let resizeCallback: ResizeObserverCallback | null = null;
class MockResizeObserver {
constructor(callback: ResizeObserverCallback) {
resizeCallback = callback;
}
observe() {}
unobserve() {}
disconnect() {}
}
function snapshot(
state: AgentBrowserSnapshot['state'] = 'closed',
overrides: Partial<AgentBrowserSnapshot> = {},
): 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<string, unknown> {
return JSON.parse(String(init?.body ?? '{}')) as Record<string, unknown>;
}
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(<AgentBrowserPanel projectPath={null} />);
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(<AgentBrowserPanel projectPath="D:/repo" />);
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(<AgentBrowserPanel projectPath="D:/repo" />);
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(
<>
<TitleBar integrated />
<AgentBrowserPanel projectPath="D:/repo" />
</>,
);
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(
<div data-testid="opencode-chat-layout">
<div data-testid="agent-conversation-sidebar" />
<div data-testid="opencode-chat-canvas" />
<AgentBrowserPanel projectPath="D:/repo" />
</div>,
);
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(<AgentBrowserPanel projectPath="D:/repo" />);
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(<AgentBrowserPanel projectPath="D:/repo" />);
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(<AgentBrowserPanel projectPath="D:/repo" />);
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(<AgentBrowserPanel projectId="project-1" projectPath="D:/repo" />);
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(<AgentBrowserPanel projectId="project-2" projectPath="D:/repo-2" />);
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(<AgentBrowserPanel projectId="project-1" projectPath="D:/repo" />);
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(<AgentBrowserPanel projectPath="D:/repo" />);
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(<AgentBrowserPanel projectPath="D:/repo" />);
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(<AgentBrowserPanel projectPath="D:/repo" />);
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(<AgentBrowserPanel projectPath="D:/repo" />);
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();
});
});