mirror of
https://github.com/nexu-io/open-design.git
synced 2026-07-06 22:31:53 +08:00
* feat(daemon): add 'plan' session mode and update related functionality - Introduced a new session mode 'plan' alongside existing 'design' and 'chat' modes, allowing for editable document creation. - Updated various functions and interfaces to accommodate the new session mode, including normalization and usage in commands. - Enhanced markdown rendering capabilities by integrating micromark and its GFM extension for improved markdown processing. - Added new input types for question forms, expanding the range of user interactions. - Updated UI components to reflect changes in session modes and ensure proper handling of next steps based on the current mode. * feat(chat): integrate SessionModeToggle into ChatComposer and HomeHero - Added SessionModeToggle component to both ChatComposer and HomeHero for improved session management. - Updated HomeHero styles to accommodate the new mode switcher layout. - Adjusted countdown timer in QuestionsPanel to extend the skip countdown from 120 seconds to 10 minutes, enhancing user experience. - Added tests to ensure the countdown functionality works as expected. * feat(FileViewer): implement synchronized scrolling for Markdown editor and preview - Added functionality to synchronize scrolling between the Markdown editor and preview panes. - Introduced new types and utility functions for managing scroll behavior. - Enhanced the MarkdownViewer component to handle scroll events and maintain scroll position across different modes. - Updated the component's state management to accommodate the new scrolling features. * feat(excalidraw): integrate Excalidraw into the project - Added @excalidraw/excalidraw as a dependency in package.json. - Updated vitest configuration to include an alias for Excalidraw. - Imported Excalidraw CSS in the layout component for styling. - Modified AssistantMessage component to handle optional projectId. - Enhanced FileOpsSummary to display delete operations. - Implemented new Excalidraw scene management in SketchEditor and FileWorkspace components. - Updated MarkdownViewer to support file mentions and improved file handling. - Refactored various components to accommodate Excalidraw integration and ensure compatibility with existing features. * feat(files): improve sketch and file handoff flows * test: align post-merge expectations * chore(nix): refresh pnpm deps hash * fix(workspace): stabilize sketch persistence and ci checks * fix(workspace): address review blockers in editable files * fix(workspace): persist cleared sketch scenes * test(workspace): type sketch editor mock scene * fix(workspace): serialize sketch autosaves * fix(workspace): keep sketch save revisions current * test(e2e): stabilize project workspace smoke flows * fix(analytics): preserve plan mode for BYOK runs * test(e2e): stabilize new project rail interactions * fix(files): stop bash delete parsing at shell operators * test(e2e): stabilize ui cold-start suites * fix(viewer): preserve absolute markdown image sources * feat(workspace): preload sketches and enhance markdown save options - Added functionality to preload persisted sketches before opening the tab. - Introduced new MarkdownSaveOptions type to manage save behavior. - Updated saveMarkdownText to handle options for refreshing files and showing saving state. - Enhanced FileViewer to maintain focus and selection during metadata refresh. - Implemented a Toast component for user feedback on save and export actions. * fix(web): stabilize markdown and sketch editor polish * fix(web): finish sketch editor merge resolution * chore(nix): refresh pnpm deps hash * fix(plan): bypass discovery and stabilize markdown sync * fix(web): simplify scene retrieval in SketchEditor component * feat(web): enhance markdown viewer with auto-save functionality - Implemented passive auto-save status in the MarkdownViewer component, replacing the manual Save button with an auto-save indicator. - Introduced new hooks and state management for tracking auto-save events and displaying the last saved time. - Added support for synchronized scrolling between the markdown editor and preview. - Created a new markdown-scroll-sync module to handle scroll synchronization logic. - Updated localization files to include new strings for auto-save messages. - Added a SketchEnginePrewarm component to optimize Excalidraw loading times. * feat(web): enhance session mode toggle with cost indicators - Added cost tiers for each session mode in the SessionModeToggle component, providing users with a visual representation of usage costs. - Introduced a new ModeCostTag component to display cost information alongside session mode labels. - Updated localization files to include new keys for cost labels and notes. - Enhanced styling for cost indicators to improve user experience and clarity. - Refactored EntryShell to open the new project modal instead of creating a blank project directly from the rail. - Implemented a utility function in markdown-scroll-sync to check for vertical progression in block offsets. * feat(web): add max height adjustment for session mode description card - Introduced maxHeight prop to the ModeDescriptionCard component to control the height of the description card based on available space. - Implemented useLayoutEffect in SessionModeToggle to dynamically calculate and set the maximum height of the description card, ensuring it does not overlap with the project tab bar. - Updated tests to verify that session modes display their expected usage/cost correctly in the UI. - Enhanced localization files to include new cost-related strings for various languages. * feat(web): implement goBack function for improved navigation and update auto-open logic - Added a new `goBack` function to handle in-app navigation, allowing users to return to the previous route instead of a hardcoded destination. - Updated the `navigate` function to maintain history state for better back navigation. - Refactored auto-open logic to prioritize produced artifacts, allowing markdown files to be opened alongside HTML files. - Updated tests to cover new navigation behavior and artifact selection logic. - Enhanced localization files to include new descriptions for workspace actions. * refactor(web): remove create design system functionality and update design files panel actions - Removed the `onCreateDesignSystem` prop and associated button from the DesignFilesPanel component. - Updated the empty state actions to include a button for creating a new document via the `onPaste` function. - Adjusted tests to reflect the removal of the design system creation action and ensure the new document button is functional. - Enhanced the MarkdownViewer component by adding a placeholder for the text area and removing the header bar for a cleaner interface. - Updated localization files to include a new placeholder string for the markdown editor. * fix(web): restore markdown placeholder translations * refactor(web): streamline MarkdownViewer and enhance localization - Removed unnecessary state management and reload functionality from the MarkdownViewer component for improved performance. - Added a placeholder text for the markdown editor in multiple localization files to enhance user guidance. - Updated styles for the save state indicator in the viewer to improve visual clarity and alignment. - Adjusted tests to reflect changes in the MarkdownViewer and ensure proper functionality. * fix(web): adjust SketchEditor button size and remove shortcut hints - Reduced the icon size in the SketchEditor component from 13 to 12 for better alignment. - Updated the removeSketchMermaidShortcutHints function to also remove the submit shortcut hints from the dialog, enhancing the user interface by decluttering unnecessary elements. - Adjusted tests to verify the absence of shortcut hints in the modal after updates. * fix(web): address plan mode follow-up polish * fix(web): align ci expectations after merge * test(e2e): stabilize project workspace helpers * test(e2e): scale settings visual timeout * fix(prompts): lock ElevenLabs voice picker choices * test(e2e): pin project workspace P0 worker * test(e2e): use rail new project entry * test(e2e): relax app restoration startup waits * fix(web): limit markdown pipe escaping to tables --------- Co-authored-by: open-design-bot[bot] <282769551+open-design-bot[bot]@users.noreply.github.com> Co-authored-by: Amy <1184569493@qq.com>
2656 lines
80 KiB
TypeScript
2656 lines
80 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
import { act } from 'react';
|
|
import { createRoot, type Root } from 'react-dom/client';
|
|
import { renderToStaticMarkup } from 'react-dom/server';
|
|
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import {
|
|
DESIGN_FILES_TAB,
|
|
FileWorkspace,
|
|
scrollWorkspaceTabsWithWheel,
|
|
} from '../../src/components/FileWorkspace';
|
|
import { DesignFilesPanel } from '../../src/components/DesignFilesPanel';
|
|
import { projectSplitClassName, projectSplitStyle } from '../../src/components/ProjectView';
|
|
import {
|
|
fetchProjectFileText,
|
|
uploadProjectFiles,
|
|
writeProjectTextFile,
|
|
fetchProjectFolders,
|
|
} from '../../src/providers/registry';
|
|
import type { ChatMessage, ProjectFile, ProjectFolder } from '../../src/types';
|
|
|
|
vi.mock('../../src/providers/registry', async () => {
|
|
const actual = await vi.importActual<typeof import('../../src/providers/registry')>(
|
|
'../../src/providers/registry',
|
|
);
|
|
return {
|
|
...actual,
|
|
fetchProjectFileText: vi.fn(),
|
|
uploadProjectFiles: vi.fn(),
|
|
writeProjectBase64File: vi.fn(),
|
|
writeProjectTextFile: vi.fn(),
|
|
fetchProjectFolders: vi.fn().mockResolvedValue([]),
|
|
};
|
|
});
|
|
|
|
vi.mock('../../src/components/DesignBrowserPanel', () => ({
|
|
DesignBrowserPanel: ({
|
|
initialIconUrl,
|
|
initialTitle,
|
|
initialUrl,
|
|
navigateRequest,
|
|
onPageSnapshotToast,
|
|
}: {
|
|
initialIconUrl?: string;
|
|
initialTitle?: string;
|
|
initialUrl?: string;
|
|
navigateRequest?: { url: string; nonce: number };
|
|
onPageSnapshotToast?: (event: {
|
|
actionFileName?: string;
|
|
actionLabel?: string;
|
|
actionTarget?: 'design-files' | 'file';
|
|
elapsedSeconds?: number;
|
|
message: string;
|
|
status: 'loading' | 'success' | 'error' | 'canceled';
|
|
tabId: string;
|
|
ttlMs?: number;
|
|
}) => void;
|
|
}) => (
|
|
<div
|
|
data-testid="design-browser-panel"
|
|
data-initial-icon-url={initialIconUrl ?? ''}
|
|
data-initial-title={initialTitle ?? ''}
|
|
data-initial-url={initialUrl ?? ''}
|
|
data-navigate-url={navigateRequest?.url ?? ''}
|
|
data-navigate-nonce={navigateRequest?.nonce ?? ''}
|
|
>
|
|
<button
|
|
type="button"
|
|
data-testid="emit-browser-snapshot-success"
|
|
onClick={() => onPageSnapshotToast?.({
|
|
actionFileName: 'browser-archive/example/manifest.json',
|
|
actionLabel: 'View Design Files',
|
|
actionTarget: 'design-files',
|
|
elapsedSeconds: 0,
|
|
message: 'Saved page snapshot (HTML + CSS).',
|
|
status: 'success',
|
|
tabId: '__browser__:1',
|
|
ttlMs: 8000,
|
|
})}
|
|
>
|
|
emit snapshot success
|
|
</button>
|
|
</div>
|
|
),
|
|
labelFromUrl: (url: string) => {
|
|
try {
|
|
return new URL(url).hostname.replace(/^www\./, '') || url;
|
|
} catch {
|
|
return url;
|
|
}
|
|
},
|
|
normalizeBrowserAddress: (rawAddress: string) => {
|
|
const value = rawAddress.trim();
|
|
if (/^https?:\/\//i.test(value)) return value;
|
|
if (/^[\w.-]+\.[a-z]{2,}/i.test(value)) return `https://${value}`;
|
|
return value || 'about:blank';
|
|
},
|
|
}));
|
|
|
|
vi.mock('../../src/components/workspace/TerminalViewer', () => ({
|
|
TerminalViewer: ({ terminalId }: { terminalId: string }) => (
|
|
<div data-testid="terminal-viewer">{terminalId}</div>
|
|
),
|
|
}));
|
|
|
|
const { excalidrawWorkspaceMock } = vi.hoisted(() => ({
|
|
excalidrawWorkspaceMock: {
|
|
lastProps: null as Record<string, any> | null,
|
|
},
|
|
}));
|
|
|
|
vi.mock('@excalidraw/excalidraw', async () => {
|
|
const React = await import('react');
|
|
const MainMenu = Object.assign(
|
|
(props: Record<string, any>) => React.createElement('div', null, props.children),
|
|
{
|
|
Item: ({ children, disabled, icon, onClick, ...rest }: Record<string, any>) => React.createElement(
|
|
'button',
|
|
{
|
|
...rest,
|
|
type: 'button',
|
|
disabled,
|
|
onClick,
|
|
},
|
|
icon,
|
|
children,
|
|
),
|
|
DefaultItems: {
|
|
SearchMenu: () => null,
|
|
Help: () => null,
|
|
ClearCanvas: () => null,
|
|
ChangeCanvasBackground: () => null,
|
|
},
|
|
Separator: () => null,
|
|
},
|
|
);
|
|
return {
|
|
Excalidraw: (props: Record<string, any>) => {
|
|
excalidrawWorkspaceMock.lastProps = props;
|
|
React.useEffect(() => {
|
|
props.excalidrawAPI?.({
|
|
getSceneElementsIncludingDeleted: () => [{ id: 'workspace-element', type: 'freedraw', isDeleted: false }],
|
|
getAppState: () => ({ viewBackgroundColor: '#ffffff' }),
|
|
getFiles: () => ({}),
|
|
updateScene: vi.fn(),
|
|
setOpenDialog: vi.fn(),
|
|
});
|
|
}, [props]);
|
|
return React.createElement(
|
|
'div',
|
|
{ 'data-testid': 'excalidraw' },
|
|
React.createElement('canvas'),
|
|
props.renderTopRightUI?.(false, {}),
|
|
props.children,
|
|
);
|
|
},
|
|
MainMenu,
|
|
convertToExcalidrawElements: vi.fn((elements: unknown[]) => elements),
|
|
exportToBlob: vi.fn(async () => new Blob(['mock image'], { type: 'image/png' })),
|
|
};
|
|
});
|
|
|
|
// Records the `folders` prop DesignFilesPanel receives on EVERY render (still
|
|
// renders the real component). Lets a test observe the first render after a
|
|
// project switch — the pre-paint frame RTL's post-rerender DOM assertion can't
|
|
// see — to prove no stale folders ever reach the new panel.
|
|
const { designFilesPanelRenders } = vi.hoisted(() => ({
|
|
designFilesPanelRenders: [] as { projectId: string; folderCount: number }[],
|
|
}));
|
|
vi.mock('../../src/components/DesignFilesPanel', async () => {
|
|
const actual = await vi.importActual<typeof import('../../src/components/DesignFilesPanel')>(
|
|
'../../src/components/DesignFilesPanel',
|
|
);
|
|
const Real = actual.DesignFilesPanel;
|
|
return {
|
|
...actual,
|
|
DesignFilesPanel: (props: Parameters<typeof Real>[0]) => {
|
|
designFilesPanelRenders.push({
|
|
projectId: props.projectId,
|
|
folderCount: props.folders?.length ?? 0,
|
|
});
|
|
return <Real {...props} />;
|
|
},
|
|
};
|
|
});
|
|
|
|
const mockedFetchProjectFileText = vi.mocked(fetchProjectFileText);
|
|
const mockedUploadProjectFiles = vi.mocked(uploadProjectFiles);
|
|
const mockedWriteProjectTextFile = vi.mocked(writeProjectTextFile);
|
|
|
|
let root: Root | null = null;
|
|
let host: HTMLDivElement | null = null;
|
|
|
|
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
|
|
// Needed else the ResizeObserver in SketchEditor crashes the test
|
|
beforeAll(() => {
|
|
globalThis.ResizeObserver = class {
|
|
observe() {}
|
|
disconnect() {}
|
|
unobserve() {}
|
|
};
|
|
});
|
|
|
|
beforeEach(() => {
|
|
mockedFetchProjectFileText.mockResolvedValue('');
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
excalidrawWorkspaceMock.lastProps = null;
|
|
if (root) {
|
|
act(() => root?.unmount());
|
|
root = null;
|
|
}
|
|
host?.remove();
|
|
host = null;
|
|
vi.clearAllMocks();
|
|
vi.restoreAllMocks();
|
|
vi.useRealTimers();
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
function baseFile(overrides: Partial<ProjectFile> = {}): ProjectFile {
|
|
return {
|
|
name: 'mock.png',
|
|
path: 'mock.png',
|
|
type: 'file',
|
|
size: 1024,
|
|
mtime: 1710000000,
|
|
kind: 'image',
|
|
mime: 'image/png',
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function workspaceFile(name: string): ProjectFile {
|
|
return {
|
|
name,
|
|
path: name,
|
|
type: 'file',
|
|
size: 100,
|
|
mtime: 1700000000,
|
|
kind: name.endsWith('.html') ? 'html' : 'text',
|
|
mime: name.endsWith('.html') ? 'text/html' : 'text/plain',
|
|
};
|
|
}
|
|
|
|
function renderWorkspace(element: React.ReactElement) {
|
|
host = document.createElement('div');
|
|
document.body.appendChild(host);
|
|
root = createRoot(host);
|
|
act(() => {
|
|
root?.render(element);
|
|
});
|
|
return host;
|
|
}
|
|
|
|
function getTabByName(container: HTMLElement, name: RegExp): HTMLElement {
|
|
const tabs = Array.from(container.querySelectorAll<HTMLElement>('[role="tab"]'));
|
|
const tab = tabs.find((node) => name.test(node.textContent ?? ''));
|
|
if (!tab) throw new Error(`Could not find tab matching ${name}`);
|
|
return tab;
|
|
}
|
|
|
|
function renderedTabLabels(): string[] {
|
|
return screen.getAllByRole('tab').map((tab) => tab.textContent?.trim() ?? '');
|
|
}
|
|
|
|
function createDragDataTransfer() {
|
|
const store = new Map<string, string>();
|
|
return {
|
|
effectAllowed: 'move',
|
|
dropEffect: 'move',
|
|
getData: vi.fn((type: string) => store.get(type) ?? ''),
|
|
setData: vi.fn((type: string, value: string) => {
|
|
store.set(type, value);
|
|
}),
|
|
};
|
|
}
|
|
|
|
function dispatchDragEvent(
|
|
target: HTMLElement,
|
|
type: string,
|
|
dataTransfer = createDragDataTransfer(),
|
|
clientX = 0,
|
|
relatedTarget: EventTarget | null = null,
|
|
) {
|
|
const event = new Event(type, { bubbles: true, cancelable: true });
|
|
Object.defineProperties(event, {
|
|
clientX: { value: clientX },
|
|
dataTransfer: { value: dataTransfer },
|
|
relatedTarget: { value: relatedTarget },
|
|
});
|
|
target.dispatchEvent(event);
|
|
return dataTransfer;
|
|
}
|
|
|
|
function stubTabRect(tab: HTMLElement, left = 0, width = 100) {
|
|
tab.getBoundingClientRect = vi.fn(() => ({
|
|
x: left,
|
|
y: 0,
|
|
left,
|
|
top: 0,
|
|
right: left + width,
|
|
bottom: 20,
|
|
width,
|
|
height: 20,
|
|
toJSON: () => ({}),
|
|
}));
|
|
}
|
|
|
|
function changeInputValue(input: HTMLInputElement, value: string) {
|
|
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set;
|
|
setter?.call(input, value);
|
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
}
|
|
|
|
function renderDesignFilesPanel(overrides: Partial<React.ComponentProps<typeof DesignFilesPanel>> = {}) {
|
|
const props: React.ComponentProps<typeof DesignFilesPanel> = {
|
|
projectId: 'project-1',
|
|
files: [],
|
|
liveArtifacts: [],
|
|
onRefreshFiles: vi.fn(),
|
|
onOpenFile: vi.fn(),
|
|
onOpenLiveArtifact: vi.fn(),
|
|
onRenameFile: vi.fn(),
|
|
onDeleteFile: vi.fn(),
|
|
onDeleteFiles: vi.fn(),
|
|
onUpload: vi.fn(),
|
|
onUploadFiles: vi.fn(),
|
|
onPaste: vi.fn(),
|
|
onNewSketch: vi.fn(),
|
|
...overrides,
|
|
};
|
|
return render(<DesignFilesPanel {...props} />);
|
|
}
|
|
|
|
function unreadableDropDataTransfer(fallbackFiles: File[] = []) {
|
|
return {
|
|
files: fallbackFiles,
|
|
items: [
|
|
{
|
|
webkitGetAsEntry: () => ({
|
|
isFile: true,
|
|
isDirectory: false,
|
|
name: 'stale.png',
|
|
file: (_done: (file: File) => void, fail?: (error: DOMException) => void) => {
|
|
fail?.(new DOMException('missing', 'NotFoundError'));
|
|
},
|
|
}),
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
describe('FileWorkspace upload input', () => {
|
|
it('keeps the Design Files picker aligned with drag-and-drop file support', () => {
|
|
const markup = renderToStaticMarkup(
|
|
<FileWorkspace
|
|
projectId="project-1"
|
|
projectKind="prototype"
|
|
files={[]}
|
|
liveArtifacts={[]}
|
|
onRefreshFiles={vi.fn()}
|
|
isDeck={false}
|
|
tabsState={{ tabs: [], active: null }}
|
|
onTabsStateChange={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
expect(markup).toContain('data-testid="design-files-upload-input"');
|
|
expect(markup).not.toContain('accept=');
|
|
});
|
|
|
|
it('auto-saves a newly created sketch into project files', async () => {
|
|
const onRefreshFiles = vi.fn();
|
|
const onTabsStateChange = vi.fn();
|
|
mockedWriteProjectTextFile.mockImplementation(async (_projectId, name) => ({
|
|
name,
|
|
path: name,
|
|
type: 'file',
|
|
size: 128,
|
|
mtime: 1710000000,
|
|
kind: 'sketch',
|
|
mime: 'application/json',
|
|
}));
|
|
|
|
render(
|
|
<FileWorkspace
|
|
projectId="project-1"
|
|
projectKind="prototype"
|
|
files={[]}
|
|
liveArtifacts={[]}
|
|
onRefreshFiles={onRefreshFiles}
|
|
isDeck={false}
|
|
tabsState={{ tabs: [], active: null }}
|
|
onTabsStateChange={onTabsStateChange}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByTestId('design-files-empty-new-sketch'));
|
|
|
|
await waitFor(() => expect(mockedWriteProjectTextFile).toHaveBeenCalledTimes(1));
|
|
const [projectId, name, content] = mockedWriteProjectTextFile.mock.calls[0]!;
|
|
expect(projectId).toBe('project-1');
|
|
expect(name).toMatch(/^sketch-.*\.sketch\.json$/);
|
|
expect(JSON.parse(content as string)).toMatchObject({
|
|
type: 'excalidraw',
|
|
version: 2,
|
|
});
|
|
await waitFor(() => expect(onRefreshFiles).toHaveBeenCalledTimes(1));
|
|
await waitFor(() =>
|
|
expect(onTabsStateChange).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
tabs: [name],
|
|
active: name,
|
|
}),
|
|
),
|
|
);
|
|
});
|
|
|
|
it('hides upload failure details during in-panel preview and restores them after closing preview', async () => {
|
|
mockedUploadProjectFiles.mockRejectedValueOnce(new Error('storage offline'));
|
|
|
|
render(
|
|
<FileWorkspace
|
|
projectId="project-1"
|
|
projectKind="prototype"
|
|
files={[baseFile()]}
|
|
liveArtifacts={[]}
|
|
onRefreshFiles={vi.fn()}
|
|
isDeck={false}
|
|
tabsState={{ tabs: [], active: null }}
|
|
onTabsStateChange={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.change(screen.getByTestId('design-files-upload-input'), {
|
|
target: { files: [new File(['mock'], 'mock.png', { type: 'image/png' })] },
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('upload-error-banner').textContent).toContain(
|
|
'storage offline',
|
|
);
|
|
});
|
|
|
|
const row = screen.getByTestId('design-file-row-mock.png');
|
|
const nameButton = row.querySelector<HTMLButtonElement>('.df-row-name-btn');
|
|
if (!nameButton) throw new Error('Could not find file name button');
|
|
fireEvent.click(nameButton);
|
|
|
|
expect(screen.getByTestId('design-file-preview')).toBeTruthy();
|
|
expect(screen.queryByTestId('upload-error-banner')).toBeNull();
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Close preview' }));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('upload-error-banner').textContent).toContain(
|
|
'storage offline',
|
|
);
|
|
});
|
|
|
|
fireEvent.click(screen.getByTestId('upload-error-dismiss'));
|
|
|
|
expect(screen.queryByTestId('upload-error-banner')).toBeNull();
|
|
});
|
|
|
|
it('keeps partial upload failures visible after a successful file opens', async () => {
|
|
mockedUploadProjectFiles.mockResolvedValueOnce({
|
|
uploaded: [
|
|
{
|
|
path: 'uploaded.png',
|
|
name: 'uploaded.png',
|
|
kind: 'image',
|
|
size: 1024,
|
|
},
|
|
],
|
|
failed: [{ name: 'failed.png', error: 'permission denied' }],
|
|
error: 'permission denied',
|
|
});
|
|
|
|
render(
|
|
<FileWorkspace
|
|
projectId="project-1"
|
|
projectKind="prototype"
|
|
files={[baseFile({ name: 'uploaded.png', path: 'uploaded.png' })]}
|
|
liveArtifacts={[]}
|
|
onRefreshFiles={vi.fn()}
|
|
isDeck={false}
|
|
tabsState={{ tabs: [], active: null }}
|
|
onTabsStateChange={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.change(screen.getByTestId('design-files-upload-input'), {
|
|
target: {
|
|
files: [
|
|
new File(['uploaded'], 'uploaded.png', { type: 'image/png' }),
|
|
new File(['failed'], 'failed.png', { type: 'image/png' }),
|
|
],
|
|
},
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('upload-error-banner').textContent).toContain(
|
|
'Uploaded 1 file(s), but 1 failed (permission denied).',
|
|
);
|
|
});
|
|
});
|
|
|
|
it('starts Design Files navigation fresh when switching projects', () => {
|
|
const baseProps: React.ComponentProps<typeof FileWorkspace> = {
|
|
projectId: 'project-a',
|
|
projectKind: 'prototype',
|
|
files: [
|
|
workspaceFile('assets/logo.png'),
|
|
workspaceFile('top.html'),
|
|
],
|
|
liveArtifacts: [],
|
|
onRefreshFiles: vi.fn(),
|
|
isDeck: false,
|
|
tabsState: { tabs: [], active: null },
|
|
onTabsStateChange: vi.fn(),
|
|
};
|
|
|
|
const { container, rerender } = render(<FileWorkspace {...baseProps} />);
|
|
|
|
fireEvent.click(container.querySelector('.df-dir-row .df-row-name-btn')!);
|
|
expect(container.querySelector('.df-breadcrumb-current')?.textContent).toBe('assets');
|
|
|
|
rerender(
|
|
<FileWorkspace
|
|
{...baseProps}
|
|
projectId="project-b"
|
|
files={[
|
|
workspaceFile('beta-assets/logo.png'),
|
|
workspaceFile('home.html'),
|
|
]}
|
|
/>,
|
|
);
|
|
|
|
expect(container.querySelector('.df-breadcrumb-current')?.textContent).toBe('Project');
|
|
expect(screen.getByTestId('design-file-row-home.html')).toBeTruthy();
|
|
});
|
|
|
|
it('drops the previous project folders when switching, before the new fetch resolves', async () => {
|
|
const folder = (path: string): ProjectFolder => ({
|
|
name: path.split('/').pop() ?? path,
|
|
path,
|
|
type: 'dir',
|
|
size: 0,
|
|
mtime: 1700000000,
|
|
});
|
|
const mockedFolders = vi.mocked(fetchProjectFolders);
|
|
// project-a has an empty persisted folder; project-b's fetch stays pending.
|
|
// (One-time values take precedence over the factory default `[]`; no reset,
|
|
// so later tests keep that default.)
|
|
mockedFolders.mockResolvedValueOnce([folder('assets')]);
|
|
mockedFolders.mockReturnValueOnce(new Promise<ProjectFolder[]>(() => {}));
|
|
|
|
const baseProps: React.ComponentProps<typeof FileWorkspace> = {
|
|
projectId: 'project-a',
|
|
projectKind: 'prototype',
|
|
files: [],
|
|
liveArtifacts: [],
|
|
onRefreshFiles: vi.fn(),
|
|
isDeck: false,
|
|
tabsState: { tabs: [], active: null },
|
|
onTabsStateChange: vi.fn(),
|
|
};
|
|
const { container, rerender } = render(<FileWorkspace {...baseProps} />);
|
|
// project-a's empty folder shows once its fetch resolves.
|
|
await waitFor(() => {
|
|
expect(
|
|
[...container.querySelectorAll('.df-dir-row .df-row-name')].some(
|
|
(e) => e.textContent === 'assets',
|
|
),
|
|
).toBe(true);
|
|
});
|
|
|
|
// Switch to project-b; its folder fetch is still pending. The previous
|
|
// project's 'assets' folder must be gone immediately (reset synchronously),
|
|
// not linger and suppress the new project's empty state.
|
|
designFilesPanelRenders.length = 0;
|
|
rerender(<FileWorkspace {...baseProps} projectId="project-b" files={[]} />);
|
|
expect(
|
|
[...container.querySelectorAll('.df-dir-row .df-row-name')].some(
|
|
(e) => e.textContent === 'assets',
|
|
),
|
|
).toBe(false);
|
|
|
|
// The reset happens during render, not in an effect — so the new panel's
|
|
// FIRST render (and every render thereafter) already sees zero folders.
|
|
// An effect-based reset would let project-b's first render observe the
|
|
// stale 'assets' folder before the effect cleared it; RTL's post-rerender
|
|
// DOM check above can't catch that frame, this can.
|
|
const projectBRenders = designFilesPanelRenders.filter((r) => r.projectId === 'project-b');
|
|
expect(projectBRenders.length).toBeGreaterThan(0);
|
|
expect(projectBRenders.every((r) => r.folderCount === 0)).toBe(true);
|
|
});
|
|
|
|
it('clears a prior upload failure after a later successful upload', async () => {
|
|
mockedUploadProjectFiles
|
|
.mockRejectedValueOnce(new Error('storage offline'))
|
|
.mockResolvedValueOnce({
|
|
uploaded: [
|
|
{
|
|
path: 'retry.png',
|
|
name: 'retry.png',
|
|
kind: 'image',
|
|
size: 1024,
|
|
},
|
|
],
|
|
failed: [],
|
|
});
|
|
|
|
const onRefreshFiles = vi.fn();
|
|
render(
|
|
<FileWorkspace
|
|
projectId="project-1"
|
|
projectKind="prototype"
|
|
files={[baseFile({ name: 'retry.png', path: 'retry.png' })]}
|
|
liveArtifacts={[]}
|
|
onRefreshFiles={onRefreshFiles}
|
|
isDeck={false}
|
|
tabsState={{ tabs: [], active: null }}
|
|
onTabsStateChange={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
const input = screen.getByTestId('design-files-upload-input');
|
|
fireEvent.change(input, {
|
|
target: { files: [new File(['failed'], 'failed.png', { type: 'image/png' })] },
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('upload-error-banner').textContent).toContain('storage offline');
|
|
});
|
|
|
|
fireEvent.change(input, {
|
|
target: { files: [new File(['retry'], 'retry.png', { type: 'image/png' })] },
|
|
});
|
|
|
|
await waitFor(() => expect(onRefreshFiles).toHaveBeenCalledTimes(1));
|
|
await waitFor(() => expect(screen.queryByTestId('upload-error-banner')).toBeNull());
|
|
});
|
|
|
|
it('falls back to the browser file list when a dragged entry cannot be read', async () => {
|
|
const fallbackFile = new File(['mock'], 'fallback.png', { type: 'image/png' });
|
|
const onUploadFiles = vi.fn();
|
|
const { container } = renderDesignFilesPanel({ onUploadFiles });
|
|
|
|
fireEvent.drop(container.querySelector('.df-body')!, {
|
|
dataTransfer: unreadableDropDataTransfer([fallbackFile]),
|
|
});
|
|
|
|
await waitFor(() => expect(onUploadFiles).toHaveBeenCalledWith([fallbackFile]));
|
|
expect(screen.queryByTestId('upload-error-banner')).toBeNull();
|
|
});
|
|
|
|
it('uploads files pasted from the clipboard in Design Files', () => {
|
|
const pastedFile = new File(['mock'], 'clipboard.png', { type: 'image/png' });
|
|
const onUploadFiles = vi.fn();
|
|
const onClearUploadError = vi.fn();
|
|
renderDesignFilesPanel({ onUploadFiles, onClearUploadError });
|
|
|
|
const event = new Event('paste', { bubbles: true, cancelable: true }) as ClipboardEvent;
|
|
Object.defineProperty(event, 'clipboardData', {
|
|
value: {
|
|
files: [pastedFile],
|
|
items: [],
|
|
},
|
|
});
|
|
|
|
window.dispatchEvent(event);
|
|
|
|
expect(event.defaultPrevented).toBe(true);
|
|
expect(onClearUploadError).toHaveBeenCalledTimes(1);
|
|
expect(onUploadFiles).toHaveBeenCalledWith([pastedFile]);
|
|
});
|
|
|
|
it('does not steal clipboard files from text inputs', () => {
|
|
const pastedFile = new File(['mock'], 'clipboard.png', { type: 'image/png' });
|
|
const onUploadFiles = vi.fn();
|
|
renderDesignFilesPanel({ onUploadFiles });
|
|
const textarea = document.createElement('textarea');
|
|
document.body.appendChild(textarea);
|
|
|
|
try {
|
|
const event = new Event('paste', { bubbles: true, cancelable: true }) as ClipboardEvent;
|
|
Object.defineProperty(event, 'clipboardData', {
|
|
value: {
|
|
files: [pastedFile],
|
|
items: [],
|
|
},
|
|
});
|
|
textarea.dispatchEvent(event);
|
|
|
|
expect(event.defaultPrevented).toBe(false);
|
|
expect(onUploadFiles).not.toHaveBeenCalled();
|
|
} finally {
|
|
textarea.remove();
|
|
}
|
|
});
|
|
|
|
it('shows a recoverable read error when a dragged entry disappears before import', async () => {
|
|
const onUploadFiles = vi.fn();
|
|
const { container } = renderDesignFilesPanel({ onUploadFiles });
|
|
|
|
fireEvent.drop(container.querySelector('.df-body')!, {
|
|
dataTransfer: unreadableDropDataTransfer(),
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('upload-error-banner').textContent).toContain(
|
|
'Could not read one or more dropped files or folders',
|
|
);
|
|
});
|
|
expect(onUploadFiles).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('hides the workspace focus control while the chat pane is open', () => {
|
|
const markup = renderToStaticMarkup(
|
|
<FileWorkspace
|
|
projectId="project-1"
|
|
projectKind="prototype"
|
|
files={[]}
|
|
liveArtifacts={[]}
|
|
onRefreshFiles={vi.fn()}
|
|
isDeck={false}
|
|
tabsState={{ tabs: [], active: null }}
|
|
onTabsStateChange={vi.fn()}
|
|
focusMode={false}
|
|
onFocusModeChange={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
// While chat is visible the collapse trigger lives in ChatPane.
|
|
// FileWorkspace only renders an expand control once chat is hidden.
|
|
expect(markup).not.toContain('data-testid="workspace-focus-toggle"');
|
|
});
|
|
|
|
it('renders the expand control on the LEFT of the tab bar while focused', () => {
|
|
const markup = renderToStaticMarkup(
|
|
<FileWorkspace
|
|
projectId="project-1"
|
|
projectKind="prototype"
|
|
files={[]}
|
|
liveArtifacts={[]}
|
|
onRefreshFiles={vi.fn()}
|
|
isDeck={false}
|
|
tabsState={{ tabs: [], active: null }}
|
|
onTabsStateChange={vi.fn()}
|
|
focusMode
|
|
onFocusModeChange={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
expect(markup).toContain('class="ws-tabs-shell"');
|
|
expect(markup).toContain('data-testid="workspace-focus-toggle"');
|
|
// The expand control sits before the tabs bar (left side) so its
|
|
// direction matches where the chat pane re-emerges from.
|
|
expect(markup).toMatch(
|
|
/<div class="ws-tabs-shell">\s*<button[^>]*data-testid="workspace-focus-toggle"[\s\S]*?<\/button>\s*<div class="ws-tabs-bar"/,
|
|
);
|
|
});
|
|
|
|
it('keeps the Design Files tab as the first workspace tab before opened files', () => {
|
|
const markup = renderToStaticMarkup(
|
|
<FileWorkspace
|
|
projectId="project-1"
|
|
projectKind="prototype"
|
|
files={[workspaceFile('artifact.html')]}
|
|
liveArtifacts={[]}
|
|
onRefreshFiles={vi.fn()}
|
|
isDeck={false}
|
|
tabsState={{ tabs: ['artifact.html'], active: 'artifact.html' }}
|
|
onTabsStateChange={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
expect(markup).toContain('class="ws-tabs-bar"');
|
|
expect(markup).toMatch(
|
|
/role="tablist"[\s\S]*data-testid="design-files-tab"[\s\S]*artifact\.html/,
|
|
);
|
|
});
|
|
|
|
it('labels the same workspace control as chat restore while focused', () => {
|
|
const markup = renderToStaticMarkup(
|
|
<FileWorkspace
|
|
projectId="project-1"
|
|
projectKind="prototype"
|
|
files={[]}
|
|
liveArtifacts={[]}
|
|
onRefreshFiles={vi.fn()}
|
|
isDeck={false}
|
|
tabsState={{ tabs: [], active: null }}
|
|
onTabsStateChange={vi.fn()}
|
|
focusMode
|
|
onFocusModeChange={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
expect(markup).toContain('Show chat');
|
|
});
|
|
});
|
|
|
|
describe('FileWorkspace launcher tab creation', () => {
|
|
it('does not report a Design Files context for an empty project', async () => {
|
|
// A brand-new project has no files, live artifacts, or folders. The
|
|
// composer must not auto-stage a "Design files" chip that points at
|
|
// nothing, so the active workspace context stays null.
|
|
const onActiveContextChange = vi.fn();
|
|
render(
|
|
<FileWorkspace
|
|
projectId="project-1"
|
|
projectKind="prototype"
|
|
resolvedDir="/tmp/open-design/project-1"
|
|
files={[]}
|
|
liveArtifacts={[]}
|
|
onRefreshFiles={vi.fn()}
|
|
isDeck={false}
|
|
tabsState={{ tabs: [], active: null }}
|
|
onTabsStateChange={vi.fn()}
|
|
onActiveContextChange={onActiveContextChange}
|
|
/>,
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(onActiveContextChange).toHaveBeenCalled();
|
|
});
|
|
expect(onActiveContextChange).toHaveBeenLastCalledWith(null);
|
|
});
|
|
|
|
it('reports the active Design Files tab as workspace context once files exist', async () => {
|
|
const onActiveContextChange = vi.fn();
|
|
render(
|
|
<FileWorkspace
|
|
projectId="project-1"
|
|
projectKind="prototype"
|
|
resolvedDir="/tmp/open-design/project-1"
|
|
files={[workspaceFile('cover.html')]}
|
|
liveArtifacts={[]}
|
|
onRefreshFiles={vi.fn()}
|
|
isDeck={false}
|
|
tabsState={{ tabs: [], active: null }}
|
|
onTabsStateChange={vi.fn()}
|
|
onActiveContextChange={onActiveContextChange}
|
|
/>,
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(onActiveContextChange).toHaveBeenLastCalledWith({
|
|
id: 'workspace:design-files',
|
|
kind: 'design-files',
|
|
label: 'Design Files',
|
|
tabId: '__design_files__',
|
|
absolutePath: '/tmp/open-design/project-1',
|
|
});
|
|
});
|
|
});
|
|
|
|
it('hides terminal creation while keeping browser creation available', () => {
|
|
render(
|
|
<FileWorkspace
|
|
projectId="project-1"
|
|
projectKind="prototype"
|
|
files={[]}
|
|
liveArtifacts={[]}
|
|
onRefreshFiles={vi.fn()}
|
|
isDeck={false}
|
|
tabsState={{ tabs: [], active: null }}
|
|
onTabsStateChange={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByTestId('workspace-add-tab'));
|
|
|
|
expect(screen.queryByRole('button', { name: /New Terminal/i })).toBeNull();
|
|
expect(screen.getByRole('button', { name: /New Browser/i })).toBeTruthy();
|
|
expect(
|
|
screen.getByText('Sketch rough layouts and notes for the agent to use as design context'),
|
|
).toBeTruthy();
|
|
expect(screen.getByText('Create new')).toBeTruthy();
|
|
});
|
|
|
|
it('renders terminal and side chat tabs after a Design Files-anchored browser tab', () => {
|
|
render(
|
|
<FileWorkspace
|
|
projectId="project-1"
|
|
projectKind="prototype"
|
|
files={[]}
|
|
liveArtifacts={[]}
|
|
onRefreshFiles={vi.fn()}
|
|
isDeck={false}
|
|
tabsState={{
|
|
tabs: ['terminal:term-1', 'chat:conversation-1'],
|
|
active: 'chat:conversation-1',
|
|
browserTabs: [
|
|
{
|
|
id: '__browser__:1',
|
|
insertAfter: '__design_files__',
|
|
label: 'Browser',
|
|
},
|
|
],
|
|
}}
|
|
conversations={[
|
|
{
|
|
id: 'conversation-1',
|
|
projectId: 'project-1',
|
|
title: null,
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
},
|
|
]}
|
|
onTabsStateChange={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
expect(renderedTabLabels()).toEqual([
|
|
'Design Files',
|
|
'Browser',
|
|
'New Terminal',
|
|
'Side chat',
|
|
]);
|
|
});
|
|
|
|
it('opens Design Files from the browser snapshot toast action instead of the manifest file', async () => {
|
|
const onTabsStateChange = vi.fn();
|
|
const browserTab = {
|
|
id: '__browser__:1',
|
|
insertAfter: '__design_files__',
|
|
label: 'Browser',
|
|
title: 'Example',
|
|
url: 'https://example.com',
|
|
};
|
|
|
|
render(
|
|
<FileWorkspace
|
|
projectId="project-1"
|
|
projectKind="prototype"
|
|
files={[]}
|
|
liveArtifacts={[]}
|
|
onRefreshFiles={vi.fn()}
|
|
isDeck={false}
|
|
tabsState={{
|
|
tabs: [],
|
|
active: '__browser__:1',
|
|
browserTabs: [browserTab],
|
|
}}
|
|
onTabsStateChange={onTabsStateChange}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByTestId('emit-browser-snapshot-success'));
|
|
await screen.findByRole('button', { name: 'View Design Files' });
|
|
const toastAction = document.querySelector<HTMLButtonElement>('.od-toast-action');
|
|
if (!toastAction) throw new Error('Could not find browser snapshot toast action');
|
|
await act(async () => {
|
|
fireEvent.click(toastAction);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(onTabsStateChange).toHaveBeenCalledWith({
|
|
tabs: [],
|
|
active: DESIGN_FILES_TAB,
|
|
browserTabs: [browserTab],
|
|
});
|
|
});
|
|
expect(onTabsStateChange).not.toHaveBeenCalledWith(
|
|
expect.objectContaining({ active: 'browser-archive/example/manifest.json' }),
|
|
);
|
|
});
|
|
|
|
it('anchors a new browser after the visible tab tail', async () => {
|
|
const onTabsStateChange = vi.fn();
|
|
const rootBrowserTab = {
|
|
id: '__browser__:1',
|
|
insertAfter: '__design_files__',
|
|
label: 'Browser',
|
|
};
|
|
|
|
render(
|
|
<FileWorkspace
|
|
projectId="project-1"
|
|
projectKind="prototype"
|
|
files={[]}
|
|
liveArtifacts={[]}
|
|
onRefreshFiles={vi.fn()}
|
|
isDeck={false}
|
|
tabsState={{
|
|
tabs: ['terminal:term-1'],
|
|
active: 'terminal:term-1',
|
|
browserTabs: [rootBrowserTab],
|
|
}}
|
|
onTabsStateChange={onTabsStateChange}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByTestId('workspace-add-tab'));
|
|
fireEvent.click(await screen.findByRole('button', { name: /New Browser/i }));
|
|
|
|
await waitFor(() => {
|
|
expect(onTabsStateChange).toHaveBeenCalledWith({
|
|
tabs: ['terminal:term-1'],
|
|
active: '__browser__:2',
|
|
browserTabs: [
|
|
rootBrowserTab,
|
|
{
|
|
id: '__browser__:2',
|
|
insertAfter: 'terminal:term-1',
|
|
label: 'Browser 2',
|
|
},
|
|
],
|
|
});
|
|
});
|
|
});
|
|
|
|
it('reanchors stale browser tabs before appending a file from the launcher', async () => {
|
|
const onTabsStateChange = vi.fn();
|
|
const staleBrowserTab = {
|
|
id: '__browser__:1',
|
|
insertAfter: 'deleted.html',
|
|
label: 'Browser',
|
|
};
|
|
|
|
render(
|
|
<FileWorkspace
|
|
projectId="project-1"
|
|
projectKind="prototype"
|
|
files={[workspaceFile('cover.html'), workspaceFile('notes.html')]}
|
|
liveArtifacts={[]}
|
|
onRefreshFiles={vi.fn()}
|
|
isDeck={false}
|
|
tabsState={{
|
|
tabs: ['cover.html'],
|
|
active: 'cover.html',
|
|
browserTabs: [staleBrowserTab],
|
|
}}
|
|
onTabsStateChange={onTabsStateChange}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByTestId('workspace-add-tab'));
|
|
fireEvent.click(await screen.findByRole('button', { name: /notes\.html/i }));
|
|
|
|
await waitFor(() => {
|
|
expect(onTabsStateChange).toHaveBeenCalledWith({
|
|
tabs: ['cover.html', 'notes.html'],
|
|
active: 'notes.html',
|
|
browserTabs: [
|
|
{
|
|
...staleBrowserTab,
|
|
insertAfter: 'cover.html',
|
|
},
|
|
],
|
|
});
|
|
});
|
|
});
|
|
|
|
it('opens the Design Files tab launcher with the browser new-tab shortcut', async () => {
|
|
render(
|
|
<FileWorkspace
|
|
projectId="project-1"
|
|
projectKind="prototype"
|
|
files={[workspaceFile('cover.html')]}
|
|
liveArtifacts={[]}
|
|
onRefreshFiles={vi.fn()}
|
|
isDeck={false}
|
|
tabsState={{ tabs: [], active: null }}
|
|
onTabsStateChange={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
const allowedDefault = fireEvent.keyDown(window, {
|
|
key: 't',
|
|
metaKey: true,
|
|
});
|
|
|
|
expect(allowedDefault).toBe(false);
|
|
expect(screen.getByTestId('workspace-add-tab').getAttribute('aria-expanded')).toBe(
|
|
'true',
|
|
);
|
|
expect(await screen.findByRole('dialog', { name: /New tab/i })).toBeTruthy();
|
|
expect(screen.getByTestId('tab-launcher-search')).toBe(document.activeElement);
|
|
});
|
|
|
|
it('closes the active Design Files workspace tab with the browser close-tab shortcut', () => {
|
|
const onTabsStateChange = vi.fn();
|
|
render(
|
|
<FileWorkspace
|
|
projectId="project-1"
|
|
projectKind="prototype"
|
|
files={[workspaceFile('analysis.html'), workspaceFile('notes.html')]}
|
|
liveArtifacts={[]}
|
|
onRefreshFiles={vi.fn()}
|
|
isDeck={false}
|
|
tabsState={{ tabs: ['analysis.html', 'notes.html'], active: 'notes.html' }}
|
|
onTabsStateChange={onTabsStateChange}
|
|
/>,
|
|
);
|
|
|
|
const allowedDefault = fireEvent.keyDown(window, {
|
|
key: 'w',
|
|
ctrlKey: true,
|
|
});
|
|
|
|
expect(allowedDefault).toBe(false);
|
|
expect(onTabsStateChange).toHaveBeenLastCalledWith({
|
|
tabs: ['analysis.html'],
|
|
active: 'analysis.html',
|
|
});
|
|
});
|
|
|
|
it('switches Design Files workspace tabs with browser-style next and previous shortcuts', async () => {
|
|
const onTabsStateChange = vi.fn();
|
|
render(
|
|
<FileWorkspace
|
|
projectId="project-1"
|
|
projectKind="prototype"
|
|
files={[workspaceFile('analysis.html'), workspaceFile('notes.html')]}
|
|
liveArtifacts={[]}
|
|
onRefreshFiles={vi.fn()}
|
|
isDeck={false}
|
|
tabsState={{ tabs: ['analysis.html', 'notes.html'], active: 'analysis.html' }}
|
|
onTabsStateChange={onTabsStateChange}
|
|
/>,
|
|
);
|
|
|
|
const nextAllowedDefault = fireEvent.keyDown(window, {
|
|
key: 'Tab',
|
|
ctrlKey: true,
|
|
});
|
|
|
|
expect(nextAllowedDefault).toBe(false);
|
|
await waitFor(() => {
|
|
expect(screen.getByRole('tab', { name: /notes\.html/ }).getAttribute('aria-selected')).toBe(
|
|
'true',
|
|
);
|
|
});
|
|
expect(onTabsStateChange).toHaveBeenLastCalledWith({
|
|
tabs: ['analysis.html', 'notes.html'],
|
|
active: 'notes.html',
|
|
});
|
|
|
|
const previousAllowedDefault = fireEvent.keyDown(window, {
|
|
key: 'Tab',
|
|
ctrlKey: true,
|
|
shiftKey: true,
|
|
});
|
|
|
|
expect(previousAllowedDefault).toBe(false);
|
|
await waitFor(() => {
|
|
expect(screen.getByRole('tab', { name: /analysis\.html/ }).getAttribute('aria-selected')).toBe(
|
|
'true',
|
|
);
|
|
});
|
|
expect(onTabsStateChange).toHaveBeenLastCalledWith({
|
|
tabs: ['analysis.html', 'notes.html'],
|
|
active: 'analysis.html',
|
|
});
|
|
});
|
|
|
|
it('focuses a browser open request without adding it to file tabs', async () => {
|
|
const onTabsStateChange = vi.fn();
|
|
const browserTabs = [
|
|
{
|
|
id: '__browser__:1',
|
|
label: 'Browser 1',
|
|
title: 'Dribbble',
|
|
url: 'https://dribbble.com/',
|
|
},
|
|
];
|
|
|
|
render(
|
|
<FileWorkspace
|
|
projectId="project-1"
|
|
projectKind="prototype"
|
|
files={[workspaceFile('cover.html')]}
|
|
liveArtifacts={[]}
|
|
onRefreshFiles={vi.fn()}
|
|
isDeck={false}
|
|
tabsState={{ tabs: ['cover.html'], active: 'cover.html', browserTabs }}
|
|
openRequest={{ name: '__browser__:1', nonce: 1 }}
|
|
onTabsStateChange={onTabsStateChange}
|
|
/>,
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(onTabsStateChange).toHaveBeenCalledWith({
|
|
tabs: ['cover.html'],
|
|
active: '__browser__:1',
|
|
browserTabs,
|
|
});
|
|
});
|
|
});
|
|
|
|
it('creates and navigates a browser tab from a browser open request', async () => {
|
|
const onTabsStateChange = vi.fn();
|
|
|
|
render(
|
|
<FileWorkspace
|
|
projectId="project-1"
|
|
projectKind="prototype"
|
|
files={[workspaceFile('cover.html')]}
|
|
liveArtifacts={[]}
|
|
onRefreshFiles={vi.fn()}
|
|
isDeck={false}
|
|
tabsState={{ tabs: ['cover.html'], active: 'cover.html' }}
|
|
browserOpenRequest={{ tabId: '__browser__:1', url: 'https://economist.com/', nonce: 7 }}
|
|
onTabsStateChange={onTabsStateChange}
|
|
/>,
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(onTabsStateChange).toHaveBeenCalledWith({
|
|
tabs: ['cover.html'],
|
|
active: '__browser__:1',
|
|
browserTabs: [
|
|
{
|
|
id: '__browser__:1',
|
|
insertAfter: 'cover.html',
|
|
label: 'Browser',
|
|
title: 'economist.com',
|
|
url: 'https://economist.com/',
|
|
},
|
|
],
|
|
});
|
|
});
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('design-browser-panel').getAttribute('data-navigate-url'))
|
|
.toBe('https://economist.com/');
|
|
});
|
|
});
|
|
|
|
it('opens a share request without dropping existing browser tabs', async () => {
|
|
const onTabsStateChange = vi.fn();
|
|
const browserTabs = [
|
|
{
|
|
id: '__browser__:1',
|
|
label: 'Browser 1',
|
|
title: 'Dribbble',
|
|
url: 'https://dribbble.com/',
|
|
},
|
|
];
|
|
|
|
render(
|
|
<FileWorkspace
|
|
projectId="project-1"
|
|
projectKind="prototype"
|
|
files={[workspaceFile('cover.html'), workspaceFile('landing.html')]}
|
|
liveArtifacts={[]}
|
|
onRefreshFiles={vi.fn()}
|
|
isDeck={false}
|
|
tabsState={{ tabs: ['cover.html'], active: '__browser__:1', browserTabs }}
|
|
shareRequest={{ name: 'landing.html', nonce: 1 }}
|
|
onTabsStateChange={onTabsStateChange}
|
|
/>,
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(onTabsStateChange).toHaveBeenCalledWith({
|
|
tabs: ['cover.html', 'landing.html'],
|
|
active: 'landing.html',
|
|
browserTabs,
|
|
});
|
|
});
|
|
});
|
|
|
|
it('opens and activates the target file for a download request', async () => {
|
|
const onTabsStateChange = vi.fn();
|
|
const browserTabs = [
|
|
{ id: '__browser__:1', label: 'Browser 1', title: 'Dribbble', url: 'https://dribbble.com/' },
|
|
];
|
|
|
|
render(
|
|
<FileWorkspace
|
|
projectId="project-1"
|
|
projectKind="prototype"
|
|
files={[workspaceFile('cover.html'), workspaceFile('landing.html')]}
|
|
liveArtifacts={[]}
|
|
onRefreshFiles={vi.fn()}
|
|
isDeck={false}
|
|
tabsState={{ tabs: ['cover.html'], active: '__browser__:1', browserTabs }}
|
|
downloadRequest={{ name: 'landing.html', nonce: 1 }}
|
|
onTabsStateChange={onTabsStateChange}
|
|
/>,
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(onTabsStateChange).toHaveBeenCalledWith({
|
|
tabs: ['cover.html', 'landing.html'],
|
|
active: 'landing.html',
|
|
browserTabs,
|
|
});
|
|
});
|
|
});
|
|
|
|
it('focuses the design-system workspace tab without adding it to file tabs', async () => {
|
|
const onTabsStateChange = vi.fn();
|
|
|
|
render(
|
|
<FileWorkspace
|
|
projectId="project-1"
|
|
projectKind="prototype"
|
|
files={[workspaceFile('cover.html')]}
|
|
liveArtifacts={[]}
|
|
onRefreshFiles={vi.fn()}
|
|
isDeck={false}
|
|
tabsState={{ tabs: ['cover.html'], active: 'cover.html' }}
|
|
openRequest={{ name: '__design_system__', nonce: 1 }}
|
|
onTabsStateChange={onTabsStateChange}
|
|
designSystemProject={{
|
|
id: 'neutral-modern',
|
|
title: 'Neutral Modern',
|
|
category: 'Starter',
|
|
source: 'bundled',
|
|
updatedAt: 1,
|
|
} as never}
|
|
/>,
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(onTabsStateChange).toHaveBeenCalledWith({
|
|
tabs: ['cover.html'],
|
|
active: '__design_system__',
|
|
});
|
|
});
|
|
});
|
|
|
|
it('focuses an already-open file tab without adding a duplicate tab', async () => {
|
|
const onTabsStateChange = vi.fn();
|
|
|
|
render(
|
|
<FileWorkspace
|
|
projectId="project-1"
|
|
projectKind="prototype"
|
|
files={[workspaceFile('Web Prototype mutuals-v2.html')]}
|
|
liveArtifacts={[]}
|
|
onRefreshFiles={vi.fn()}
|
|
isDeck={false}
|
|
tabsState={{
|
|
tabs: ['Web Prototype mutuals-v2.html'],
|
|
active: 'notes.html',
|
|
}}
|
|
openRequest={{ name: 'Web Prototype mutuals-v2.html', nonce: 1 }}
|
|
onTabsStateChange={onTabsStateChange}
|
|
/>,
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(onTabsStateChange).toHaveBeenCalledWith({
|
|
tabs: ['Web Prototype mutuals-v2.html'],
|
|
active: 'Web Prototype mutuals-v2.html',
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('DesignFilesPanel plugin folders', () => {
|
|
it('surfaces generated plugin folders with agent-routed CLI actions', async () => {
|
|
const onPluginFolderAgentAction = vi.fn();
|
|
const container = renderWorkspace(
|
|
<DesignFilesPanel
|
|
projectId="project-1"
|
|
files={[
|
|
workspaceFile('generated-plugin/open-design.json'),
|
|
workspaceFile('generated-plugin/SKILL.md'),
|
|
workspaceFile('generated-plugin/examples/demo.md'),
|
|
]}
|
|
liveArtifacts={[]}
|
|
onRefreshFiles={vi.fn()}
|
|
onOpenFile={vi.fn()}
|
|
onOpenLiveArtifact={vi.fn()}
|
|
onDeleteFile={vi.fn()}
|
|
onDeleteFiles={vi.fn()}
|
|
onRenameFile={vi.fn()}
|
|
onUpload={vi.fn()}
|
|
onUploadFiles={vi.fn()}
|
|
onPaste={vi.fn()}
|
|
onNewSketch={vi.fn()}
|
|
onPluginFolderAgentAction={onPluginFolderAgentAction}
|
|
/>,
|
|
);
|
|
|
|
expect(container.querySelector('[data-testid="design-plugin-folder-generated-plugin"]')).toBeTruthy();
|
|
const install = container.querySelector<HTMLButtonElement>(
|
|
'[data-testid="design-plugin-folder-install-generated-plugin"]',
|
|
);
|
|
expect(install).toBeTruthy();
|
|
await act(async () => {
|
|
install?.click();
|
|
});
|
|
expect(onPluginFolderAgentAction).toHaveBeenCalledWith('generated-plugin', 'install');
|
|
|
|
const publish = container.querySelector<HTMLButtonElement>(
|
|
'[data-testid="design-plugin-folder-publish-generated-plugin"]',
|
|
);
|
|
const contribute = container.querySelector<HTMLButtonElement>(
|
|
'[data-testid="design-plugin-folder-contribute-generated-plugin"]',
|
|
);
|
|
expect(publish).toBeTruthy();
|
|
expect(contribute).toBeTruthy();
|
|
await act(async () => {
|
|
publish?.click();
|
|
});
|
|
expect(onPluginFolderAgentAction).toHaveBeenCalledWith('generated-plugin', 'publish');
|
|
await act(async () => {
|
|
contribute?.click();
|
|
});
|
|
expect(onPluginFolderAgentAction).toHaveBeenCalledWith('generated-plugin', 'contribute');
|
|
expect(container.textContent).not.toContain(
|
|
'Sent to the agent. The CLI run will continue in chat.',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('FileWorkspace tab reordering', () => {
|
|
it('persists a dragged file tab before the tab it is dropped on', () => {
|
|
const onTabsStateChange = vi.fn();
|
|
|
|
const container = renderWorkspace(
|
|
<FileWorkspace
|
|
projectId="project-1"
|
|
projectKind="prototype"
|
|
files={[
|
|
workspaceFile('analysis.html'),
|
|
workspaceFile('notes.md'),
|
|
workspaceFile('summary.html'),
|
|
]}
|
|
liveArtifacts={[]}
|
|
onRefreshFiles={vi.fn()}
|
|
isDeck={false}
|
|
tabsState={{
|
|
tabs: ['analysis.html', 'notes.md', 'summary.html'],
|
|
active: null,
|
|
}}
|
|
onTabsStateChange={onTabsStateChange}
|
|
/>,
|
|
);
|
|
|
|
const source = getTabByName(container, /summary\.html/i);
|
|
const target = getTabByName(container, /analysis\.html/i);
|
|
stubTabRect(target);
|
|
|
|
let dataTransfer = createDragDataTransfer();
|
|
act(() => {
|
|
dataTransfer = dispatchDragEvent(source, 'dragstart', dataTransfer);
|
|
});
|
|
act(() => dispatchDragEvent(target, 'dragover', dataTransfer));
|
|
act(() => dispatchDragEvent(target, 'drop', dataTransfer));
|
|
|
|
expect(onTabsStateChange).toHaveBeenCalledWith({
|
|
tabs: ['summary.html', 'analysis.html', 'notes.md'],
|
|
active: null,
|
|
});
|
|
});
|
|
|
|
it('persists a dragged file tab after the tab when dropped on its right side', () => {
|
|
const onTabsStateChange = vi.fn();
|
|
|
|
const container = renderWorkspace(
|
|
<FileWorkspace
|
|
projectId="project-1"
|
|
projectKind="prototype"
|
|
files={[
|
|
workspaceFile('analysis.html'),
|
|
workspaceFile('notes.md'),
|
|
workspaceFile('summary.html'),
|
|
]}
|
|
liveArtifacts={[]}
|
|
onRefreshFiles={vi.fn()}
|
|
isDeck={false}
|
|
tabsState={{
|
|
tabs: ['analysis.html', 'notes.md', 'summary.html'],
|
|
active: null,
|
|
}}
|
|
onTabsStateChange={onTabsStateChange}
|
|
/>,
|
|
);
|
|
|
|
const source = getTabByName(container, /analysis\.html/i);
|
|
const target = getTabByName(container, /summary\.html/i);
|
|
stubTabRect(target);
|
|
|
|
let dataTransfer = createDragDataTransfer();
|
|
act(() => {
|
|
dataTransfer = dispatchDragEvent(source, 'dragstart', dataTransfer);
|
|
});
|
|
act(() => dispatchDragEvent(target, 'drop', dataTransfer, 75));
|
|
|
|
expect(onTabsStateChange).toHaveBeenCalledWith({
|
|
tabs: ['notes.md', 'summary.html', 'analysis.html'],
|
|
active: null,
|
|
});
|
|
});
|
|
|
|
it('does not persist when a tab is dropped on itself', () => {
|
|
const onTabsStateChange = vi.fn();
|
|
|
|
const container = renderWorkspace(
|
|
<FileWorkspace
|
|
projectId="project-1"
|
|
projectKind="prototype"
|
|
files={[workspaceFile('analysis.html'), workspaceFile('notes.md')]}
|
|
liveArtifacts={[]}
|
|
onRefreshFiles={vi.fn()}
|
|
isDeck={false}
|
|
tabsState={{
|
|
tabs: ['analysis.html', 'notes.md'],
|
|
active: null,
|
|
}}
|
|
onTabsStateChange={onTabsStateChange}
|
|
/>,
|
|
);
|
|
|
|
const tab = getTabByName(container, /analysis\.html/i);
|
|
stubTabRect(tab);
|
|
|
|
let dataTransfer = createDragDataTransfer();
|
|
act(() => {
|
|
dataTransfer = dispatchDragEvent(tab, 'dragstart', dataTransfer);
|
|
});
|
|
act(() => dispatchDragEvent(tab, 'drop', dataTransfer));
|
|
|
|
expect(onTabsStateChange).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('clears the drop indicator when the drag leaves the tab bar', () => {
|
|
const container = renderWorkspace(
|
|
<FileWorkspace
|
|
projectId="project-1"
|
|
projectKind="prototype"
|
|
files={[workspaceFile('analysis.html'), workspaceFile('notes.md')]}
|
|
liveArtifacts={[]}
|
|
onRefreshFiles={vi.fn()}
|
|
isDeck={false}
|
|
tabsState={{
|
|
tabs: ['analysis.html', 'notes.md'],
|
|
active: null,
|
|
}}
|
|
onTabsStateChange={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
const source = getTabByName(container, /analysis\.html/i);
|
|
const target = getTabByName(container, /notes\.md/i);
|
|
const tabBar = container.querySelector<HTMLElement>('.ws-tabs-bar');
|
|
if (!tabBar) throw new Error('Could not find tabs bar');
|
|
stubTabRect(target);
|
|
|
|
let dataTransfer = createDragDataTransfer();
|
|
act(() => {
|
|
dataTransfer = dispatchDragEvent(source, 'dragstart', dataTransfer);
|
|
});
|
|
act(() => dispatchDragEvent(target, 'dragover', dataTransfer));
|
|
|
|
expect(target.className).toContain('drag-over-before');
|
|
|
|
act(() => dispatchDragEvent(tabBar, 'dragleave', dataTransfer, 0, document.body));
|
|
|
|
expect(target.className).not.toContain('drag-over-before');
|
|
expect(target.className).not.toContain('drag-over-after');
|
|
});
|
|
});
|
|
|
|
describe('FileWorkspace Questions tab', () => {
|
|
const discoveryForm = {
|
|
id: 'discovery',
|
|
title: 'Quick brief',
|
|
questions: [
|
|
{
|
|
id: 'platform',
|
|
label: 'Platform',
|
|
type: 'radio' as const,
|
|
options: [
|
|
{ label: 'Mobile', value: 'Mobile' },
|
|
{ label: 'Desktop web', value: 'Desktop web' },
|
|
],
|
|
required: true,
|
|
},
|
|
],
|
|
};
|
|
|
|
it('shows the Questions tab while the form is unanswered', () => {
|
|
render(
|
|
<FileWorkspace
|
|
projectId="project-1"
|
|
projectKind="prototype"
|
|
files={[]}
|
|
liveArtifacts={[]}
|
|
onRefreshFiles={vi.fn()}
|
|
isDeck={false}
|
|
tabsState={{ tabs: [], active: null }}
|
|
onTabsStateChange={vi.fn()}
|
|
questionForm={discoveryForm}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.getByTestId('questions-tab')).toBeTruthy();
|
|
});
|
|
|
|
it('closes the Questions preview after submit, then lets the answered form reopen', async () => {
|
|
const baseProps: React.ComponentProps<typeof FileWorkspace> = {
|
|
projectId: 'project-1',
|
|
projectKind: 'prototype',
|
|
files: [],
|
|
liveArtifacts: [],
|
|
onRefreshFiles: vi.fn(),
|
|
isDeck: false,
|
|
tabsState: { tabs: [], active: null },
|
|
onTabsStateChange: vi.fn(),
|
|
questionForm: discoveryForm,
|
|
focusQuestionsRequest: { nonce: 1 },
|
|
};
|
|
const { rerender } = render(<FileWorkspace {...baseProps} />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Quick brief')).toBeTruthy();
|
|
});
|
|
|
|
rerender(
|
|
<FileWorkspace
|
|
{...baseProps}
|
|
questionFormSubmittedAnswers={{ platform: 'Mobile' }}
|
|
/>,
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.queryByText('Quick brief')).toBeNull();
|
|
});
|
|
expect(screen.getByTestId('questions-tab')).toBeTruthy();
|
|
|
|
fireEvent.click(screen.getByTestId('questions-tab'));
|
|
expect(screen.getByText('Quick brief')).toBeTruthy();
|
|
expect(screen.getByText('Mobile')).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
describe('projectSplitClassName', () => {
|
|
it('marks the project split as focused so the chat pane can collapse globally', () => {
|
|
expect(projectSplitClassName(false)).toBe('split');
|
|
expect(projectSplitClassName(true)).toBe('split split-focus');
|
|
});
|
|
|
|
it('uses CSS variables for split widths so pointer resize can update layout without rerendering workspace content', () => {
|
|
expect(projectSplitStyle(false, 512, 'minmax(420px, 1fr)')).toEqual({
|
|
'--project-chat-panel-width': '512px',
|
|
'--project-workspace-panel-track': 'minmax(420px, 1fr)',
|
|
gridTemplateColumns: '512px 8px minmax(420px, 1fr)',
|
|
});
|
|
expect(projectSplitStyle(true, 512, 'minmax(420px, 1fr)')).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('scrollWorkspaceTabsWithWheel', () => {
|
|
function makeTabBar(scrollLeft: number, scrollWidth = 400, clientWidth = 200) {
|
|
return { scrollLeft, scrollWidth, clientWidth } as HTMLDivElement;
|
|
}
|
|
|
|
function makeClampedTabBar(scrollLeft: number, scrollWidth = 400, clientWidth = 200) {
|
|
let value = scrollLeft;
|
|
return {
|
|
scrollWidth,
|
|
clientWidth,
|
|
get scrollLeft() {
|
|
return value;
|
|
},
|
|
set scrollLeft(next: number) {
|
|
value = Math.min(Math.max(next, 0), scrollWidth - clientWidth);
|
|
},
|
|
} as HTMLDivElement;
|
|
}
|
|
|
|
it('maps vertical mouse wheel movement to horizontal tab scrolling', () => {
|
|
const preventDefault = vi.fn();
|
|
const currentTarget = makeTabBar(12);
|
|
const event = {
|
|
ctrlKey: false,
|
|
deltaMode: 0,
|
|
deltaX: 0,
|
|
deltaY: 40,
|
|
preventDefault,
|
|
} as unknown as WheelEvent;
|
|
|
|
scrollWorkspaceTabsWithWheel(currentTarget, event);
|
|
|
|
expect(currentTarget.scrollLeft).toBe(52);
|
|
expect(preventDefault).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('supports reverse vertical wheel movement', () => {
|
|
const preventDefault = vi.fn();
|
|
const currentTarget = makeTabBar(52);
|
|
const event = {
|
|
ctrlKey: false,
|
|
deltaMode: 0,
|
|
deltaX: 0,
|
|
deltaY: -40,
|
|
preventDefault,
|
|
} as unknown as WheelEvent;
|
|
|
|
scrollWorkspaceTabsWithWheel(currentTarget, event);
|
|
|
|
expect(currentTarget.scrollLeft).toBe(12);
|
|
expect(preventDefault).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('normalizes line-based wheel deltas to useful pixel movement', () => {
|
|
const preventDefault = vi.fn();
|
|
const currentTarget = makeTabBar(12);
|
|
const event = {
|
|
ctrlKey: false,
|
|
deltaMode: 1,
|
|
deltaX: 0,
|
|
deltaY: 3,
|
|
preventDefault,
|
|
} as unknown as WheelEvent;
|
|
|
|
scrollWorkspaceTabsWithWheel(currentTarget, event);
|
|
|
|
expect(currentTarget.scrollLeft).toBe(60);
|
|
expect(preventDefault).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('normalizes page-based wheel deltas to useful pixel movement', () => {
|
|
const preventDefault = vi.fn();
|
|
const currentTarget = makeTabBar(12, 600, 200);
|
|
const event = {
|
|
ctrlKey: false,
|
|
deltaMode: 2,
|
|
deltaX: 0,
|
|
deltaY: 1,
|
|
preventDefault,
|
|
} as unknown as WheelEvent;
|
|
|
|
scrollWorkspaceTabsWithWheel(currentTarget, event);
|
|
|
|
expect(currentTarget.scrollLeft).toBe(172);
|
|
expect(preventDefault).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('leaves native horizontal wheel gestures alone', () => {
|
|
const preventDefault = vi.fn();
|
|
const currentTarget = makeTabBar(12);
|
|
const event = {
|
|
ctrlKey: false,
|
|
deltaMode: 0,
|
|
deltaX: 50,
|
|
deltaY: 10,
|
|
preventDefault,
|
|
} as unknown as WheelEvent;
|
|
|
|
scrollWorkspaceTabsWithWheel(currentTarget, event);
|
|
|
|
expect(currentTarget.scrollLeft).toBe(12);
|
|
expect(preventDefault).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('leaves ctrl-wheel zoom gestures alone', () => {
|
|
const preventDefault = vi.fn();
|
|
const currentTarget = makeTabBar(12);
|
|
const event = {
|
|
ctrlKey: true,
|
|
deltaMode: 0,
|
|
deltaX: 0,
|
|
deltaY: 40,
|
|
preventDefault,
|
|
} as unknown as WheelEvent;
|
|
|
|
scrollWorkspaceTabsWithWheel(currentTarget, event);
|
|
|
|
expect(currentTarget.scrollLeft).toBe(12);
|
|
expect(preventDefault).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('does not intercept vertical wheel movement when tabs do not overflow', () => {
|
|
const preventDefault = vi.fn();
|
|
const currentTarget = makeTabBar(12, 200, 200);
|
|
const event = {
|
|
ctrlKey: false,
|
|
deltaMode: 0,
|
|
deltaX: 0,
|
|
deltaY: 40,
|
|
preventDefault,
|
|
} as unknown as WheelEvent;
|
|
|
|
scrollWorkspaceTabsWithWheel(currentTarget, event);
|
|
|
|
expect(currentTarget.scrollLeft).toBe(12);
|
|
expect(preventDefault).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('lets page scrolling continue when the tab bar is already at the wheel boundary', () => {
|
|
const preventDefault = vi.fn();
|
|
const currentTarget = makeClampedTabBar(200, 400, 200);
|
|
const event = {
|
|
ctrlKey: false,
|
|
deltaMode: 0,
|
|
deltaX: 0,
|
|
deltaY: 40,
|
|
preventDefault,
|
|
} as unknown as WheelEvent;
|
|
|
|
scrollWorkspaceTabsWithWheel(currentTarget, event);
|
|
|
|
expect(currentTarget.scrollLeft).toBe(200);
|
|
expect(preventDefault).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('FileWorkspace sketch save', () => {
|
|
it('opens a persisted sketch through the editor path without rendering the static preview first', async () => {
|
|
const file: ProjectFile = {
|
|
name: 'test.sketch.json',
|
|
path: 'test.sketch.json',
|
|
type: 'file',
|
|
size: 100,
|
|
mtime: 1700000000,
|
|
kind: 'sketch',
|
|
mime: 'application/json',
|
|
};
|
|
|
|
mockedFetchProjectFileText.mockResolvedValue(
|
|
JSON.stringify({
|
|
type: 'excalidraw',
|
|
version: 2,
|
|
elements: [{ id: 'box', type: 'rectangle', isDeleted: false }],
|
|
appState: { viewBackgroundColor: '#ffffff' },
|
|
files: {},
|
|
}),
|
|
);
|
|
|
|
const { container } = render(
|
|
<FileWorkspace
|
|
projectId="project-1"
|
|
projectKind="prototype"
|
|
files={[file]}
|
|
liveArtifacts={[]}
|
|
onRefreshFiles={vi.fn()}
|
|
isDeck={false}
|
|
tabsState={{ tabs: ['test.sketch.json'], active: 'test.sketch.json' }}
|
|
onTabsStateChange={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.getByText('Loading sketch…')).toBeTruthy();
|
|
expect(container.querySelector('[data-testid="sketch-preview-svg"]')).toBeNull();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('excalidraw')).toBeTruthy();
|
|
});
|
|
|
|
expect(mockedFetchProjectFileText).toHaveBeenCalledTimes(1);
|
|
expect(mockedFetchProjectFileText).toHaveBeenCalledWith('project-1', 'test.sketch.json');
|
|
expect(container.querySelector('[data-testid="sketch-preview-svg"]')).toBeNull();
|
|
});
|
|
|
|
it('preloads persisted sketches before the tab is opened', async () => {
|
|
const file: ProjectFile = {
|
|
name: 'test.sketch.json',
|
|
path: 'test.sketch.json',
|
|
type: 'file',
|
|
size: 100,
|
|
mtime: 1700000000,
|
|
kind: 'sketch',
|
|
mime: 'application/json',
|
|
};
|
|
|
|
mockedFetchProjectFileText.mockResolvedValue(
|
|
JSON.stringify({
|
|
type: 'excalidraw',
|
|
version: 2,
|
|
elements: [{ id: 'box', type: 'rectangle', isDeleted: false }],
|
|
appState: { viewBackgroundColor: '#ffffff' },
|
|
files: {},
|
|
}),
|
|
);
|
|
|
|
const baseProps: React.ComponentProps<typeof FileWorkspace> = {
|
|
projectId: 'project-1',
|
|
projectKind: 'prototype',
|
|
files: [file],
|
|
liveArtifacts: [],
|
|
onRefreshFiles: vi.fn(),
|
|
isDeck: false,
|
|
tabsState: { tabs: [], active: null },
|
|
onTabsStateChange: vi.fn(),
|
|
};
|
|
const { rerender } = render(
|
|
<FileWorkspace {...baseProps} />,
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(mockedFetchProjectFileText).toHaveBeenCalledWith('project-1', 'test.sketch.json');
|
|
});
|
|
|
|
rerender(
|
|
<FileWorkspace
|
|
{...baseProps}
|
|
tabsState={{ tabs: ['test.sketch.json'], active: 'test.sketch.json' }}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.queryByText('Loading sketch…')).toBeNull();
|
|
expect(screen.getByTestId('excalidraw')).toBeTruthy();
|
|
expect(mockedFetchProjectFileText).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('keeps saving state visible for at least 500ms', async () => {
|
|
// Simulate user doing some edits in the workspace
|
|
const file: ProjectFile = {
|
|
name: 'test.sketch.json',
|
|
path: 'test.sketch.json',
|
|
type: 'file',
|
|
size: 100,
|
|
mtime: 1700000000,
|
|
kind: 'sketch',
|
|
mime: 'application/json',
|
|
};
|
|
|
|
mockedFetchProjectFileText.mockResolvedValue(
|
|
JSON.stringify({
|
|
version: 1,
|
|
items: [
|
|
{ kind: 'pen', points: [{ x: 10, y: 20 }], color: '#000', size: 2 },
|
|
],
|
|
}),
|
|
);
|
|
mockedWriteProjectTextFile.mockResolvedValue(file);
|
|
|
|
render(
|
|
<FileWorkspace
|
|
projectId="project-1"
|
|
projectKind="prototype"
|
|
files={[file]}
|
|
liveArtifacts={[]}
|
|
onRefreshFiles={vi.fn()}
|
|
isDeck={false}
|
|
tabsState={{ tabs: ['test.sketch.json'], active: 'test.sketch.json' }}
|
|
onTabsStateChange={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(document.querySelector('canvas')).not.toBeNull();
|
|
});
|
|
|
|
vi.useFakeTimers();
|
|
|
|
const btn = screen.getByText('Save') as HTMLButtonElement;
|
|
expect(btn.disabled).toBe(false);
|
|
|
|
await act(async () => {
|
|
fireEvent.click(btn);
|
|
});
|
|
|
|
expect(btn.textContent).toBe('Saving…');
|
|
expect(btn.disabled).toBe(true);
|
|
|
|
// Before the 500ms floor is reached, still saving
|
|
await act(async () => {
|
|
vi.advanceTimersByTime(400);
|
|
});
|
|
expect(btn.textContent).toBe('Saving…');
|
|
expect(btn.disabled).toBe(true);
|
|
|
|
// After 500ms total, saving should end and the checkmark should appear
|
|
await act(async () => {
|
|
vi.advanceTimersByTime(100);
|
|
});
|
|
expect(btn.textContent).not.toBe('Saving…');
|
|
expect(btn.querySelector('svg')).not.toBeNull();
|
|
});
|
|
|
|
it('autosaves an empty sketch when clear happens before a pending sketch autosave', async () => {
|
|
const file: ProjectFile = {
|
|
name: 'test.sketch.json',
|
|
path: 'test.sketch.json',
|
|
type: 'file',
|
|
size: 100,
|
|
mtime: 1700000000,
|
|
kind: 'sketch',
|
|
mime: 'application/json',
|
|
};
|
|
|
|
mockedFetchProjectFileText.mockResolvedValue(
|
|
JSON.stringify({
|
|
type: 'excalidraw',
|
|
version: 2,
|
|
elements: [],
|
|
appState: { viewBackgroundColor: '#ffffff' },
|
|
files: {},
|
|
}),
|
|
);
|
|
mockedWriteProjectTextFile.mockResolvedValue(file);
|
|
|
|
render(
|
|
<FileWorkspace
|
|
projectId="project-1"
|
|
projectKind="prototype"
|
|
files={[file]}
|
|
liveArtifacts={[]}
|
|
onRefreshFiles={vi.fn()}
|
|
isDeck={false}
|
|
tabsState={{ tabs: ['test.sketch.json'], active: 'test.sketch.json' }}
|
|
onTabsStateChange={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('excalidraw')).toBeTruthy();
|
|
});
|
|
|
|
vi.useFakeTimers();
|
|
const props = excalidrawWorkspaceMock.lastProps;
|
|
if (!props?.onChange) throw new Error('expected Excalidraw onChange');
|
|
|
|
act(() => {
|
|
props.onChange(
|
|
[{ id: 'baseline', type: 'rectangle', isDeleted: false }],
|
|
{ viewBackgroundColor: '#ffffff' },
|
|
{},
|
|
);
|
|
props.onChange(
|
|
[{ id: 'drawn-before-clear', type: 'rectangle', isDeleted: false }],
|
|
{ viewBackgroundColor: '#ffffff' },
|
|
{},
|
|
);
|
|
});
|
|
|
|
const clearButton = screen.getByRole('button', { name: 'Clear' }) as HTMLButtonElement;
|
|
expect(clearButton.disabled).toBe(false);
|
|
act(() => {
|
|
fireEvent.click(clearButton);
|
|
});
|
|
|
|
await act(async () => {
|
|
vi.advanceTimersByTime(800);
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(mockedWriteProjectTextFile).toHaveBeenCalledTimes(1);
|
|
const savedText = mockedWriteProjectTextFile.mock.calls[0]?.[2];
|
|
if (typeof savedText !== 'string') throw new Error('expected saved sketch JSON');
|
|
const saved = JSON.parse(savedText) as { elements?: unknown[] };
|
|
expect(saved.elements).toEqual([]);
|
|
});
|
|
|
|
it('serializes overlapping sketch autosaves so the latest scene wins', async () => {
|
|
const file: ProjectFile = {
|
|
name: 'test.sketch.json',
|
|
path: 'test.sketch.json',
|
|
type: 'file',
|
|
size: 100,
|
|
mtime: 1700000000,
|
|
kind: 'sketch',
|
|
mime: 'application/json',
|
|
};
|
|
let resolveFirstSave!: (file: ProjectFile) => void;
|
|
const firstSave = new Promise<ProjectFile>((resolve) => {
|
|
resolveFirstSave = resolve;
|
|
});
|
|
const savedTexts: string[] = [];
|
|
|
|
mockedFetchProjectFileText.mockResolvedValue(
|
|
JSON.stringify({
|
|
type: 'excalidraw',
|
|
version: 2,
|
|
elements: [],
|
|
appState: { viewBackgroundColor: '#ffffff' },
|
|
files: {},
|
|
}),
|
|
);
|
|
mockedWriteProjectTextFile.mockImplementation(async (_projectId, _name, text) => {
|
|
savedTexts.push(text);
|
|
return savedTexts.length === 1 ? firstSave : file;
|
|
});
|
|
|
|
render(
|
|
<FileWorkspace
|
|
projectId="project-1"
|
|
projectKind="prototype"
|
|
files={[file]}
|
|
liveArtifacts={[]}
|
|
onRefreshFiles={vi.fn()}
|
|
isDeck={false}
|
|
tabsState={{ tabs: ['test.sketch.json'], active: 'test.sketch.json' }}
|
|
onTabsStateChange={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('excalidraw')).toBeTruthy();
|
|
});
|
|
|
|
vi.useFakeTimers();
|
|
const props = excalidrawWorkspaceMock.lastProps;
|
|
if (!props?.onChange) throw new Error('expected Excalidraw onChange');
|
|
|
|
act(() => {
|
|
props.onChange(
|
|
[{ id: 'baseline', type: 'rectangle', isDeleted: false }],
|
|
{ viewBackgroundColor: '#ffffff' },
|
|
{},
|
|
);
|
|
props.onChange(
|
|
[{ id: 'autosave-a', type: 'rectangle', isDeleted: false }],
|
|
{ viewBackgroundColor: '#ffffff' },
|
|
{},
|
|
);
|
|
});
|
|
await act(async () => {
|
|
vi.advanceTimersByTime(800);
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(mockedWriteProjectTextFile).toHaveBeenCalledTimes(1);
|
|
|
|
act(() => {
|
|
props.onChange(
|
|
[{ id: 'autosave-b', type: 'rectangle', isDeleted: false }],
|
|
{ viewBackgroundColor: '#ffffff' },
|
|
{},
|
|
);
|
|
});
|
|
await act(async () => {
|
|
vi.advanceTimersByTime(800);
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(mockedWriteProjectTextFile).toHaveBeenCalledTimes(1);
|
|
|
|
await act(async () => {
|
|
resolveFirstSave(file);
|
|
await firstSave;
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(mockedWriteProjectTextFile).toHaveBeenCalledTimes(2);
|
|
const firstSaved = JSON.parse(savedTexts[0]!) as { elements?: Array<{ id?: string }> };
|
|
const secondSaved = JSON.parse(savedTexts[1]!) as { elements?: Array<{ id?: string }> };
|
|
expect(firstSaved.elements?.[0]?.id).toBe('autosave-a');
|
|
expect(secondSaved.elements?.[0]?.id).toBe('autosave-b');
|
|
});
|
|
|
|
it('does not wire Excalidraw library item changes into sketch autosave', async () => {
|
|
const file: ProjectFile = {
|
|
name: 'test.sketch.json',
|
|
path: 'test.sketch.json',
|
|
type: 'file',
|
|
size: 100,
|
|
mtime: 1700000000,
|
|
kind: 'sketch',
|
|
mime: 'application/json',
|
|
};
|
|
|
|
mockedFetchProjectFileText.mockResolvedValue(
|
|
JSON.stringify({
|
|
type: 'excalidraw',
|
|
version: 2,
|
|
elements: [],
|
|
appState: { viewBackgroundColor: '#ffffff' },
|
|
files: {},
|
|
libraryItems: [],
|
|
}),
|
|
);
|
|
mockedWriteProjectTextFile.mockResolvedValue(file);
|
|
|
|
render(
|
|
<FileWorkspace
|
|
projectId="project-1"
|
|
projectKind="prototype"
|
|
files={[file]}
|
|
liveArtifacts={[]}
|
|
onRefreshFiles={vi.fn()}
|
|
isDeck={false}
|
|
tabsState={{ tabs: ['test.sketch.json'], active: 'test.sketch.json' }}
|
|
onTabsStateChange={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('excalidraw')).toBeTruthy();
|
|
});
|
|
|
|
const props = excalidrawWorkspaceMock.lastProps;
|
|
expect(props?.onLibraryChange).toBeUndefined();
|
|
expect(props?.initialData?.libraryItems).toBeUndefined();
|
|
expect(mockedWriteProjectTextFile).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('flushes pending sketch autosaves when the workspace unmounts before debounce', async () => {
|
|
const file: ProjectFile = {
|
|
name: 'test.sketch.json',
|
|
path: 'test.sketch.json',
|
|
type: 'file',
|
|
size: 100,
|
|
mtime: 1700000000,
|
|
kind: 'sketch',
|
|
mime: 'application/json',
|
|
};
|
|
|
|
mockedFetchProjectFileText.mockResolvedValue(
|
|
JSON.stringify({
|
|
type: 'excalidraw',
|
|
version: 2,
|
|
elements: [],
|
|
appState: { viewBackgroundColor: '#ffffff' },
|
|
files: {},
|
|
libraryItems: [],
|
|
}),
|
|
);
|
|
mockedWriteProjectTextFile.mockResolvedValue(file);
|
|
|
|
const { unmount } = render(
|
|
<FileWorkspace
|
|
projectId="project-1"
|
|
projectKind="prototype"
|
|
files={[file]}
|
|
liveArtifacts={[]}
|
|
onRefreshFiles={vi.fn()}
|
|
isDeck={false}
|
|
tabsState={{ tabs: ['test.sketch.json'], active: 'test.sketch.json' }}
|
|
onTabsStateChange={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('excalidraw')).toBeTruthy();
|
|
});
|
|
|
|
const props = excalidrawWorkspaceMock.lastProps;
|
|
if (!props?.onChange) throw new Error('expected Excalidraw onChange');
|
|
|
|
act(() => {
|
|
props.onChange([], { viewBackgroundColor: '#ffffff' }, {});
|
|
props.onChange(
|
|
[{ id: 'scene-before-unmount', type: 'rectangle', isDeleted: false }],
|
|
{ viewBackgroundColor: '#ffffff' },
|
|
{},
|
|
);
|
|
unmount();
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(mockedWriteProjectTextFile).toHaveBeenCalledTimes(1);
|
|
});
|
|
const savedText = mockedWriteProjectTextFile.mock.calls[0]?.[2];
|
|
if (typeof savedText !== 'string') throw new Error('expected saved sketch JSON');
|
|
const saved = JSON.parse(savedText) as { elements?: Array<{ id?: string }>; libraryItems?: unknown[] };
|
|
expect(saved.elements?.map((item) => item.id)).toEqual(['scene-before-unmount']);
|
|
expect(saved.libraryItems).toBeUndefined();
|
|
});
|
|
|
|
it('preserves a newer sketch scene while its autosave is still debouncing', async () => {
|
|
const file: ProjectFile = {
|
|
name: 'test.sketch.json',
|
|
path: 'test.sketch.json',
|
|
type: 'file',
|
|
size: 100,
|
|
mtime: 1700000000,
|
|
kind: 'sketch',
|
|
mime: 'application/json',
|
|
};
|
|
const writes: Array<{
|
|
text: string;
|
|
resolve: (file: ProjectFile | null) => void;
|
|
}> = [];
|
|
|
|
mockedFetchProjectFileText.mockResolvedValue(
|
|
JSON.stringify({
|
|
type: 'excalidraw',
|
|
version: 2,
|
|
elements: [],
|
|
appState: { viewBackgroundColor: '#ffffff' },
|
|
files: {},
|
|
}),
|
|
);
|
|
mockedWriteProjectTextFile.mockImplementation((_projectId, _name, text) => new Promise((resolve) => {
|
|
if (typeof text !== 'string') throw new Error('expected saved sketch JSON');
|
|
writes.push({ text, resolve });
|
|
}));
|
|
|
|
render(
|
|
<FileWorkspace
|
|
projectId="project-1"
|
|
projectKind="prototype"
|
|
files={[file]}
|
|
liveArtifacts={[]}
|
|
onRefreshFiles={vi.fn()}
|
|
isDeck={false}
|
|
tabsState={{ tabs: ['test.sketch.json'], active: 'test.sketch.json' }}
|
|
onTabsStateChange={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('excalidraw')).toBeTruthy();
|
|
});
|
|
|
|
vi.useFakeTimers();
|
|
const props = excalidrawWorkspaceMock.lastProps;
|
|
if (!props?.onChange) throw new Error('expected Excalidraw onChange');
|
|
|
|
act(() => {
|
|
props.onChange(
|
|
[{ id: 'baseline', type: 'rectangle', isDeleted: false }],
|
|
{ viewBackgroundColor: '#ffffff' },
|
|
{},
|
|
);
|
|
props.onChange(
|
|
[{ id: 'older-scene', type: 'rectangle', isDeleted: false }],
|
|
{ viewBackgroundColor: '#ffffff' },
|
|
{},
|
|
);
|
|
});
|
|
|
|
await act(async () => {
|
|
vi.advanceTimersByTime(800);
|
|
await Promise.resolve();
|
|
});
|
|
expect(writes).toHaveLength(1);
|
|
|
|
act(() => {
|
|
props.onChange(
|
|
[{ id: 'latest-scene', type: 'rectangle', isDeleted: false }],
|
|
{ viewBackgroundColor: '#ffffff' },
|
|
{},
|
|
);
|
|
});
|
|
|
|
await act(async () => {
|
|
writes[0]?.resolve(file);
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(writes).toHaveLength(2);
|
|
|
|
await act(async () => {
|
|
writes[1]?.resolve(file);
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
});
|
|
|
|
const firstSaved = JSON.parse(writes[0]?.text ?? '{}') as { elements?: Array<{ id?: string }> };
|
|
const latestSaved = JSON.parse(writes[1]?.text ?? '{}') as { elements?: Array<{ id?: string }> };
|
|
expect(firstSaved.elements?.map((element) => element.id)).toEqual(['older-scene']);
|
|
expect(latestSaved.elements?.map((element) => element.id)).toEqual(['latest-scene']);
|
|
});
|
|
});
|
|
|
|
describe('FileWorkspace add-module menu', () => {
|
|
it('opens the add-module menu with Browser available and Terminal hidden', () => {
|
|
render(
|
|
<FileWorkspace
|
|
projectId="project-1"
|
|
projectKind="prototype"
|
|
files={[]}
|
|
liveArtifacts={[]}
|
|
onRefreshFiles={vi.fn()}
|
|
isDeck={false}
|
|
tabsState={{ tabs: [], active: null }}
|
|
onTabsStateChange={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
const addButton = screen.getByTestId('workspace-add-tab');
|
|
expect(addButton.getAttribute('aria-expanded')).toBe('false');
|
|
|
|
act(() => {
|
|
fireEvent.click(addButton);
|
|
});
|
|
|
|
expect(addButton.getAttribute('aria-expanded')).toBe('true');
|
|
const browserItem = screen.getByRole('button', { name: /New Browser/ });
|
|
const menu = browserItem.closest('[data-testid="tab-launcher-menu"]');
|
|
expect(menu).not.toBeNull();
|
|
expect(screen.queryByRole('button', { name: /New Terminal/ })).toBeNull();
|
|
|
|
// The tab strip is a horizontal scroll container that also clips
|
|
// vertically, so the "+" button lives outside it in `.ws-add-tab`
|
|
// and the launcher menu is portaled to <body> -- neither can be clipped
|
|
// by the scrolling bar.
|
|
const tabsBar = document.querySelector('.ws-tabs-bar');
|
|
expect(tabsBar).not.toBeNull();
|
|
expect(tabsBar!.contains(addButton)).toBe(false);
|
|
expect(tabsBar!.contains(menu)).toBe(false);
|
|
expect(addButton.closest('.ws-add-tab')).not.toBeNull();
|
|
});
|
|
|
|
it('orders launcher sections as create new, files, then tabs in one scroll body', () => {
|
|
render(
|
|
<FileWorkspace
|
|
projectId="project-1"
|
|
projectKind="prototype"
|
|
files={[workspaceFile('cover.html')]}
|
|
liveArtifacts={[]}
|
|
onRefreshFiles={vi.fn()}
|
|
isDeck={false}
|
|
tabsState={{
|
|
tabs: ['cover.html'],
|
|
active: 'cover.html',
|
|
browserTabs: [
|
|
{
|
|
id: '__browser__:1',
|
|
label: 'Reference Browser',
|
|
title: 'Behance',
|
|
url: 'https://www.behance.net/',
|
|
},
|
|
],
|
|
}}
|
|
onTabsStateChange={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
act(() => {
|
|
fireEvent.click(screen.getByTestId('workspace-add-tab'));
|
|
});
|
|
|
|
const scrollBody = screen.getByTestId('tab-launcher-scroll-body');
|
|
const createHeader = screen.getByText('Create new');
|
|
const fileHeader = screen.getByText('Open a file');
|
|
const tabsHeader = screen.getByText('Open tabs');
|
|
|
|
expect(scrollBody.contains(createHeader)).toBe(true);
|
|
expect(scrollBody.contains(fileHeader)).toBe(true);
|
|
expect(scrollBody.contains(tabsHeader)).toBe(true);
|
|
expect(createHeader.compareDocumentPosition(fileHeader) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
|
expect(fileHeader.compareDocumentPosition(tabsHeader) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
|
});
|
|
|
|
it('adds a new browser tab every time the Browser module is selected', () => {
|
|
const onTabsStateChange = vi.fn();
|
|
render(
|
|
<FileWorkspace
|
|
projectId="project-1"
|
|
projectKind="prototype"
|
|
files={[]}
|
|
liveArtifacts={[]}
|
|
onRefreshFiles={vi.fn()}
|
|
isDeck={false}
|
|
tabsState={{ tabs: [], active: null }}
|
|
onTabsStateChange={onTabsStateChange}
|
|
/>,
|
|
);
|
|
|
|
const addButton = screen.getByTestId('workspace-add-tab');
|
|
for (let i = 0; i < 3; i += 1) {
|
|
act(() => {
|
|
fireEvent.click(addButton);
|
|
});
|
|
act(() => {
|
|
fireEvent.click(screen.getByRole('button', { name: /New Browser/ }));
|
|
});
|
|
}
|
|
|
|
const browserTabs = screen
|
|
.getAllByRole('tab')
|
|
.filter((tab) => /Browser(?: \d+)?/.test(tab.textContent ?? ''));
|
|
expect(browserTabs).toHaveLength(3);
|
|
expect(browserTabs.map((tab) => tab.textContent?.trim())).toEqual([
|
|
'Browser',
|
|
'Browser 2',
|
|
'Browser 3',
|
|
]);
|
|
expect(browserTabs[2]!.getAttribute('aria-selected')).toBe('true');
|
|
|
|
const browserPanels = screen
|
|
.getAllByTestId('design-browser-panel')
|
|
.map((panel) => panel.closest('.ws-browser-panel'));
|
|
expect(browserPanels).toHaveLength(3);
|
|
expect(browserPanels[0]!.className).not.toContain('active');
|
|
expect(browserPanels[1]!.className).not.toContain('active');
|
|
expect(browserPanels[2]!.className).toContain('active');
|
|
expect(onTabsStateChange).toHaveBeenLastCalledWith({
|
|
tabs: [],
|
|
active: '__browser__:3',
|
|
browserTabs: [
|
|
{ id: '__browser__:1', insertAfter: '__design_files__', label: 'Browser' },
|
|
{ id: '__browser__:2', insertAfter: '__browser__:1', label: 'Browser 2' },
|
|
{ id: '__browser__:3', insertAfter: '__browser__:2', label: 'Browser 3' },
|
|
],
|
|
});
|
|
});
|
|
|
|
it('restores persisted browser tabs with their active URL state', () => {
|
|
render(
|
|
<FileWorkspace
|
|
projectId="project-1"
|
|
projectKind="prototype"
|
|
files={[]}
|
|
liveArtifacts={[]}
|
|
onRefreshFiles={vi.fn()}
|
|
isDeck={false}
|
|
tabsState={{
|
|
tabs: [],
|
|
active: '__browser__:2',
|
|
browserTabs: [
|
|
{
|
|
id: '__browser__:2',
|
|
insertAfter: '__design_files__',
|
|
label: 'Browser 2',
|
|
title: 'SVG Repo',
|
|
url: 'https://www.svgrepo.com/',
|
|
iconUrl: 'https://www.svgrepo.com/favicon.ico',
|
|
},
|
|
],
|
|
}}
|
|
onTabsStateChange={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
const restoredTab = screen.getByRole('tab', { name: /SVG Repo/ });
|
|
expect(restoredTab.getAttribute('aria-selected')).toBe('true');
|
|
const browserPanel = screen.getByTestId('design-browser-panel');
|
|
expect(browserPanel.dataset.initialUrl).toBe('https://www.svgrepo.com/');
|
|
expect(browserPanel.dataset.initialTitle).toBe('SVG Repo');
|
|
expect(browserPanel.dataset.initialIconUrl).toBe('https://www.svgrepo.com/favicon.ico');
|
|
});
|
|
|
|
it('persists browser-tab removal when a browser tab is closed', () => {
|
|
const onTabsStateChange = vi.fn();
|
|
render(
|
|
<FileWorkspace
|
|
projectId="project-1"
|
|
projectKind="prototype"
|
|
files={[]}
|
|
liveArtifacts={[]}
|
|
onRefreshFiles={vi.fn()}
|
|
isDeck={false}
|
|
tabsState={{
|
|
tabs: [],
|
|
active: '__browser__:1',
|
|
browserTabs: [
|
|
{ id: '__browser__:1', insertAfter: '__design_files__', label: 'Browser' },
|
|
],
|
|
}}
|
|
onTabsStateChange={onTabsStateChange}
|
|
/>,
|
|
);
|
|
|
|
const restoredTab = screen.getByRole('tab', { name: /Browser/ });
|
|
const closeButton = restoredTab.querySelector<HTMLButtonElement>('.ws-tab-close');
|
|
expect(closeButton).not.toBeNull();
|
|
act(() => {
|
|
fireEvent.click(closeButton!);
|
|
});
|
|
|
|
expect(screen.queryByRole('tab', { name: /Browser/ })).toBeNull();
|
|
expect(screen.queryByTestId('design-browser-panel')).toBeNull();
|
|
expect(onTabsStateChange).toHaveBeenLastCalledWith({
|
|
tabs: [],
|
|
active: '__design_files__',
|
|
});
|
|
});
|
|
|
|
it('keeps the pinned brand browser tab mounted while another tab is active', () => {
|
|
const browserTabs = [
|
|
{
|
|
id: '__browser__:1',
|
|
label: 'Browser',
|
|
title: 'The Economist',
|
|
url: 'https://www.economist.com/',
|
|
},
|
|
];
|
|
|
|
// Without the pin, a browser tab that was never activated this session is
|
|
// not mounted while a file tab is active, so its live (post-wall) DOM can't
|
|
// be read — this is the failure the pin fixes.
|
|
const { unmount } = render(
|
|
<FileWorkspace
|
|
projectId="project-1"
|
|
projectKind="prototype"
|
|
files={[workspaceFile('brand.html')]}
|
|
liveArtifacts={[]}
|
|
onRefreshFiles={vi.fn()}
|
|
isDeck={false}
|
|
tabsState={{ tabs: ['brand.html'], active: 'brand.html', browserTabs }}
|
|
onTabsStateChange={vi.fn()}
|
|
/>,
|
|
);
|
|
expect(screen.queryByTestId('design-browser-panel')).toBeNull();
|
|
unmount();
|
|
|
|
// With the pin, the same inactive browser tab stays mounted so the chat
|
|
// "Continue extraction" handler can read its post-wall DOM.
|
|
render(
|
|
<FileWorkspace
|
|
projectId="project-1"
|
|
projectKind="prototype"
|
|
files={[workspaceFile('brand.html')]}
|
|
liveArtifacts={[]}
|
|
onRefreshFiles={vi.fn()}
|
|
isDeck={false}
|
|
tabsState={{ tabs: ['brand.html'], active: 'brand.html', browserTabs }}
|
|
pinnedBrowserTabId="__browser__:1"
|
|
onTabsStateChange={vi.fn()}
|
|
/>,
|
|
);
|
|
const panel = screen.getByTestId('design-browser-panel');
|
|
expect(panel.dataset.initialTitle).toBe('The Economist');
|
|
});
|
|
|
|
});
|
|
|
|
describe('FileWorkspace empty-project generation contract', () => {
|
|
function assistantMessage(runStatus: 'running' | 'failed'): ChatMessage {
|
|
return {
|
|
id: `msg-${runStatus}`,
|
|
role: 'assistant',
|
|
content: '',
|
|
createdAt: 1700000000,
|
|
startedAt: 1700000000,
|
|
runId: `run-${runStatus}`,
|
|
runStatus,
|
|
agentId: 'claude',
|
|
preTurnFileNames: [],
|
|
events: [{ kind: 'status', label: runStatus === 'failed' ? 'error' : 'thinking' }],
|
|
};
|
|
}
|
|
|
|
// The generation-preview card / transient `generating-tab` were removed: an
|
|
// empty project keeps the plain `design-files-empty` placeholder for running
|
|
// AND failed turns, with no card hijacking the surface.
|
|
it.each(['running', 'failed'] as const)(
|
|
'keeps the design-files empty placeholder and shows no generation card for a %s turn',
|
|
(runStatus) => {
|
|
render(
|
|
<FileWorkspace
|
|
projectId="project-1"
|
|
projectKind="prototype"
|
|
files={[]}
|
|
liveArtifacts={[]}
|
|
onRefreshFiles={vi.fn()}
|
|
isDeck={false}
|
|
streaming={runStatus === 'running'}
|
|
tabsState={{ tabs: [], active: DESIGN_FILES_TAB }}
|
|
onTabsStateChange={vi.fn()}
|
|
messages={[assistantMessage(runStatus)]}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.queryByTestId('generating-tab')).toBeNull();
|
|
expect(screen.queryByTestId('generation-preview-stage')).toBeNull();
|
|
expect(screen.getByTestId('design-files-empty')).toBeTruthy();
|
|
},
|
|
);
|
|
});
|