070959e133
landing-page-staging / Deploy landing page to staging (push) Has been skipped
landing-page-ci / Validate landing page (push) Failing after 4s
visual-baseline / Capture visual baselines (push) Has been cancelled
bake-plugin-previews / Bake plugin previews (push) Has been cancelled
964 lines
37 KiB
TypeScript
964 lines
37 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
import { act, useState } from 'react';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { installMockOpenDesignHost } from '@open-design/host/testing';
|
|
|
|
import { DesignBrowserPanel } from '../../src/components/DesignBrowserPanel';
|
|
import { I18nProvider } from '../../src/i18n';
|
|
import { writeProjectTextFile } from '../../src/providers/registry';
|
|
|
|
// The panel imports these writers from the registry at module load; stub them so
|
|
// rendering never reaches the network.
|
|
vi.mock('../../src/providers/registry', async () => {
|
|
const actual = await vi.importActual<typeof import('../../src/providers/registry')>(
|
|
'../../src/providers/registry',
|
|
);
|
|
return {
|
|
...actual,
|
|
openExternalUrl: vi.fn(async () => true),
|
|
writeProjectTextFile: vi.fn(async () => null),
|
|
writeProjectBase64File: vi.fn(async () => null),
|
|
};
|
|
});
|
|
|
|
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
|
|
let restoreHost: (() => void) | null = null;
|
|
|
|
beforeEach(() => {
|
|
window.localStorage.clear();
|
|
vi.mocked(writeProjectTextFile).mockResolvedValue(null);
|
|
// Makes isOpenDesignHostAvailable() true so the panel renders the desktop
|
|
// <webview> branch (rather than the iframe fallback).
|
|
restoreHost = installMockOpenDesignHost();
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
restoreHost?.();
|
|
restoreHost = null;
|
|
vi.useRealTimers();
|
|
window.localStorage.clear();
|
|
});
|
|
|
|
function dispatchWebviewNavigate(webview: HTMLElement, url: string) {
|
|
act(() => {
|
|
const event = new Event('did-navigate') as Event & { url?: string; isMainFrame?: boolean };
|
|
event.url = url;
|
|
event.isMainFrame = true;
|
|
webview.dispatchEvent(event);
|
|
});
|
|
}
|
|
|
|
function dispatchWebviewTitle(webview: HTMLElement, title: string) {
|
|
act(() => {
|
|
const event = new Event('page-title-updated') as Event & { title?: string };
|
|
event.title = title;
|
|
webview.dispatchEvent(event);
|
|
});
|
|
}
|
|
|
|
function getAddressDisplay(container: HTMLElement) {
|
|
return {
|
|
title: container.querySelector('.db-address-title')?.textContent ?? '',
|
|
url: container.querySelector('.db-address-url')?.textContent ?? '',
|
|
};
|
|
}
|
|
|
|
describe('DesignBrowserPanel <webview> navigation', () => {
|
|
it('keeps external browser mutation and annotation tools hidden', () => {
|
|
render(
|
|
<DesignBrowserPanel
|
|
projectId="proj-webview-more-tools"
|
|
initialTitle="Example"
|
|
initialUrl="https://example.com"
|
|
onOpenFile={() => {}}
|
|
onRefreshFiles={() => {}}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.queryByRole('button', { name: 'Tune element' })).toBeNull();
|
|
expect(screen.queryByRole('button', { name: 'Edit live DOM' })).toBeNull();
|
|
expect(screen.queryByRole('button', { name: 'Mark' })).toBeNull();
|
|
expect(screen.queryByRole('button', { name: 'Comment' })).toBeNull();
|
|
expect(screen.getByRole('button', { name: 'Screenshot' })).toBeTruthy();
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Browser menu' }));
|
|
|
|
expect(screen.queryByRole('menuitem', { name: /Tune Element/ })).toBeNull();
|
|
expect(screen.queryByRole('menuitem', { name: /Edit Live DOM/ })).toBeNull();
|
|
expect(screen.queryByRole('menuitem', { name: /Edit HTML/ })).toBeNull();
|
|
expect(screen.queryByRole('menuitem', { name: 'Mark' })).toBeNull();
|
|
expect(screen.queryByRole('menuitem', { name: 'Comment' })).toBeNull();
|
|
expect(screen.getByRole('menuitem', { name: 'Copy Screenshot' })).toBeTruthy();
|
|
});
|
|
|
|
it('opens the browser menu and highlights Download Page for attention requests', async () => {
|
|
const navigateRequest = { url: 'https://example.com', nonce: 1 };
|
|
const attentionRequest = { action: 'download-page' as const, nonce: 1 };
|
|
const view = render(
|
|
<DesignBrowserPanel
|
|
projectId="proj-webview-download-attention"
|
|
initialTitle="Example"
|
|
initialUrl="https://example.com"
|
|
navigateRequest={navigateRequest}
|
|
attentionRequest={attentionRequest}
|
|
onOpenFile={() => {}}
|
|
onRefreshFiles={() => {}}
|
|
/>,
|
|
);
|
|
|
|
const downloadItem = await screen.findByRole('menuitem', { name: 'Download Page' });
|
|
expect(downloadItem.classList.contains('is-attention')).toBe(true);
|
|
expect(screen.getByText(/click Download Page here/)).toBeTruthy();
|
|
|
|
view.rerender(
|
|
<DesignBrowserPanel
|
|
projectId="proj-webview-download-attention"
|
|
initialTitle="Example"
|
|
initialUrl="https://example.com"
|
|
navigateRequest={{ ...navigateRequest }}
|
|
attentionRequest={attentionRequest}
|
|
onOpenFile={() => {}}
|
|
onRefreshFiles={() => {}}
|
|
/>,
|
|
);
|
|
await act(async () => {
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(screen.getByRole('menuitem', { name: 'Download Page' })).toBeTruthy();
|
|
});
|
|
|
|
it('closes the browser menu and uses the status toast while downloading a page snapshot', async () => {
|
|
let resolveCapture: (value: unknown) => void = () => {};
|
|
const capturePromise = new Promise<unknown>((resolve) => {
|
|
resolveCapture = resolve;
|
|
});
|
|
vi.mocked(writeProjectTextFile).mockImplementation(async (_projectId, name) => ({
|
|
kind: 'code',
|
|
mime: 'application/json',
|
|
mtime: 1,
|
|
name,
|
|
size: 1,
|
|
type: 'file',
|
|
}));
|
|
const onOpenFile = vi.fn();
|
|
const { container } = render(
|
|
<DesignBrowserPanel
|
|
projectId="proj-webview-download-toast"
|
|
initialTitle="Example"
|
|
initialUrl="https://example.com"
|
|
onOpenFile={onOpenFile}
|
|
onRefreshFiles={() => {}}
|
|
/>,
|
|
);
|
|
const webview = container.querySelector('webview.db-webview') as HTMLElement & {
|
|
executeJavaScript?: ReturnType<typeof vi.fn>;
|
|
};
|
|
webview.executeJavaScript = vi.fn(() => capturePromise);
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Browser menu' }));
|
|
const downloadItem = screen.getByRole('menuitem', { name: 'Download Page' });
|
|
expect(downloadItem.classList.contains('is-loading')).toBe(false);
|
|
|
|
fireEvent.click(downloadItem);
|
|
|
|
expect(screen.queryByRole('menuitem', { name: 'Download Page' })).toBeNull();
|
|
expect(screen.getByText(/Saving page snapshot/)).toBeTruthy();
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Browser menu' }));
|
|
expect((screen.getByRole('menuitem', { name: 'Copy Screenshot' }) as HTMLButtonElement).disabled).toBe(false);
|
|
expect((screen.getByRole('menuitem', { name: 'Save Page Brief' }) as HTMLButtonElement).disabled).toBe(false);
|
|
expect((screen.getByRole('menuitem', { name: 'Download Page' }) as HTMLButtonElement).disabled).toBe(true);
|
|
|
|
await act(async () => {
|
|
resolveCapture({
|
|
title: 'Example',
|
|
url: 'https://example.com',
|
|
html: '<!doctype html><html><body>Example</body></html>',
|
|
css: '',
|
|
resources: [],
|
|
});
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/Saved page snapshot/)).toBeTruthy();
|
|
});
|
|
expect(onOpenFile).not.toHaveBeenCalled();
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Design Files' }));
|
|
|
|
expect(onOpenFile).toHaveBeenCalledTimes(1);
|
|
expect(onOpenFile.mock.calls[0]?.[0]).toMatch(/^browser\/snapshots\/example\.com-/);
|
|
expect(onOpenFile.mock.calls[0]?.[0]).toMatch(/\/manifest\.json$/);
|
|
});
|
|
|
|
it('shows elapsed snapshot time and cancels a pending page snapshot', async () => {
|
|
vi.useFakeTimers();
|
|
let resolveCapture: (value: unknown) => void = () => {};
|
|
const capturePromise = new Promise<unknown>((resolve) => {
|
|
resolveCapture = resolve;
|
|
});
|
|
vi.mocked(writeProjectTextFile).mockImplementation(async (_projectId, name) => ({
|
|
kind: 'code',
|
|
mime: 'application/json',
|
|
mtime: 1,
|
|
name,
|
|
size: 1,
|
|
type: 'file',
|
|
}));
|
|
const onPageSnapshotToast = vi.fn();
|
|
const { container } = render(
|
|
<DesignBrowserPanel
|
|
projectId="proj-webview-download-cancel"
|
|
initialTitle="Example"
|
|
initialUrl="https://example.com"
|
|
onOpenFile={() => {}}
|
|
onRefreshFiles={() => {}}
|
|
onPageSnapshotToast={onPageSnapshotToast}
|
|
/>,
|
|
);
|
|
const webview = container.querySelector('webview.db-webview') as HTMLElement & {
|
|
executeJavaScript?: ReturnType<typeof vi.fn>;
|
|
};
|
|
webview.executeJavaScript = vi.fn(() => capturePromise);
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Browser menu' }));
|
|
fireEvent.click(screen.getByRole('menuitem', { name: 'Download Page' }));
|
|
|
|
expect(container.querySelector('.db-status')).toBeNull();
|
|
expect(onPageSnapshotToast).toHaveBeenLastCalledWith(expect.objectContaining({
|
|
elapsedSeconds: 0,
|
|
status: 'loading',
|
|
}));
|
|
|
|
act(() => {
|
|
vi.advanceTimersByTime(2100);
|
|
});
|
|
|
|
expect(container.querySelector('.db-status')).toBeNull();
|
|
expect(onPageSnapshotToast).toHaveBeenLastCalledWith(expect.objectContaining({
|
|
elapsedSeconds: 2,
|
|
status: 'loading',
|
|
}));
|
|
|
|
const latestToast = onPageSnapshotToast.mock.calls.at(-1)?.[0];
|
|
expect(latestToast?.onCancel).toEqual(expect.any(Function));
|
|
act(() => {
|
|
latestToast?.onCancel?.();
|
|
});
|
|
await act(async () => {
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(container.querySelector('.db-status')).toBeNull();
|
|
expect(onPageSnapshotToast).toHaveBeenLastCalledWith(expect.objectContaining({
|
|
elapsedSeconds: 2,
|
|
status: 'canceled',
|
|
}));
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Browser menu' }));
|
|
expect((screen.getByRole('menuitem', { name: 'Download Page' }) as HTMLButtonElement).disabled).toBe(false);
|
|
|
|
await act(async () => {
|
|
resolveCapture({
|
|
title: 'Example',
|
|
url: 'https://example.com',
|
|
html: '<!doctype html><html><body>Example</body></html>',
|
|
css: '',
|
|
resources: [],
|
|
});
|
|
await Promise.resolve();
|
|
});
|
|
});
|
|
|
|
it('does not restart the snapshot progress effect when the parent toast callback changes identity', async () => {
|
|
function UnstableToastCallbackHarness() {
|
|
const [toastEventCount, setToastEventCount] = useState(0);
|
|
return (
|
|
<>
|
|
<span data-testid="toast-event-count">{toastEventCount}</span>
|
|
<DesignBrowserPanel
|
|
projectId="proj-webview-download-unstable-toast"
|
|
initialTitle="Example"
|
|
initialUrl="https://example.com"
|
|
onOpenFile={() => {}}
|
|
onRefreshFiles={() => {}}
|
|
onPageSnapshotToast={() => setToastEventCount((count) => count + 1)}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
const capturePromise = new Promise<unknown>(() => {});
|
|
const { container } = render(<UnstableToastCallbackHarness />);
|
|
const webview = container.querySelector('webview.db-webview') as HTMLElement & {
|
|
executeJavaScript?: ReturnType<typeof vi.fn>;
|
|
};
|
|
webview.executeJavaScript = vi.fn(() => capturePromise);
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Browser menu' }));
|
|
fireEvent.click(screen.getByRole('menuitem', { name: 'Download Page' }));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('toast-event-count').textContent).toBe('1');
|
|
});
|
|
await act(async () => {
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(screen.getByTestId('toast-event-count').textContent).toBe('1');
|
|
});
|
|
|
|
it('saves only HTML + CSS and never fetches page assets', async () => {
|
|
// Design-system extraction reads back nothing but page.html + styles.css
|
|
// (ProjectView.readLocalBrowserPageArchiveSnapshot → extract-from-html posts
|
|
// only { html, css, baseUrl }), so Download Page must not fan out to the
|
|
// page's images/fonts/scripts even when the capture lists them.
|
|
const fetchMock = vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('should not fetch assets'));
|
|
const writes: Array<{ name: string; content: string }> = [];
|
|
vi.mocked(writeProjectTextFile).mockImplementation(async (_projectId, name, content) => {
|
|
writes.push({ name, content: String(content ?? '') });
|
|
return { kind: 'code', mime: 'application/json', mtime: 1, name, size: 1, type: 'file' };
|
|
});
|
|
const { container } = render(
|
|
<DesignBrowserPanel
|
|
projectId="proj-webview-download-essentials"
|
|
initialTitle="Example"
|
|
initialUrl="https://example.com"
|
|
onOpenFile={() => {}}
|
|
onRefreshFiles={() => {}}
|
|
/>,
|
|
);
|
|
const webview = container.querySelector('webview.db-webview') as HTMLElement & {
|
|
executeJavaScript?: ReturnType<typeof vi.fn>;
|
|
};
|
|
webview.executeJavaScript = vi.fn().mockResolvedValue({
|
|
title: 'Example',
|
|
url: 'https://example.com',
|
|
html: '<!doctype html><html><body>Example</body></html>',
|
|
css: 'body{color:#111}',
|
|
resources: [{ kind: 'image', url: 'https://cdn.example.test/hero.png' }],
|
|
});
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Browser menu' }));
|
|
fireEvent.click(screen.getByRole('menuitem', { name: 'Download Page' }));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/Saved page snapshot/)).toBeTruthy();
|
|
});
|
|
|
|
// The listed image is never fetched, and the manifest records no resources.
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
expect(writes.find((w) => w.name.endsWith('/page.html'))?.content).toContain('Example');
|
|
expect(writes.find((w) => w.name.endsWith('/styles.css'))?.content).toContain('#111');
|
|
const manifestWrite = writes.find((w) => w.name.endsWith('/manifest.json'));
|
|
expect(manifestWrite).toBeTruthy();
|
|
expect(JSON.parse(manifestWrite!.content).resources).toEqual([]);
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Browser menu' }));
|
|
expect((screen.getByRole('menuitem', { name: 'Download Page' }) as HTMLButtonElement).disabled).toBe(false);
|
|
|
|
fetchMock.mockRestore();
|
|
});
|
|
|
|
it('does not re-fire the snapshot ticker when the parent recreates onPageSnapshotToast', async () => {
|
|
// Regression for "Maximum update depth exceeded": the 1s progress ticker's
|
|
// publish() emits a toast → the parent's setState re-renders and hands back a
|
|
// fresh onPageSnapshotToast identity. While that identity was an effect dep,
|
|
// every such render tore down and re-ran the effect, whose immediate publish()
|
|
// looped the cycle. The effect must subscribe to `archiveSaving` only.
|
|
let resolveCapture: (value: unknown) => void = () => {};
|
|
const capturePromise = new Promise<unknown>((resolve) => {
|
|
resolveCapture = resolve;
|
|
});
|
|
vi.mocked(writeProjectTextFile).mockResolvedValue({
|
|
kind: 'code',
|
|
mime: 'application/json',
|
|
mtime: 1,
|
|
name: 'x',
|
|
size: 1,
|
|
type: 'file',
|
|
});
|
|
const toastSpy = vi.fn();
|
|
// Mirrors FileWorkspace: a brand-new onPageSnapshotToast on every render.
|
|
function Harness({ nonce }: { nonce: number }) {
|
|
void nonce;
|
|
return (
|
|
<DesignBrowserPanel
|
|
projectId="proj-snapshot-ticker-stable"
|
|
initialTitle="Example"
|
|
initialUrl="https://example.com"
|
|
onOpenFile={() => {}}
|
|
onRefreshFiles={() => {}}
|
|
onPageSnapshotToast={(event) => toastSpy(event)}
|
|
/>
|
|
);
|
|
}
|
|
const view = render(<Harness nonce={0} />);
|
|
const webview = view.container.querySelector('webview.db-webview') as HTMLElement & {
|
|
executeJavaScript?: ReturnType<typeof vi.fn>;
|
|
};
|
|
// Leave the capture pending so the snapshot stays in-progress (archiveSaving
|
|
// true) across the parent re-renders below.
|
|
webview.executeJavaScript = vi.fn(() => capturePromise);
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Browser menu' }));
|
|
fireEvent.click(screen.getByRole('menuitem', { name: 'Download Page' }));
|
|
|
|
const baseline = toastSpy.mock.calls.length;
|
|
for (let i = 1; i <= 5; i += 1) {
|
|
view.rerender(<Harness nonce={i} />);
|
|
}
|
|
|
|
// No timer advanced, so any growth here means the effect re-ran per render —
|
|
// the regressed behavior. The fixed effect ignores onPageSnapshotToast churn.
|
|
expect(toastSpy.mock.calls.length).toBe(baseline);
|
|
|
|
resolveCapture({
|
|
title: 'Example',
|
|
url: 'https://example.com',
|
|
html: '<!doctype html><html><body>Example</body></html>',
|
|
css: '',
|
|
resources: [],
|
|
});
|
|
await act(async () => {
|
|
await Promise.resolve();
|
|
});
|
|
});
|
|
|
|
it('searches inspiration actions and adds an operation prompt for the current browser tab', () => {
|
|
const onRequestBrowserUsePrompt = vi.fn();
|
|
|
|
render(
|
|
<I18nProvider initial="zh-CN">
|
|
<DesignBrowserPanel
|
|
projectId="proj-webview-browser-use"
|
|
initialTitle="Example"
|
|
initialUrl="https://example.com"
|
|
onOpenFile={() => {}}
|
|
onRefreshFiles={() => {}}
|
|
onRequestBrowserUsePrompt={onRequestBrowserUsePrompt}
|
|
/>
|
|
</I18nProvider>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '灵感' }));
|
|
fireEvent.change(screen.getByRole('searchbox', { name: '搜索灵感' }), {
|
|
target: { value: '字体' },
|
|
});
|
|
expect(screen.queryByRole('menuitem', { name: /validate_view/ })).toBeNull();
|
|
fireEvent.click(screen.getByRole('menuitem', { name: /extract_fonts/ }));
|
|
|
|
expect(onRequestBrowserUsePrompt).toHaveBeenCalledTimes(1);
|
|
const prompt = onRequestBrowserUsePrompt.mock.calls[0]?.[0] as string;
|
|
expect(prompt).toContain('@agent-browser');
|
|
expect(prompt).toContain('Operation: extract_fonts');
|
|
expect(prompt).toContain('- title: Example');
|
|
expect(prompt).toContain('- url: https://example.com');
|
|
expect(prompt).toContain('browser-use / browser-harness style evidence');
|
|
});
|
|
|
|
it('pins the webview src to the load target when the guest commits a redirected URL', () => {
|
|
// Regression guard for the blank-page bug: the embedded <webview> rendered
|
|
// but never painted because did-navigate fed the committed (trailing-slash)
|
|
// URL straight back into the src prop, so Electron re-navigated and aborted
|
|
// the in-flight load (ERR_ABORTED -3). The load target (src) must stay put
|
|
// while only the address bar follows the committed URL.
|
|
const { container } = render(
|
|
<DesignBrowserPanel projectId="proj-webview" onOpenFile={() => {}} onRefreshFiles={() => {}} />,
|
|
);
|
|
|
|
const input = screen.getByLabelText('Browser address') as HTMLInputElement;
|
|
fireEvent.change(input, { target: { value: 'example.com' } });
|
|
fireEvent.submit(input.closest('form')!);
|
|
|
|
const webview = container.querySelector('webview.db-webview') as HTMLElement | null;
|
|
expect(webview).not.toBeNull();
|
|
// The bare domain is normalized to https and becomes the load target.
|
|
expect(webview!.getAttribute('src')).toBe('https://example.com');
|
|
expect(getAddressDisplay(container).url).toBe('https://example.com');
|
|
|
|
// The guest commits a redirect that appends a trailing slash.
|
|
dispatchWebviewNavigate(webview!, 'https://example.com/');
|
|
|
|
// The address bar follows the committed URL...
|
|
expect(getAddressDisplay(container).url).toBe('https://example.com/');
|
|
// ...but the src remains the original target, so no abort/reload loop.
|
|
expect(webview!.getAttribute('src')).toBe('https://example.com');
|
|
});
|
|
|
|
it('changes the src only when the user navigates to a new target', () => {
|
|
const { container } = render(
|
|
<DesignBrowserPanel projectId="proj-webview-2" onOpenFile={() => {}} onRefreshFiles={() => {}} />,
|
|
);
|
|
|
|
const input = screen.getByLabelText('Browser address') as HTMLInputElement;
|
|
fireEvent.change(input, { target: { value: 'https://gsap.com' } });
|
|
fireEvent.submit(input.closest('form')!);
|
|
|
|
const webview = container.querySelector('webview.db-webview') as HTMLElement;
|
|
expect(webview.getAttribute('src')).toBe('https://gsap.com');
|
|
|
|
// An in-page navigation event must not move the load target.
|
|
dispatchWebviewNavigate(webview, 'https://gsap.com/docs/');
|
|
expect(webview.getAttribute('src')).toBe('https://gsap.com');
|
|
expect(getAddressDisplay(container).url).toBe('https://gsap.com/docs/');
|
|
|
|
// A fresh user navigation does move it.
|
|
fireEvent.change(input, { target: { value: 'unsplash.com' } });
|
|
fireEvent.submit(input.closest('form')!);
|
|
expect(webview.getAttribute('src')).toBe('https://unsplash.com');
|
|
});
|
|
|
|
it('swallows benign Electron webview ERR_ABORTED loadURL rejections', async () => {
|
|
const { container } = render(
|
|
<DesignBrowserPanel
|
|
projectId="proj-webview-aborted-load"
|
|
initialUrl="https://example.com"
|
|
onOpenFile={() => {}}
|
|
onRefreshFiles={() => {}}
|
|
/>,
|
|
);
|
|
|
|
const webview = container.querySelector('webview.db-webview') as HTMLElement & {
|
|
loadURL?: (url: string) => Promise<void>;
|
|
};
|
|
const loadURL = vi.fn().mockRejectedValue(
|
|
new Error("ERR_ABORTED (-3) loading 'https://mobbin.com/'"),
|
|
);
|
|
webview.loadURL = loadURL;
|
|
|
|
const input = screen.getByLabelText('Browser address') as HTMLInputElement;
|
|
fireEvent.change(input, { target: { value: 'mobbin.com' } });
|
|
fireEvent.submit(input.closest('form')!);
|
|
|
|
await waitFor(() => expect(loadURL).toHaveBeenCalledWith('https://mobbin.com'));
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
expect(webview.getAttribute('src')).toBe('https://example.com');
|
|
expect(getAddressDisplay(container).url).toBe('https://mobbin.com');
|
|
});
|
|
|
|
it('derives back and forward availability from the committed navigation stack', () => {
|
|
const { container } = render(
|
|
<DesignBrowserPanel projectId="proj-webview-3" onOpenFile={() => {}} onRefreshFiles={() => {}} />,
|
|
);
|
|
|
|
const input = screen.getByLabelText('Browser address') as HTMLInputElement;
|
|
fireEvent.change(input, { target: { value: 'example.com' } });
|
|
fireEvent.submit(input.closest('form')!);
|
|
|
|
const webview = container.querySelector('webview.db-webview') as HTMLElement & {
|
|
loadURL?: (url: string) => void;
|
|
};
|
|
const loadURL = vi.fn();
|
|
webview.loadURL = loadURL;
|
|
|
|
const backButton = screen.getByRole('button', { name: 'Go Back' }) as HTMLButtonElement;
|
|
const forwardButton = screen.getByRole('button', { name: 'Go Forward' }) as HTMLButtonElement;
|
|
expect(backButton.disabled).toBe(false);
|
|
expect(backButton.parentElement?.getAttribute('data-tooltip')).toBe('Go Back');
|
|
|
|
dispatchWebviewNavigate(webview, 'https://example.com/');
|
|
expect(backButton.disabled).toBe(false);
|
|
|
|
dispatchWebviewNavigate(webview, 'https://example.com/docs/');
|
|
expect(getAddressDisplay(container).url).toBe('https://example.com/docs/');
|
|
expect(backButton.disabled).toBe(false);
|
|
expect(forwardButton.disabled).toBe(true);
|
|
|
|
fireEvent.click(backButton);
|
|
expect(loadURL).toHaveBeenCalledWith('https://example.com/');
|
|
expect(forwardButton.disabled).toBe(false);
|
|
});
|
|
|
|
it('treats the start page as the previous browser step after the first address navigation', () => {
|
|
const { container } = render(
|
|
<DesignBrowserPanel projectId="proj-webview-home-back" onOpenFile={() => {}} onRefreshFiles={() => {}} />,
|
|
);
|
|
|
|
expect(screen.getByText('Reference Board')).toBeTruthy();
|
|
|
|
const input = screen.getByLabelText('Browser address') as HTMLInputElement;
|
|
fireEvent.change(input, { target: { value: 'https://dribbble.com/' } });
|
|
fireEvent.submit(input.closest('form')!);
|
|
|
|
const webview = container.querySelector('webview.db-webview') as HTMLElement | null;
|
|
expect(webview?.getAttribute('src')).toBe('https://dribbble.com/');
|
|
|
|
const backButton = screen.getByRole('button', { name: 'Go Back' }) as HTMLButtonElement;
|
|
const forwardButton = screen.getByRole('button', { name: 'Go Forward' }) as HTMLButtonElement;
|
|
expect(backButton.disabled).toBe(false);
|
|
expect(forwardButton.disabled).toBe(true);
|
|
|
|
fireEvent.click(backButton);
|
|
|
|
expect(screen.getByText('Reference Board')).toBeTruthy();
|
|
expect(container.querySelector('webview.db-webview')).toBeNull();
|
|
expect((screen.getByLabelText('Browser address') as HTMLInputElement).value).toBe('');
|
|
expect(backButton.disabled).toBe(true);
|
|
expect(forwardButton.disabled).toBe(false);
|
|
|
|
fireEvent.click(forwardButton);
|
|
|
|
expect(container.querySelector('webview.db-webview')?.getAttribute('src')).toBe('https://dribbble.com/');
|
|
});
|
|
|
|
it('uses native webview history for back navigation when Chromium has it cached', () => {
|
|
const { container } = render(
|
|
<DesignBrowserPanel projectId="proj-webview-native" onOpenFile={() => {}} onRefreshFiles={() => {}} />,
|
|
);
|
|
|
|
const input = screen.getByLabelText('Browser address') as HTMLInputElement;
|
|
fireEvent.change(input, { target: { value: 'example.com' } });
|
|
fireEvent.submit(input.closest('form')!);
|
|
|
|
const webview = container.querySelector('webview.db-webview') as HTMLElement & {
|
|
canGoBack?: () => boolean;
|
|
goBack?: () => void;
|
|
loadURL?: (url: string) => void;
|
|
};
|
|
dispatchWebviewNavigate(webview, 'https://example.com/');
|
|
dispatchWebviewNavigate(webview, 'https://example.com/docs/');
|
|
|
|
const goBack = vi.fn();
|
|
const loadURL = vi.fn();
|
|
webview.canGoBack = () => true;
|
|
webview.goBack = goBack;
|
|
webview.loadURL = loadURL;
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Go Back' }));
|
|
|
|
expect(goBack).toHaveBeenCalledTimes(1);
|
|
expect(loadURL).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('shows extracted page titles in the passive address display and history suggestions', () => {
|
|
const { container } = render(
|
|
<DesignBrowserPanel projectId="proj-webview-title" onOpenFile={() => {}} onRefreshFiles={() => {}} />,
|
|
);
|
|
|
|
const input = screen.getByLabelText('Browser address') as HTMLInputElement;
|
|
fireEvent.change(input, { target: { value: 'https://www.baidu.com' } });
|
|
fireEvent.submit(input.closest('form')!);
|
|
|
|
const webview = container.querySelector('webview.db-webview') as HTMLElement & {
|
|
getTitle?: () => string;
|
|
getURL?: () => string;
|
|
};
|
|
webview.getURL = () => 'https://www.baidu.com/';
|
|
webview.getTitle = () => '百度一下,你就知道';
|
|
dispatchWebviewNavigate(webview, 'https://www.baidu.com/');
|
|
dispatchWebviewTitle(webview, '百度一下,你就知道');
|
|
fireEvent.blur(input);
|
|
|
|
expect(getAddressDisplay(container)).toMatchObject({
|
|
title: '百度一下,你就知道',
|
|
url: 'https://www.baidu.com',
|
|
});
|
|
|
|
fireEvent.focus(input);
|
|
expect(input.value).toBe('https://www.baidu.com/');
|
|
expect(screen.getByRole('option', { name: /百度一下,你就知道/ })).toBeTruthy();
|
|
});
|
|
|
|
it('opens all reference suggestions by default from the address bar', () => {
|
|
render(
|
|
<DesignBrowserPanel projectId="proj-webview-suggestions" onOpenFile={() => {}} onRefreshFiles={() => {}} />,
|
|
);
|
|
|
|
fireEvent.focus(screen.getByLabelText('Browser address'));
|
|
|
|
expect(screen.getByRole('option', { name: /Whirrls/ })).toBeTruthy();
|
|
expect(screen.getByRole('option', { name: /Startups Gallery/ })).toBeTruthy();
|
|
});
|
|
|
|
it('closes address suggestions when the address input blurs outside the address bar', () => {
|
|
render(
|
|
<DesignBrowserPanel projectId="proj-webview-suggestions-blur" onOpenFile={() => {}} onRefreshFiles={() => {}} />,
|
|
);
|
|
|
|
const input = screen.getByLabelText('Browser address');
|
|
fireEvent.focus(input);
|
|
|
|
expect(screen.getByRole('listbox')).toBeTruthy();
|
|
|
|
fireEvent.blur(input, { relatedTarget: null });
|
|
|
|
expect(screen.queryByRole('listbox')).toBeNull();
|
|
});
|
|
|
|
it('keeps the browser fallback content free of desktop-only overlay banners', () => {
|
|
restoreHost?.();
|
|
restoreHost = null;
|
|
|
|
const { container } = render(
|
|
<DesignBrowserPanel projectId="proj-browser-fallback" onOpenFile={() => {}} onRefreshFiles={() => {}} />,
|
|
);
|
|
|
|
const input = screen.getByLabelText('Browser address') as HTMLInputElement;
|
|
fireEvent.change(input, { target: { value: 'https://example.com' } });
|
|
fireEvent.submit(input.closest('form')!);
|
|
|
|
expect(container.querySelector('iframe')).not.toBeNull();
|
|
expect(screen.queryByText('Embedded browser controls are available in the desktop app.')).toBeNull();
|
|
});
|
|
|
|
it('does not render saved comment markers in the browser fallback iframe', () => {
|
|
restoreHost?.();
|
|
restoreHost = null;
|
|
|
|
const previewComments = [{
|
|
id: 'comment-fallback-1',
|
|
projectId: 'proj-browser-fallback-comments',
|
|
conversationId: 'conv-1',
|
|
filePath: 'browser:https://example.com',
|
|
elementId: 'dom:#card',
|
|
selector: '#card',
|
|
label: 'article.card',
|
|
note: 'Review this card',
|
|
text: 'Card',
|
|
position: { x: 24, y: 32, width: 240, height: 160 },
|
|
htmlHint: '<article id="card">',
|
|
status: 'open' as const,
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
}];
|
|
|
|
const { container } = render(
|
|
<DesignBrowserPanel
|
|
initialUrl="https://example.com"
|
|
projectId="proj-browser-fallback-comments"
|
|
previewComments={previewComments}
|
|
onOpenFile={() => {}}
|
|
onRefreshFiles={() => {}}
|
|
/>,
|
|
);
|
|
|
|
expect(container.querySelector('iframe')).not.toBeNull();
|
|
expect(container.querySelector('.db-comment-layer')).toBeNull();
|
|
expect(container.querySelector('.db-comment-marker')).toBeNull();
|
|
});
|
|
|
|
it('does not expose page annotation controls in the external browser', () => {
|
|
const { container } = render(
|
|
<I18nProvider initial="zh-CN">
|
|
<DesignBrowserPanel
|
|
initialUrl="https://example.com"
|
|
projectId="proj-webview-mark-i18n"
|
|
onOpenFile={() => {}}
|
|
onRefreshFiles={() => {}}
|
|
/>
|
|
</I18nProvider>,
|
|
);
|
|
|
|
expect(screen.queryByRole('button', { name: '标记' })).toBeNull();
|
|
expect(screen.queryByRole('button', { name: '评论' })).toBeNull();
|
|
expect(container.querySelector('.ri-pencil-line')).toBeNull();
|
|
expect(screen.queryByRole('button', { name: 'Annotate page' })).toBeNull();
|
|
});
|
|
|
|
it('does not start browser element comments from the external browser toolbar', () => {
|
|
const onSendBoardCommentAttachments = vi.fn(async (_attachments: unknown[], _images?: File[]) => undefined);
|
|
const { container } = render(
|
|
<DesignBrowserPanel
|
|
initialUrl="https://example.com"
|
|
projectId="proj-webview-comment-queue"
|
|
onOpenFile={() => {}}
|
|
onRefreshFiles={() => {}}
|
|
onSendBoardCommentAttachments={onSendBoardCommentAttachments}
|
|
sendDisabled
|
|
/>,
|
|
);
|
|
|
|
const webview = container.querySelector('webview.db-webview') as HTMLElement & {
|
|
executeJavaScript?: ReturnType<typeof vi.fn>;
|
|
};
|
|
webview.executeJavaScript = vi.fn();
|
|
|
|
expect(screen.queryByRole('button', { name: 'Comment' })).toBeNull();
|
|
fireEvent.click(screen.getByRole('button', { name: 'Browser menu' }));
|
|
expect(screen.queryByRole('menuitem', { name: 'Comment' })).toBeNull();
|
|
expect(screen.queryByTestId('comment-popover-input')).toBeNull();
|
|
expect(onSendBoardCommentAttachments).not.toHaveBeenCalled();
|
|
expect(webview.executeJavaScript).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('does not show the bottom Download Page hint when the workspace owns snapshot toasts', async () => {
|
|
const attentionRequest = { action: 'download-page' as const, nonce: 1 };
|
|
render(
|
|
<DesignBrowserPanel
|
|
projectId="proj-webview-download-attention-parent-toast"
|
|
initialTitle="Example"
|
|
initialUrl="https://example.com"
|
|
attentionRequest={attentionRequest}
|
|
onOpenFile={() => {}}
|
|
onRefreshFiles={() => {}}
|
|
onPageSnapshotToast={() => {}}
|
|
/>,
|
|
);
|
|
|
|
const downloadItem = await screen.findByRole('menuitem', { name: 'Download Page' });
|
|
expect(downloadItem.classList.contains('is-attention')).toBe(true);
|
|
expect(screen.queryByText(/click Download Page here/)).toBeNull();
|
|
});
|
|
|
|
it('does not render saved browser comment markers in the external browser', async () => {
|
|
const previewComments = [{
|
|
id: 'comment-1',
|
|
projectId: 'proj-webview-live-comment-marker',
|
|
conversationId: 'conv-1',
|
|
filePath: 'browser:https://example.com',
|
|
elementId: 'dom:#card',
|
|
selector: '#card',
|
|
label: 'article.card',
|
|
note: 'Review this card',
|
|
text: 'Card',
|
|
position: { x: 24, y: 32, width: 240, height: 160 },
|
|
htmlHint: '<article id="card">',
|
|
status: 'open' as const,
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
}];
|
|
const { container } = render(
|
|
<DesignBrowserPanel
|
|
initialUrl="https://example.com"
|
|
projectId="proj-webview-live-comment-marker"
|
|
previewComments={previewComments}
|
|
onOpenFile={() => {}}
|
|
onRefreshFiles={() => {}}
|
|
/>,
|
|
);
|
|
|
|
const webview = container.querySelector('webview.db-webview') as HTMLElement & {
|
|
executeJavaScript?: ReturnType<typeof vi.fn>;
|
|
};
|
|
webview.executeJavaScript = vi.fn(async () => []);
|
|
|
|
await act(async () => {
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(webview.executeJavaScript).not.toHaveBeenCalled();
|
|
expect(container.querySelector('.db-comment-layer')).toBeNull();
|
|
expect(container.querySelector('.db-comment-marker')).toBeNull();
|
|
});
|
|
|
|
it('keeps browser screenshot capture available after hiding annotation tools', async () => {
|
|
restoreHost?.();
|
|
const capturePage = vi.fn(async () => {
|
|
expect(document.querySelector('.preview-draw-toolbar')).toBeNull();
|
|
return { ok: true as const, dataUrl: 'data:image/png;base64,cG5n', w: 10, h: 10 };
|
|
});
|
|
restoreHost = installMockOpenDesignHost({
|
|
host: { capture: { page: capturePage } },
|
|
});
|
|
|
|
const { container } = render(
|
|
<DesignBrowserPanel
|
|
initialUrl="https://example.com"
|
|
projectId="proj-webview-screenshot-hides-tools"
|
|
onOpenFile={() => {}}
|
|
onRefreshFiles={() => {}}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.queryByRole('button', { name: 'Mark' })).toBeNull();
|
|
expect(screen.queryByRole('button', { name: 'Comment' })).toBeNull();
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Screenshot' }));
|
|
|
|
await waitFor(() => expect(capturePage).toHaveBeenCalledTimes(1));
|
|
});
|
|
|
|
it('turns the reload button into a stop control while the page is loading', () => {
|
|
const { container } = render(
|
|
<DesignBrowserPanel
|
|
projectId="proj-webview-stop-loading"
|
|
initialTitle="Example"
|
|
initialUrl="https://example.com"
|
|
onOpenFile={() => {}}
|
|
onRefreshFiles={() => {}}
|
|
/>,
|
|
);
|
|
const webview = container.querySelector('webview.db-webview') as HTMLElement & {
|
|
stop?: ReturnType<typeof vi.fn>;
|
|
};
|
|
const stop = vi.fn();
|
|
webview.stop = stop;
|
|
|
|
// Idle: the control reloads.
|
|
expect(screen.getByRole('button', { name: 'Reload' })).toBeTruthy();
|
|
expect(screen.queryByRole('button', { name: 'Stop loading' })).toBeNull();
|
|
|
|
// While loading, the same control becomes a Chrome-style stop affordance.
|
|
act(() => {
|
|
webview.dispatchEvent(new Event('did-start-loading'));
|
|
});
|
|
const stopButton = screen.getByRole('button', { name: 'Stop loading' });
|
|
expect(screen.queryByRole('button', { name: 'Reload' })).toBeNull();
|
|
|
|
fireEvent.click(stopButton);
|
|
|
|
// Clicking halts the in-flight navigation and returns to the reload control.
|
|
expect(stop).toHaveBeenCalledTimes(1);
|
|
expect(screen.getByRole('button', { name: 'Reload' })).toBeTruthy();
|
|
});
|
|
|
|
it('stops a stuck load before capturing the page snapshot', async () => {
|
|
vi.mocked(writeProjectTextFile).mockImplementation(async (_projectId, name) => ({
|
|
kind: 'code',
|
|
mime: 'application/json',
|
|
mtime: 1,
|
|
name,
|
|
size: 1,
|
|
type: 'file',
|
|
}));
|
|
const { container } = render(
|
|
<DesignBrowserPanel
|
|
projectId="proj-webview-download-stops-load"
|
|
initialTitle="Example"
|
|
initialUrl="https://example.com"
|
|
onOpenFile={() => {}}
|
|
onRefreshFiles={() => {}}
|
|
/>,
|
|
);
|
|
const webview = container.querySelector('webview.db-webview') as HTMLElement & {
|
|
executeJavaScript?: ReturnType<typeof vi.fn>;
|
|
stop?: ReturnType<typeof vi.fn>;
|
|
};
|
|
const stop = vi.fn();
|
|
webview.stop = stop;
|
|
webview.executeJavaScript = vi.fn().mockResolvedValue({
|
|
title: 'Example',
|
|
url: 'https://example.com',
|
|
html: '<!doctype html><html><body>Example</body></html>',
|
|
css: '',
|
|
resources: [],
|
|
});
|
|
|
|
// The page is wedged mid-load (perpetual top-left spinner), which is exactly
|
|
// when Download Page would otherwise hang waiting on a load that never ends.
|
|
act(() => {
|
|
webview.dispatchEvent(new Event('did-start-loading'));
|
|
});
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Browser menu' }));
|
|
fireEvent.click(screen.getByRole('menuitem', { name: 'Download Page' }));
|
|
|
|
// The download halts the in-flight load first, then captures the current DOM.
|
|
expect(stop).toHaveBeenCalledTimes(1);
|
|
expect(webview.executeJavaScript).toHaveBeenCalledTimes(1);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/Saved page snapshot/)).toBeTruthy();
|
|
});
|
|
});
|
|
});
|