mirror of
https://github.com/nexu-io/open-design.git
synced 2026-07-07 14:38:42 +08:00
* feat(brand-extraction): enhance programmatic extraction with reconciliation and UI improvements - Introduced `reconcileProgrammaticExtractionTranscript` to manage the state of brand extraction transcripts, ensuring accurate status updates during extraction processes. - Updated the `cancel-extraction` route to handle asynchronous operations, allowing for better user feedback and immediate reconciliation of extraction states. - Enhanced the `startBrandExtraction` function to support draft design system creation, ensuring that users can see their systems immediately, even if extraction fails or is stopped. - Improved error handling and logging for reconciliation processes, providing clearer insights into failures during brand extraction. - Added tests to validate the new reconciliation logic and ensure that the extraction process behaves as expected under various scenarios. These changes improve the overall user experience by providing more robust handling of brand extraction states and enhancing the visibility of the extraction process. * feat(brand-extraction): enhance brand preview rendering and UI feedback - Added functionality to render brand previews into projects when a draft is stopped, improving user experience by providing immediate visual feedback. - Updated the `startBrandExtraction` function to include metadata for design system IDs and statuses, ensuring accurate tracking of brand extraction states. - Enhanced the `renderBrandPreviewIntoProject` function to support new status types, including 'draft', allowing for better representation of brand states. - Introduced new localization strings for draft-related messages, improving clarity for users during the brand extraction process. - Added tests to validate the new preview rendering and status handling, ensuring robustness in the brand extraction workflow. These changes improve the overall user experience by providing clearer feedback and enhancing the visibility of brand extraction processes. * feat(design-kit): browse design-system images in a lightbox carousel The image-preview lightbox showed a single static image with no way to move between samples. Replace the single-image state with an index into the visible, non-broken samples so the modal becomes a carousel: prev/next chevrons, an "N / total" counter, ArrowLeft/ArrowRight keys, and wrap-around. Broken tiles are filtered once through a shared lightboxItems memo so the gallery and lightbox indices stay aligned with the delete handler. * feat(brands): reveal the draft design system the moment extraction starts A brand extraction pre-creates a draft `user:` design system, but the project view only rendered it once the registry summary had refreshed, leaving the Design System tab empty for the whole extraction. Build a fallback summary from project metadata so the draft renders immediately, auto-open the Design System tab when extraction begins or is cancelled, and hydrate the freshly created project with its brand metadata. Draft pre-create now records the source URL in provenance and fails loudly instead of swallowing the error. Because ProjectView imports DESIGN_SYSTEM_TAB at module scope, every FileWorkspace test mock must export the constant. * fix(design-system-picker): restore fullscreen preview styles dropped in routines.css reorg The brand-extraction routines.css reorganization accidentally removed the `.project-ds-picker-fullscreen` rule block, its `project-ds-picker-fullscreen-in` keyframes, and its responsive overrides. The modal-window drag guard and two style specs still depend on those rules (the fullscreen design-system preview must stay above modal chrome), so they went red. Restore the block verbatim to match main. * feat(brand-extraction): add continue extraction functionality for incomplete brands - Introduced a new API endpoint to restart the deterministic programmatic extraction for existing brands without creating duplicates. - Implemented a tracking mechanism for ongoing extractions to manage their state effectively. - Enhanced the UI components to support the new continue extraction action, including loading states and callbacks. - Updated relevant tests to ensure the new functionality works as expected and integrates smoothly with existing workflows. These changes improve the user experience by allowing users to resume brand extractions seamlessly, enhancing the overall efficiency of the brand management process. * feat(skills): integrate skill management into the application - Added a new SkillDetailsModal component to display detailed information about skills. - Enhanced the ChatComposer and HomeHero components to support skill selection and details viewing. - Updated the ComposerPlusMenu to include a skills submenu, allowing users to search and pick skills. - Implemented functionality to handle skill details in various components, improving user interaction with skills. - Refactored existing components to accommodate skill-related features, ensuring a cohesive user experience. These changes enhance the application's capabilities by providing users with better access to skill information and management, streamlining workflows involving skills. * feat(brand-extraction): improve brand assistant message handling and UI feedback - Enhanced the AssistantMessage component to better manage events and display content, ensuring that messages are rendered correctly based on their content and events. - Updated the ChatPane component to hide empty brand assistant messages and improve the visibility of relevant content, enhancing user experience during brand extraction processes. - Introduced new utility functions to determine the visibility of brand assistant events and content, streamlining the logic for displaying messages. - Refactored the ProjectView component to handle brand browser assist confirmations more effectively, ensuring that the UI responds appropriately to user interactions. - Added tests to validate the new message handling logic and ensure that brand extraction messages are displayed correctly. These changes enhance the overall user experience by providing clearer feedback and improving the management of brand assistant messages during extraction workflows. * fix(brands): address browser assist review blockers * fix(web): restore new project modal entrypoints * fix(web): switch to replacement brand retry conversations * fix(web): restore skill-change preview invalidation * fix(brand): harden retry completion flow * fix(brand): close extraction lifecycle races * test(e2e): wait for privacy banner dismissal * fix(brand): guard retry startup races * fix(brand): isolate extraction retry attempts * fix(design-systems): clean up partial draft creation * fix(brands): let stop supersede stalled transcript * fix(brands): fail thin browser html retries * fix(brands): preserve stopped html retries * fix(brands): reject off-path browser retries * fix(brands): expose deterministic retry in cli * fix(brands): serialize cancel with finalization * fix(brands): bound cancel settlement wait * fix(brands): bound retry takeover waits * refactor(next-step-actions): replace titles with localized keys and update related prompts * fix(brands): retry after browser read failures * fix(brands): accept canonical browser redirects * fix(brands): reset retry assist stall timer * test(web): align picker compact preview assertions * fix(brands): harden design kit cleanup paths * test(e2e): stabilize AMR runtime recovery checks * feat(brands): enhance brand extraction from post-wall pages - Introduced `browserHarvestIsUnusable` function to determine if a harvested page lacks usable brand signals, allowing for more permissive extraction from content-rich pages that may embed anti-bot widgets. - Updated `extractBrandFromHtml` to utilize the new function, ensuring that valid post-wall pages are processed correctly. - Enhanced `isChallengePage` to differentiate between genuine content-rich pages and bot protection interstitials, improving the accuracy of brand extraction. - Added tests to validate the new behavior, ensuring that pages with minimal color palettes or embedded widgets are handled appropriately. * feat(brands): improve handling of recoverable extraction states - Refactored brand extraction logic to maintain a `needs_input` status for recoverable scenarios, such as blocked pages or mid-load content, instead of marking them as `failed`. - Updated related functions and routes to reflect this change, ensuring that the user interface presents a calm, retryable state rather than a terminal failure. - Enhanced tests to validate the new behavior, confirming that the system correctly identifies and manages recoverable extraction attempts. * feat(projects): add design-system project creation from existing projects - Implemented a new command `od project create-design-system` to duplicate a regular project as a design-system workspace, including a prompt for design-system generation. - Enhanced the API to support the creation of design-system projects, ensuring proper metadata and file copying from the source project. - Added a detailed prompt structure for the design-system generation process, outlining requirements and expected outputs. - Introduced tests to validate the new functionality, ensuring that design-system projects are created correctly with the appropriate context and files. - Updated the UI components to integrate the new design-system creation functionality, allowing users to initiate the process seamlessly. * feat(projects): add project duplication workflow * fix(brand): keep browser assist recovery retryable * fix(plugins): roll back failed template duplicates * feat(brand): enhance browser assist functionality and improve user guidance - Introduced a new `brandBrowserAssistOdCard` function to generate browser assist cards for better user interaction. - Updated the content structure in `reconcileProgrammaticExtractionTranscript` to include browser assist cards when applicable. - Enhanced user guidance in error messages for stalled and stopped extraction processes, providing clearer instructions for recovery actions. - Added tests to ensure the new browser assist card functionality is correctly integrated and displayed in the chat interface. - Improved localization for various user prompts and messages related to browser assist actions. * feat(design-browser): enhance design file handling and user notifications - Added support for opening design files directly from the status message in the DesignBrowserPanel. - Introduced a new `actionTarget` property to differentiate between design files and regular files in status messages. - Implemented a `stopLoading` function to halt page loading when necessary, improving user experience during snapshot captures. - Updated localization files to include new status messages and project type labels. - Enhanced toast notifications for browser snapshots with improved styling and functionality. - Added tests to ensure new features work as expected and do not interfere with existing functionality. * feat(app): implement design system project creation with preset prompts - Updated project creation logic to route through the default design router, ensuring user interaction prompts for design system selection. - Introduced a new localization key for the design system creation prompt, enhancing user guidance. - Adjusted plugin ID to avoid assumptions about prototypes, improving the flexibility of project creation. - Updated tests to reflect changes in project creation behavior and ensure correct routing and prompt handling. * feat(chat): update icon sizes and implement draft persistence in HomeView - Increased icon sizes in ChatComposer from 13px to 16px for better visibility. - Added localStorage persistence for the Home composer prompt and design system selection to retain user input across tab switches. - Implemented functions to read and write drafts to localStorage, ensuring a seamless user experience. - Cleared drafts upon run creation to prevent reappearance of previous inputs. - Enhanced Toast component to handle auto-dismissal correctly, even with changing parent components. * fix(pr): address review blockers * fix(web): preserve authoring prompt confirmation * test(daemon): cover project CLI copy commands * fix(web): surface skill detail load failures
936 lines
36 KiB
TypeScript
936 lines
36 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('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();
|
|
});
|
|
});
|
|
});
|