Files
open-design/apps/web/tests/components/FileWorkspace.test.tsx
Ray XiandGitHub 938a09b12c [OPEND-3334] feat(web): one project view from the Home send — no hand-off, one Working state (#8267)
* fix(web): keep the project split still across the Home hand-off

The optimistic creation frame left `.split` on its stylesheet default
(460px) while ProjectView resolved the saved width, or the equal split of
the container, only after it mounted — and that first write ran through
the 200ms width transition, so the chat column moved once the real view
arrived (OPEND-3207).

Move the split geometry into `project-split-layout.ts`, have both surfaces
resolve their first-paint width through `resolveProjectSplitLayout`, and
settle a surface's first measured write without the transition.

Red spec: e2e/ui/home-send-split-width.test.ts (red on main, green here).

* test(web): read the 4px handle constant from project-split-layout

The material spec pinned `SPLIT_RESIZE_HANDLE_WIDTH = 4` in ProjectView's
source; the constant now lives in project-split-layout.ts and ProjectView
imports it, so assert on the module and on the import.

* test(cms): wait for the Test modal impression before switching language

The locale case only passed while the impression frame never fired: on a
fast machine the next language switch unmounted the modal before its
requestAnimationFrame recorded the impression. CI is slow enough for the
frame to land, and the switch that follows then closes the modal for good.
Waiting for the recorded impression makes that order deterministic.

* fix(cms): keep the open Test modal across a same-deployment reload

A language switch (or lease renewal) reloads the selected Test deployment.
While the new decisions are in flight the runtime publishes a session
without decisions, and the modal treated that gap as the presentation
ending: it dropped its "open" marker, then the returning decision looked
like a new offer of an activity this account had already seen and stayed
closed.

Key the open presentation by the selected deployment and snapshot. A
reload of the same selection continues it; a redeploy, a different
account, a dismissal, or the session ending still releases it.

* test(e2e): red spec for the Home → project hand-off parity (OPEND-3334)

Two pins for the hand-off between the optimistic project frame and
ProjectView: pixel parity of the chat and workspace columns across the
hand-off (animations frozen), and a single assistant status label + class
set from the click to the first content. Expected red on main: the frame is
a hand-drawn copy of ProjectView and drifts from it (old "Preparing…" footer
vs the execution record's "Working" head, simplified composer, disabled
empty-state pills, tabs dock, switcher name).

* test(e2e): enroll the Home hand-off specs in the ui_p0 entry-settings group

home-amr-pending, home-send-single-loading and home-send-split-width were
merged (or opened) without being enrolled in uiP0Groups, so no merge lane
had ever executed them; only the prerelease full pool would have. Enroll
them with the other Home-entry files.

* test(e2e): enroll the hand-off parity spec in ui_p0 (OPEND-3334)

* feat(web): draw the Home → project hand-off frame with the real view's parts (OPEND-3334)

The optimistic creation frame was a hand-drawn copy of ProjectView and had
drifted from it, so the hand-off moved: the old assistant footer's
"Preparing…" flipped to the execution record's "Working" head, the composer
gained its accessories, the empty-state pills lit up, the tabs dock gained
the history control.

The frame now draws the same parts: the role row named through the shared
`assistantRoleNameForAgent`, the real `ExecutionShell` on an empty running
shell ("Working", its orb, its geometry), the real composer accessories
(design-system picker + agent/model menu) inert, the history control's
footprint in the dock, and live-looking, inert empty-state pills. Behind an
execution shell the old footer no longer paints its running label, so a
running turn states its status once.

App and ProjectView pass the agent name, config, agents, daemon liveness and
design systems the frame needs; the pending record carries the send's
design-system id.

* test(e2e): compare the status row's element set per hand-off phase

The record head is more than one element (its shimmer span and the summary
wrapper both carry the text), so counting distinct class sets across the
whole log flagged an identical head as a change. Compare the set the frame
drew with the set the view drew instead.

* feat(web): let the real ChatPane draw the Home hand-off's first turn (OPEND-3334)

The pending frame and ProjectView's hand-off card were hand-drawn copies of
the chat column, so every ChatPane internal they missed (the fixed composer
layer, the chat seam, the log's top inset) was a jump at the hand-off. Both
now render the real ChatPane on an optimistic first turn built from the
creation record: a user message and a running, empty assistant placeholder.

- runtime/chat/creation-handoff: pure builder for that turn; staged images
  preview through object URLs until the files are in the project.
- ChatPane: `detached` mode (inert pane + composer layer) for the frame;
  the composer portal target resolves at mount so the composer is portaled
  before the first paint instead of one frame after it.
- ProjectView: mounts ChatPane during the hand-off and feeds it the
  optimistic turn until a real message exists; the overlay card, the hidden
  pane CSS and `composerLayerHidden` are gone.
- agentModelSelection: one `selectedAssistantIdentity` for the real and the
  optimistic turn.

* fix(web): give the pending frame's workspace column the add-tab and project-menu controls (OPEND-3334)

The parity pin now passes on the chat column and fails on the workspace
column: the frame lacked FileWorkspace's add-tab button and the project
menu trigger, so the top bar was 4px shorter and the empty state below it
moved at the hand-off.

* test(web): decouple pending handoff assertions from CSS classes

Generated-By: looper 0.13.0 (runner=fixer, agent=codex)

* fix(web): the Home hand-off's first turn fades in once, no remount, no workspace reload (OPEND-3334)

A rAF probe of the Home send found the first turn mounted four times, each
playing the 200ms `msg-enter` fade (QA's recording shows the flashes):
the pending frame (the one intended entrance); ProjectView taking over;
the ChatPane key moving from `conversation-unavailable` to the resolved
conversation id; and the real rows replacing the optimistic ones under new
ids. The workspace went pending-empty -> "Loading..." -> empty.

- ChatPane `quietEntrance`: rows first rendered while it is on get
  `msg--quiet-enter` (no fade) for their lifetime; dropping the flag never
  restarts an animation. ProjectView sets it while the hand-off runs.
- ProjectView keeps the pane's key when the hand-off pane's first
  conversation id resolves; a later switch still re-keys.
- A project born from the Home send (not an imported folder) shows the
  empty workspace until the first listing, as the pending frame did.

* test(e2e): pin one entrance, one view pane and no workspace reload across the Home hand-off (OPEND-3334)

* fixer: address PR #8267 follow-up items

Generated-By: looper 0.13.0 (runner=fixer, agent=codex)

* fix(web): keep the file-list authority gate across the Home hand-off (OPEND-3334)

e3596b032c made a Home-created project's workspace authoritative before
its first listing was accepted, so the empty-state CTAs were live while
Home attachments were still uploading, and a failed first read looked
like a genuinely empty project (review by nettee).

Restore `filesAuthoritative={committedFilesGeneration > 0}`. Until that
first listing, `emptyLookBeforeFirstListing` draws the same empty state
the hand-off frame showed, from DesignFilesPanel's one empty-state
markup, but inert and aria-busy and without CTA test ids. The live empty
state (or the files) replaces it once a listing is accepted, so the
workspace still never detours through "Loading…".

* feat(daemon): accept a caller-minted seed conversation id on project create (OPEND-3334)

* refactor(web): mount ProjectView from the Home send and hold project reads until the create answers (OPEND-3334)

* test(web): pin the held reads and the single pane across the Home send (OPEND-3334)

* test(e2e): key the Home hand-off specs on the creation phase, pin one pane and one user row (OPEND-3334)

* test(e2e): no project-scoped request leaves before the create answers (OPEND-3334)

* test(web): type the fetch spy in the held-reads case

* fix(web): hold the pending-prompt clear, tab hydrate, folders and share-status reads until the create answers (OPEND-3334)

The e2e network assertion caught five project-scoped requests leaving
before POST /api/projects answered. A Home send's view is also an
auto-send from mount now: App writes the auto-send keys after the
create, so the prompt must not fall back to a composer draft.

* fix(web): keep the run-status feed off a project until its create answers (OPEND-3334)

The rail and the tab switcher list the optimistic row from the click, and
their shared feed polled /api/runs?projectId= for it every 4s. The read
predates this branch; the e2e network assertion caught it intermittently.

* fix(web): the optimistic first turn belongs to ProjectView, not App's creation record (OPEND-3334)

QA on bd5fe20579: App releases the creation record 8s after the create;
Home uploads outliving that made the first turn and its Working row
vanish until they finished. The view now captures the turn at mount and
retires it when the real turn owns the rows, when the auto-send is turned
down, or when the record is gone with nothing left to send. The seed
conversation id is kept for the view's life so the pane never re-keys.
2026-10-22 03:52:55 +00:00

5125 lines
191 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @vitest-environment jsdom
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { act, useState } 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 {
buildWorkspacePermissions,
buildWorkspaceSeatSummary,
type WorkspaceCollabContext,
} from '@open-design/contracts';
import {
DESIGN_FILES_TAB,
FileWorkspace,
measureWorkspaceTabBarAfterResize,
settleManualEditFiles,
scrollWorkspaceTabsWithWheel,
settleManualEditExit,
} from '../../src/components/FileWorkspace';
import { ENABLE_BLANK_PAGE_WORKSPACE_ENTRYPOINT } from '../../src/components/workspace/tab-launcher';
import { fileViewerSourceAuthorizationScopeKey } from '../../src/components/FileViewer';
import { nextShareRequestNonce } from '../../src/components/share-request-nonce';
import type { ObservedShareUpdateRequest } from '../../src/components/share/observed-public-share-link';
import { I18nProvider } from '../../src/i18n';
import { DesignFilesPanel } from '../../src/components/DesignFilesPanel';
import {
defaultChatPanelWidthForSplit,
projectSplitClassName,
projectSplitStyle,
} from '../../src/components/ProjectView';
import {
fetchProjectFileText,
uploadProjectFiles,
writeProjectTextFile,
fetchProjectFolders,
} from '../../src/providers/registry';
import type { ChatMessage, OpenTabsState, ProjectFile, ProjectFolder } from '../../src/types';
import {
CollabProvider,
type CollabContextValue,
} from '../../src/collab/collab-context';
import { IframeKeepAliveProvider } from '../../src/components/IframeKeepAlivePool';
import { navigate } from '../../src/router';
describe('settleManualEditExit', () => {
it.each([
['asynchronously', () => Promise.reject(new Error('save failed'))],
['synchronously', () => { throw new Error('save failed'); }],
])('treats an exit handler that rejects %s as an unsafe exit', async (_label, exit) => {
await expect(settleManualEditExit(exit)).resolves.toBe(false);
});
it('settles every protected file instead of trusting only the active tab', async () => {
const settle = vi.fn(async (fileName: string) => fileName !== 'offscreen.html');
await expect(settleManualEditFiles(
['active.html', 'offscreen.html', 'offscreen.html'],
settle,
)).resolves.toBe(false);
expect(settle.mock.calls.map(([fileName]) => fileName)).toEqual([
'active.html',
'offscreen.html',
]);
});
});
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);
const chatCss = readFileSync(join(process.cwd(), 'src/styles/chat.css'), 'utf8');
const routinesCss = readFileSync(join(process.cwd(), 'src/styles/viewer/routines.css'), 'utf8');
let root: Root | null = null;
let host: HTMLDivElement | null = null;
let composerCssStyle: HTMLStyleElement | 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;
}
document.body.classList.remove('od-quick-switcher-open');
document.querySelectorAll('.chat-composer-fixed-layer').forEach((node) => node.remove());
composerCssStyle?.remove();
composerCssStyle = null;
host?.remove();
host = null;
window.history.replaceState(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 teamContext(
workspaceId: string,
workspaceMemberId: string,
): WorkspaceCollabContext {
return {
workspaceId,
workspaceType: 'team',
workspaceMemberId,
role: 'owner',
memberStatus: 'active',
lifecycleState: 'active',
billingState: 'active',
planId: 'team_plus',
providerMode: 'platform_credits',
teamId: `team-${workspaceId}`,
seatSummary: buildWorkspaceSeatSummary({ seatLimit: 3, usedSeats: 1 }),
permissions: buildWorkspacePermissions({ role: 'owner', lifecycleState: 'active' }),
};
}
function collabValue(workspaceContext: WorkspaceCollabContext): CollabContextValue {
return {
workspaceContext,
workspaceContextLoading: false,
enabled: false,
member: null,
present: [],
publishedVersion: null,
syncState: null,
viewerOnly: false,
writerAuthority: 'allowed',
isOwner: true,
isEffectiveOwner: true,
isSharedNonOwner: false,
ownerDisplayName: null,
ownerRole: null,
downloadPending: false,
reportChange: () => {},
requestPublish: () => {},
refreshPresence: () => {},
checkStatusNow: () => {},
};
}
it('keeps an observed share witness through a transient missing active file but clears it on a real file switch', async () => {
const onObservedPublicShareLink = vi.fn();
const file = workspaceFile('index.html');
const props: React.ComponentProps<typeof FileWorkspace> = {
projectId: 'project-1', projectKind: 'prototype', files: [file], liveArtifacts: [],
onRefreshFiles: vi.fn(), isDeck: false,
tabsState: { tabs: ['index.html'], active: 'index.html' }, onTabsStateChange: vi.fn(),
onObservedPublicShareLink,
};
const view = render(<CollabProvider value={collabValue(teamContext('workspace-a', 'member-a'))}><FileWorkspace {...props} /></CollabProvider>);
await act(async () => {});
// Mounting is not a file switch; see isObservedShareFileSwitch.
expect(onObservedPublicShareLink).not.toHaveBeenCalled();
view.rerender(<CollabProvider value={collabValue(teamContext('workspace-a', 'member-a'))}><FileWorkspace {...props} files={[]} /></CollabProvider>);
expect(onObservedPublicShareLink).not.toHaveBeenCalled();
view.rerender(<CollabProvider value={collabValue(teamContext('workspace-a', 'member-a'))}><FileWorkspace {...props} files={[file]} /></CollabProvider>);
expect(onObservedPublicShareLink).not.toHaveBeenCalled();
const otherFile = workspaceFile('other.html');
view.rerender(<CollabProvider value={collabValue(teamContext('workspace-a', 'member-a'))}><FileWorkspace {...props} files={[file, otherFile]} tabsState={{ tabs: ['other.html'], active: 'other.html' }} /></CollabProvider>);
await waitFor(() => expect(onObservedPublicShareLink).toHaveBeenCalledWith(null));
});
it('keeps an observed share witness when the same file view remounts (Owner-S13 sign-out race)', async () => {
// Signing out refreshes the Workspace context before the login status
// settles; ProjectView re-keys and remounts FileWorkspace on the SAME file.
// A remount is not a file switch: clearing the App-held witness here makes
// WorkspaceTabsBar see no signed-out share route and re-home to Home.
const onObservedPublicShareLink = vi.fn();
const file = workspaceFile('index.html');
const props: React.ComponentProps<typeof FileWorkspace> = {
projectId: 'project-1', projectKind: 'prototype', files: [file], liveArtifacts: [],
onRefreshFiles: vi.fn(), isDeck: false,
tabsState: { tabs: ['index.html'], active: 'index.html' }, onTabsStateChange: vi.fn(),
onObservedPublicShareLink,
};
const context = teamContext('workspace-a', 'member-a');
const first = render(<CollabProvider value={collabValue(context)}><FileWorkspace {...props} /></CollabProvider>);
first.unmount();
render(<CollabProvider value={collabValue(context)}><FileWorkspace {...props} /></CollabProvider>);
await act(async () => {});
expect(onObservedPublicShareLink).not.toHaveBeenCalledWith(null);
});
it('S14 shared HTML deletion warns about the exact active file link before a cancellable delete', async () => {
const context = teamContext('workspace-a', 'member-a');
const request = vi.fn(async (url: RequestInfo | URL) => String(url).endsWith('/share-state')
? Response.json({ projectId: 'project-1', bindingExists: true, hasEverShared: true, publications: [
{ sourceFilePath: 'index.html', slug: 'live', status: 'active' },
{ sourceFilePath: 'old.html', slug: 'old', status: 'stopped' },
] })
: new Response('', { status: 200 }));
const confirmDelete = vi.fn(() => false);
vi.stubGlobal('fetch', request);
vi.stubGlobal('confirm', confirmDelete);
render(<CollabProvider value={collabValue(context)}><FileWorkspace
projectId="project-1" projectKind="prototype" files={[workspaceFile('index.html')]}
liveArtifacts={[]} onRefreshFiles={vi.fn()} isDeck={false}
tabsState={{ tabs: [], active: null }} onTabsStateChange={vi.fn()}
/></CollabProvider>);
fireEvent.click(screen.getByTestId('design-file-menu-index.html'));
fireEvent.click(screen.getByTestId('design-file-delete-index.html'));
await waitFor(() => expect(confirmDelete).toHaveBeenCalledWith(expect.stringContaining('1 pages being shared')));
expect(request).toHaveBeenCalledWith('/api/projects/project-1/share-state', expect.objectContaining({ headers: expect.objectContaining({ 'x-od-workspace-id': 'workspace-a' }) }));
expect(request.mock.calls.some(([url]) => String(url).includes('/files/index.html') && !String(url).endsWith('/preview'))).toBe(false);
});
it('S14 batch deletion warns only for active links attached to selected HTML files', async () => {
const request = vi.fn(async (url: RequestInfo | URL) => String(url).endsWith('/share-state')
? Response.json({ projectId: 'project-1', bindingExists: true, hasEverShared: true, publications: [
{ sourceFilePath: 'index.html', slug: 'live', status: 'active' },
{ sourceFilePath: 'other.html', slug: 'other', status: 'active' },
{ sourceFilePath: 'index.html', slug: 'old', status: 'stopped' },
] }) : new Response('', { status: 200 }));
const confirmDelete = vi.fn(() => false);
vi.stubGlobal('fetch', request);
vi.stubGlobal('confirm', confirmDelete);
const { container } = render(<CollabProvider value={collabValue(teamContext('workspace-a', 'member-a'))}><FileWorkspace
projectId="project-1" projectKind="prototype" files={[workspaceFile('index.html'), workspaceFile('picture.png')]}
liveArtifacts={[]} onRefreshFiles={vi.fn()} isDeck={false}
tabsState={{ tabs: [], active: null }} onTabsStateChange={vi.fn()}
/></CollabProvider>);
fireEvent.click(screen.getByTestId('design-file-row-index.html').querySelector('.df-card-check')!);
fireEvent.click(container.querySelector('[data-testid="design-files-batch-delete"]')!);
await waitFor(() => expect(confirmDelete).toHaveBeenCalledWith(expect.stringContaining('1 pages being shared')));
expect(request.mock.calls.filter(([url]) => String(url).endsWith('/share-state'))).toHaveLength(1);
});
it('S14 HTML deletion without workspace identity does not guess that the file has no active links', async () => {
const request = vi.fn(async (_url: RequestInfo | URL) => new Response('', { status: 200 }));
const confirmDelete = vi.fn(() => true);
const alertFailure = vi.fn();
vi.stubGlobal('fetch', request);
vi.stubGlobal('confirm', confirmDelete);
vi.stubGlobal('alert', alertFailure);
render(<FileWorkspace
projectId="project-1" projectKind="prototype" files={[workspaceFile('index.html')]}
liveArtifacts={[]} onRefreshFiles={vi.fn()} isDeck={false}
tabsState={{ tabs: [], active: null }} onTabsStateChange={vi.fn()}
/>);
fireEvent.click(screen.getByTestId('design-file-menu-index.html'));
fireEvent.click(screen.getByTestId('design-file-delete-index.html'));
await waitFor(() => expect(alertFailure).toHaveBeenCalled());
expect(confirmDelete).not.toHaveBeenCalled();
expect(request.mock.calls.filter(([url]) => String(url).endsWith('/share-state'))).toHaveLength(0);
});
it('S14 unavailable share-state blocks HTML deletion rather than silently claiming no active links', async () => {
const request = vi.fn(async (_url: RequestInfo | URL) => new Response('', { status: 503 }));
const confirmDelete = vi.fn(() => true);
const alertFailure = vi.fn();
vi.stubGlobal('fetch', request);
vi.stubGlobal('confirm', confirmDelete);
vi.stubGlobal('alert', alertFailure);
render(<CollabProvider value={collabValue(teamContext('workspace-a', 'member-a'))}><FileWorkspace
projectId="project-1" projectKind="prototype" files={[workspaceFile('index.html')]}
liveArtifacts={[]} onRefreshFiles={vi.fn()} isDeck={false}
tabsState={{ tabs: [], active: null }} onTabsStateChange={vi.fn()}
/></CollabProvider>);
fireEvent.click(screen.getByTestId('design-file-menu-index.html'));
fireEvent.click(screen.getByTestId('design-file-delete-index.html'));
await waitFor(() => expect(alertFailure).toHaveBeenCalled());
expect(confirmDelete).not.toHaveBeenCalled();
expect(request.mock.calls.filter(([url]) => String(url).endsWith('/share-state'))).toHaveLength(1);
});
function cssDeclarations(css: string, selector: string): string {
const blocks: string[] = [];
const rulePattern = /([^{}]+)\{([^}]*)\}/g;
const cssWithoutComments = css.replace(/\/\*[\s\S]*?\*\//g, '');
let match: RegExpExecArray | null;
while ((match = rulePattern.exec(cssWithoutComments)) !== null) {
const selectors = (match[1] ?? '').split(',').map((item) => item.trim());
if (selectors.includes(selector)) blocks.push(match[2] ?? '');
}
if (blocks.length === 0) throw new Error(`Missing CSS block for ${selector}`);
return blocks.join('\n');
}
function installComposerIsolationCss() {
const rules = [
['.chat-composer-fixed-layer', chatCss],
['.chat-composer-fixed-layer .composer', chatCss],
['.composer-input-wrap', chatCss],
['.composer-input-wrap:focus-within', chatCss],
['.composer-input-editor .composer-editable', chatCss],
['.composer-input-placeholder', chatCss],
['.chat-composer-fixed-layer .composer-shell', routinesCss],
['.chat-composer-fixed-layer .composer.drag-active .composer-shell', routinesCss],
['.chat-composer-fixed-layer .composer-input-wrap', routinesCss],
['body.od-quick-switcher-open .chat-composer-fixed-layer', routinesCss],
['body.od-quick-switcher-open .chat-composer-fixed-layer .composer', routinesCss],
['body.od-quick-switcher-open .chat-composer-fixed-layer .composer-shell', routinesCss],
['body.od-quick-switcher-open .chat-composer-fixed-layer .composer.drag-active .composer-shell', routinesCss],
['body.od-quick-switcher-open .chat-composer-fixed-layer .composer-input-wrap', routinesCss],
['body.od-quick-switcher-open .chat-composer-fixed-layer .composer-input-wrap:focus-within', routinesCss],
['body.od-quick-switcher-open .chat-composer-fixed-layer .composer-input-editor .composer-editable', routinesCss],
['body.od-quick-switcher-open .chat-composer-fixed-layer .composer-input-placeholder', routinesCss],
['body.od-quick-switcher-open .chat-composer-fixed-layer .staged-context-row', routinesCss],
['body.od-quick-switcher-open .chat-composer-fixed-layer .staged-context-picker--design-system .project-ds-picker-trigger', routinesCss],
['body.od-quick-switcher-open .chat-composer-fixed-layer .staged-chip', routinesCss],
['body.od-quick-switcher-open .chat-composer-fixed-layer .staged-context', routinesCss],
['body.od-quick-switcher-open .chat-composer-fixed-layer .staged-order', routinesCss],
['body.od-quick-switcher-open .chat-composer-fixed-layer .staged-comment button', routinesCss],
['body.od-quick-switcher-open .chat-composer-fixed-layer .staged-name', routinesCss],
['body.od-quick-switcher-open .chat-composer-fixed-layer .staged-comment .staged-name strong', routinesCss],
['body.od-quick-switcher-open .chat-composer-fixed-layer .staged-comment .staged-name span', routinesCss],
['body.od-quick-switcher-open .chat-composer-fixed-layer .staged-context .staged-icon', routinesCss],
['body.od-quick-switcher-open .chat-composer-fixed-layer .staged-chip .staged-icon', routinesCss],
['body.od-quick-switcher-open .chat-composer-fixed-layer .staged-chip .staged-remove', routinesCss],
['body.od-quick-switcher-open .chat-composer-fixed-layer .composer-active-file', routinesCss],
['body.od-quick-switcher-open .chat-composer-fixed-layer .composer-row .icon-btn', routinesCss],
['body.od-quick-switcher-open .chat-composer-fixed-layer .composer-row .session-mode-toggle__trigger', routinesCss],
['body.od-quick-switcher-open .chat-composer-fixed-layer .composer-row .avatar-agent-trigger', routinesCss],
['body.od-quick-switcher-open .chat-composer-fixed-layer .composer-row .avatar-btn', routinesCss],
['body.od-quick-switcher-open .chat-composer-fixed-layer .composer-send', routinesCss],
['body.od-quick-switcher-open .chat-composer-fixed-layer .composer-send:disabled', routinesCss],
] as const;
composerCssStyle = document.createElement('style');
composerCssStyle.textContent = rules
.map(([selector, css]) => `${selector} {${cssDeclarations(css, selector)}}`)
.join('\n');
document.head.appendChild(composerCssStyle);
}
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 renderDesignFilesPanel(overrides: Partial<React.ComponentProps<typeof DesignFilesPanel>> = {}) {
const props: React.ComponentProps<typeof DesignFilesPanel> = {
projectId: 'project-1',
projectKind: 'prototype',
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} />);
}
describe('FileWorkspace quick switcher visual isolation', () => {
it('moves focus into quick search and marks the document while the overlay is open', async () => {
installComposerIsolationCss();
const composerLayer = document.createElement('div');
composerLayer.className = 'chat-composer-fixed-layer';
composerLayer.innerHTML = `
<div class="composer drag-active">
<div class="composer-shell">
<div class="staged-row staged-context-row">
<div class="staged-context-picker staged-context-picker--design-system">
<button class="project-ds-picker-trigger picked" type="button">Choose design system</button>
</div>
<div class="staged-chip staged-context staged-context--workspace">
<span class="staged-icon">F</span>
<span class="staged-name">
<span class="staged-context-kind">Current</span>manual-edit.html
</span>
<button class="staged-remove" type="button">x</button>
</div>
<div class="staged-chip staged-file">
<span class="staged-order">1</span>
<span class="staged-icon">F</span>
<span class="staged-name">hero.png</span>
<button class="staged-remove" type="button">x</button>
</div>
<div class="staged-chip staged-comment">
<span class="staged-name"><strong>Hero</strong><span>Needs tweak</span></span>
<button class="staged-remove" type="button">x</button>
</div>
</div>
<div class="composer-active-file">
<span class="composer-active-file__label">Current</span>
<span class="composer-active-file__name">manual-edit.html</span>
</div>
<div class="composer-input-wrap">
<div class="composer-input-editor">
<div class="composer-editable" contenteditable="true" tabindex="0">Mock focused composer control</div>
<div class="composer-input-placeholder">Describe what you want to generate...</div>
</div>
</div>
<div class="composer-row">
<button class="icon-btn" type="button">+</button>
<button class="avatar-agent-trigger" type="button">
<span class="avatar-btn">A</span>
</button>
<button class="session-mode-toggle__trigger" type="button">Design</button>
<button class="composer-send" type="button" disabled>Send</button>
</div>
</div>
</div>
`;
document.body.appendChild(composerLayer);
const composer = composerLayer.querySelector<HTMLElement>('.composer');
const composerShell = composerLayer.querySelector<HTMLElement>('.composer-shell');
const composerInputWrap = composerLayer.querySelector<HTMLElement>('.composer-input-wrap');
const composerControl = composerLayer.querySelector<HTMLElement>('.composer-editable');
const composerPlaceholder = composerLayer.querySelector<HTMLElement>('.composer-input-placeholder');
const designSystemTrigger = composerLayer.querySelector<HTMLElement>('.project-ds-picker-trigger');
const stagedContext = composerLayer.querySelector<HTMLElement>('.staged-context');
const stagedContextKind = composerLayer.querySelector<HTMLElement>('.staged-context-kind');
const stagedIcon = composerLayer.querySelector<HTMLElement>('.staged-context .staged-icon');
const stagedRemove = composerLayer.querySelector<HTMLElement>('.staged-context .staged-remove');
const stagedFile = composerLayer.querySelector<HTMLElement>('.staged-file');
const stagedOrder = composerLayer.querySelector<HTMLElement>('.staged-file .staged-order');
const stagedFileIcon = composerLayer.querySelector<HTMLElement>('.staged-file > .staged-icon');
const stagedFileRemove = composerLayer.querySelector<HTMLElement>('.staged-file > .staged-remove');
const stagedFileName = composerLayer.querySelector<HTMLElement>('.staged-file .staged-name');
const stagedComment = composerLayer.querySelector<HTMLElement>('.staged-comment');
const stagedCommentButton = composerLayer.querySelector<HTMLElement>('.staged-comment button');
const stagedCommentStrong = composerLayer.querySelector<HTMLElement>('.staged-comment .staged-name strong');
const stagedCommentSpan = composerLayer.querySelector<HTMLElement>('.staged-comment .staged-name span');
const activeFileChip = composerLayer.querySelector<HTMLElement>('.composer-active-file');
const toolbarIcon = composerLayer.querySelector<HTMLElement>('.icon-btn');
const toolbarAvatar = composerLayer.querySelector<HTMLElement>('.avatar-agent-trigger');
const toolbarAvatarButton = composerLayer.querySelector<HTMLElement>('.avatar-btn');
const toolbarMode = composerLayer.querySelector<HTMLElement>('.session-mode-toggle__trigger');
const toolbarSend = composerLayer.querySelector<HTMLElement>('.composer-send');
if (!composer) throw new Error('Missing mock composer');
if (!composerShell) throw new Error('Missing mock composer shell');
if (!composerInputWrap) throw new Error('Missing mock composer input wrapper');
if (!composerControl) throw new Error('Missing mock composer control');
if (!composerPlaceholder) throw new Error('Missing mock composer placeholder');
if (!designSystemTrigger) throw new Error('Missing mock design-system trigger');
if (!stagedContext) throw new Error('Missing mock staged context');
if (!stagedContextKind) throw new Error('Missing mock staged context kind');
if (!stagedIcon) throw new Error('Missing mock staged context icon');
if (!stagedRemove) throw new Error('Missing mock staged context remove');
if (!stagedFile) throw new Error('Missing mock staged file');
if (!stagedOrder) throw new Error('Missing mock staged order');
if (!stagedFileIcon) throw new Error('Missing mock staged file icon');
if (!stagedFileRemove) throw new Error('Missing mock staged file remove');
if (!stagedFileName) throw new Error('Missing mock staged file name');
if (!stagedComment) throw new Error('Missing mock staged comment');
if (!stagedCommentButton) throw new Error('Missing mock staged comment button');
if (!stagedCommentStrong) throw new Error('Missing mock staged comment strong text');
if (!stagedCommentSpan) throw new Error('Missing mock staged comment span text');
if (!activeFileChip) throw new Error('Missing mock active file chip');
if (!toolbarIcon) throw new Error('Missing mock toolbar icon');
if (!toolbarAvatar) throw new Error('Missing mock toolbar avatar');
if (!toolbarAvatarButton) throw new Error('Missing mock toolbar avatar button');
if (!toolbarMode) throw new Error('Missing mock toolbar mode');
if (!toolbarSend) throw new Error('Missing mock toolbar send');
expect(getComputedStyle(composerLayer).pointerEvents).toBe('none');
expect(getComputedStyle(composer).pointerEvents).toBe('auto');
expect(getComputedStyle(composerControl).pointerEvents).toBe('auto');
composerControl.focus();
expect(document.activeElement).toBe(composerControl);
render(
<FileWorkspace
projectId="project-1"
projectKind="prototype"
files={[workspaceFile('index.html')]}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
isDeck={false}
tabsState={{ tabs: [], active: null }}
onTabsStateChange={vi.fn()}
/>,
);
fireEvent.keyDown(window, { key: 'p', ctrlKey: true });
await waitFor(() => {
expect(document.body.classList.contains('od-quick-switcher-open')).toBe(true);
});
const quickSearchInput = screen.getByRole('textbox');
await waitFor(() => {
expect(document.activeElement).toBe(quickSearchInput);
});
await waitFor(() => {
expect(getComputedStyle(composer).pointerEvents).toBe('none');
});
expect(getComputedStyle(composerLayer).pointerEvents).toBe('none');
expect(getComputedStyle(composerLayer).opacity).toBe('0.58');
expect(getComputedStyle(composerControl).pointerEvents).toBe('none');
expect(getComputedStyle(composerShell).boxShadow).toBe('none');
expect(getComputedStyle(composerShell).borderColor).toBe('rgba(0, 0, 0, 0)');
expect(getComputedStyle(composerInputWrap).background).toBe('var(--bg-fill-tertiary)');
expect(getComputedStyle(composerInputWrap).borderColor).toBe('rgba(0, 0, 0, 0)');
expect(getComputedStyle(composerInputWrap).boxShadow).toBe('none');
expect(getComputedStyle(composerControl).color).toBe('var(--text-muted)');
expect(getComputedStyle(composerControl).caretColor).toBe('rgba(0, 0, 0, 0)');
expect(getComputedStyle(composerPlaceholder).color).toBe('color-mix(in srgb, var(--text-muted) 72%, transparent)');
for (const toolbarControl of [
designSystemTrigger,
stagedContext,
stagedIcon,
stagedRemove,
stagedFile,
stagedOrder,
stagedFileIcon,
stagedFileRemove,
stagedComment,
stagedCommentButton,
activeFileChip,
toolbarIcon,
toolbarAvatar,
toolbarAvatarButton,
toolbarMode,
toolbarSend,
]) {
expect(getComputedStyle(toolbarControl).backgroundColor).toBe('rgba(0, 0, 0, 0)');
expect(getComputedStyle(toolbarControl).borderColor).toBe('rgba(0, 0, 0, 0)');
expect(getComputedStyle(toolbarControl).boxShadow).toBe('none');
}
expect(getComputedStyle(stagedContextKind).color).toBe('var(--text-muted)');
expect(getComputedStyle(stagedFileName).color).toBe('var(--text-muted)');
expect(getComputedStyle(stagedCommentStrong).color).toBe('var(--text-muted)');
expect(getComputedStyle(stagedCommentSpan).color).toBe('var(--text-muted)');
fireEvent.keyDown(window, { key: 'Escape' });
await waitFor(() => {
expect(document.body.classList.contains('od-quick-switcher-open')).toBe(false);
});
await waitFor(() => {
expect(getComputedStyle(composer).pointerEvents).toBe('auto');
});
expect(getComputedStyle(composerControl).pointerEvents).toBe('auto');
expect(getComputedStyle(composerLayer).opacity).not.toBe('0.58');
/*
* 关掉快速切换之后,输入框要退回它自己的底色 —— 判据是「**不再是**那层
* `--bg-fill-tertiary` 隔离罩」,而不是某个具体颜色值。
*
* 原来这里钉的是 `rgb(255, 255, 255)`,依据是 #5517 那次改版把静息底色做成
* `--bg-panel`/`--bg-subtle` 的 `color-mix` 微调、"在测试主题里解析成白"。
* 可 jsdom 解析不了 `color-mix`,那一格现在返回 `rgba(0, 0, 0, 0)` ——
* 钉具体颜色等于钉住了 jsdom 的解析能力,不是钉住产品行为。
*/
expect(getComputedStyle(composerInputWrap).background).not.toBe('var(--bg-fill-tertiary)');
});
});
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,
}),
),
);
});
// PageCreator flows are unreachable while the 新建空白页面 launcher entry
// is paused (see ENABLE_BLANK_PAGE_WORKSPACE_ENTRYPOINT); these suites
// revive automatically when the switch flips back.
it.skipIf(!ENABLE_BLANK_PAGE_WORKSPACE_ENTRYPOINT)('creates slide template pages without default speaker notes', async () => {
const onRefreshFiles = vi.fn();
const onTabsStateChange = vi.fn();
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === '/api/plugins') {
return new Response(JSON.stringify({
plugins: [{
id: 'clean-deck',
title: 'Clean Deck',
version: '0.1.0',
sourceKind: 'bundled',
source: '/tmp',
trust: 'bundled',
capabilitiesGranted: [],
manifest: {
name: 'clean-deck',
version: '0.1.0',
title: 'Clean Deck',
od: {
kind: 'scenario',
mode: 'deck',
inputs: [{ name: 'audience', label: 'Audience', default: 'founder teams' }],
preview: { type: 'html', entry: './preview.html' },
useCase: { query: 'Create a clean launch deck for {{audience}}.' },
},
},
fsPath: '/tmp',
installedAt: 0,
updatedAt: 0,
}],
}), { headers: { 'content-type': 'application/json' } });
}
if (url === '/api/plugins/clean-deck/preview') {
return new Response(
'<!doctype html><html><body><main>Clean Deck</main><script type="application/json" id="speaker-notes">["Use speaker notes"]</script></body></html>',
{ headers: { 'content-type': 'text/html' } },
);
}
return new Response('', { status: 404 });
}));
mockedWriteProjectTextFile.mockImplementation(async (_projectId, name) => workspaceFile(name));
render(
<FileWorkspace
projectId="project-1"
projectKind="slide_deck"
files={[]}
liveArtifacts={[]}
onRefreshFiles={onRefreshFiles}
isDeck={false}
tabsState={{ tabs: [], active: null }}
onTabsStateChange={onTabsStateChange}
/>,
);
fireEvent.click(screen.getByTestId('workspace-add-tab'));
fireEvent.click(screen.getByRole('button', { name: /New blank page/i }));
const title = await screen.findByText('Clean Deck');
const card = title.closest('article');
expect(card).not.toBeNull();
fireEvent.click(within(card as HTMLElement).getByRole('button', { name: 'Use' }));
await waitFor(() => expect(mockedWriteProjectTextFile).toHaveBeenCalledTimes(1));
const [projectId, name, content, options] = mockedWriteProjectTextFile.mock.calls[0]!;
expect(projectId).toBe('project-1');
expect(name).toBe('clean-deck.html');
expect(content).not.toContain('id="speaker-notes"');
expect(content).not.toContain('Use speaker notes');
expect(options).toMatchObject({
versionSource: 'manual',
versionPrompt: 'Create a clean launch deck for founder teams.',
});
await waitFor(() => expect(onRefreshFiles).toHaveBeenCalledTimes(1));
});
// PageCreator flows are unreachable while the 新建空白页面 launcher entry
// is paused (see ENABLE_BLANK_PAGE_WORKSPACE_ENTRYPOINT); these suites
// revive automatically when the switch flips back.
it.skipIf(!ENABLE_BLANK_PAGE_WORKSPACE_ENTRYPOINT)('localizes page creator content and saves template query as the first version prompt', async () => {
const onRefreshFiles = vi.fn();
const onTabsStateChange = vi.fn();
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === '/api/plugins') {
return new Response(JSON.stringify({
plugins: [{
id: 'html-ppt-pitch-deck',
title: 'Write a Demo Day Pitch like a Top Accelerator Group Partner',
version: '0.1.0',
sourceKind: 'bundled',
source: '/tmp',
trust: 'bundled',
capabilitiesGranted: [],
manifest: {
name: 'html-ppt-pitch-deck',
version: '0.1.0',
title: 'Write a Demo Day Pitch like a Top Accelerator Group Partner',
title_i18n: { 'zh-CN': '像顶级加速器合伙人一样写 Demo Day 路演' },
description: 'For fundraising pitch work: turn a startup story into growth, moat, and fundraise narrative that earns another meeting.',
description_i18n: { 'zh-CN': '融资/路演场景:围绕 core query「series-a-pitch-deck」把粗糙材料整理成可购买、可复用的专业 Deck。' },
tags: ['pitch-deck', 'fundraising-pitch', 'series-a-pitch-deck', 'commercial-slide-agent'],
od: {
kind: 'scenario',
mode: 'deck',
preview: { type: 'html', entry: './preview.html' },
useCase: {
query: {
en: 'Create "Write a Demo Day Pitch like a Top Accelerator Group Partner" as a Fundraising pitch deck.',
'zh-CN': '像顶级加速器合伙人一样写 Demo Day 路演。先确认受众、决策目标、素材来源、截止时间和必须保留的数据,再输出叙事主线、页面规划、逐页文案、视觉方向和按评审标准自检的版本。',
},
},
},
},
fsPath: '/tmp',
installedAt: 0,
updatedAt: 0,
}],
}), { headers: { 'content-type': 'application/json' } });
}
if (url === '/api/plugins/html-ppt-pitch-deck/preview') {
return new Response(
'<!doctype html><html><body><main>Write a Demo Day Pitch like a Top Accelerator Group Partner</main></body></html>',
{ headers: { 'content-type': 'text/html' } },
);
}
return new Response('', { status: 404 });
}));
mockedWriteProjectTextFile.mockImplementation(async (_projectId, name) => workspaceFile(name));
render(
<I18nProvider initial="zh-CN">
<FileWorkspace
projectId="project-1"
projectKind="slide_deck"
files={[]}
liveArtifacts={[]}
onRefreshFiles={onRefreshFiles}
isDeck={false}
tabsState={{ tabs: [], active: null }}
onTabsStateChange={onTabsStateChange}
/>
</I18nProvider>,
);
fireEvent.click(screen.getByTestId('workspace-add-tab'));
fireEvent.click(screen.getByRole('button', { name: /新建空白页面/ }));
const dialog = await screen.findByRole('dialog', { name: '新建页面' });
const dialogScope = within(dialog);
expect(dialogScope.getByRole('tab', { name: /全部 幻灯片/ })).toBeTruthy();
// The deck's commercial scene ("融资路演" / fundraising-pitch) is now the
// sub-category tab, resolved from its category tag — the filter row and the
// per-card 品类 chip share one taxonomy.
expect(await dialogScope.findByRole('tab', { name: /融资路演/ })).toBeTruthy();
const title = await dialogScope.findByText('像顶级加速器合伙人一样写 Demo Day 路演');
const card = title.closest('article');
expect(card).not.toBeNull();
expect(within(card as HTMLElement).getByText('融资/路演场景:围绕 core query「series-a-pitch-deck」把粗糙材料整理成可购买、可复用的专业 Deck。')).toBeTruthy();
fireEvent.click(within(card as HTMLElement).getByRole('button', { name: '使用' }));
await waitFor(() => expect(mockedWriteProjectTextFile).toHaveBeenCalledTimes(1));
const [projectId, name, , options] = mockedWriteProjectTextFile.mock.calls[0]!;
expect(projectId).toBe('project-1');
expect(name).toBe('像顶级加速器合伙人一样写-demo-day-路演.html');
expect(options).toMatchObject({
versionSource: 'manual',
versionPrompt: '像顶级加速器合伙人一样写 Demo Day 路演。先确认受众、决策目标、素材来源、截止时间和必须保留的数据,再输出叙事主线、页面规划、逐页文案、视觉方向和按评审标准自检的版本。',
});
await waitFor(() =>
expect(onTabsStateChange).toHaveBeenCalledWith(
expect.objectContaining({
tabs: ['像顶级加速器合伙人一样写-demo-day-路演.html'],
active: '像顶级加速器合伙人一样写-demo-day-路演.html',
}),
),
);
await waitFor(() => expect(onRefreshFiles).toHaveBeenCalledTimes(1));
});
// PageCreator flows are unreachable while the 新建空白页面 launcher entry
// is paused (see ENABLE_BLANK_PAGE_WORKSPACE_ENTRYPOINT); these suites
// revive automatically when the switch flips back.
it.skipIf(!ENABLE_BLANK_PAGE_WORKSPACE_ENTRYPOINT)('hides blank cards and media category entries in the page creator dialog', async () => {
render(
<FileWorkspace
projectId="project-1"
projectKind="slide_deck"
files={[]}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
isDeck={false}
tabsState={{ tabs: [], active: null }}
onTabsStateChange={vi.fn()}
/>,
);
fireEvent.click(screen.getByTestId('workspace-add-tab'));
fireEvent.click(screen.getByRole('button', { name: /New blank page/i }));
const dialog = await screen.findByRole('dialog', { name: 'Create page' });
const dialogScope = within(dialog);
expect(dialogScope.queryByText('New blank page')).toBeNull();
expect(dialogScope.queryByRole('button', { name: /^Image\b/i })).toBeNull();
expect(dialogScope.queryByRole('button', { name: /^Video\b/i })).toBeNull();
expect(dialogScope.queryByRole('button', { name: /^Audio\b/i })).toBeNull();
});
it('reports an upload failure until dismissed, and opens a file on a single card click', async () => {
mockedUploadProjectFiles.mockRejectedValueOnce(new Error('storage offline'));
const onTabsStateChange = vi.fn();
render(
<FileWorkspace
projectId="project-1"
projectKind="prototype"
files={[baseFile()]}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
isDeck={false}
tabsState={{ tabs: [], active: null }}
onTabsStateChange={onTabsStateChange}
/>,
);
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',
);
});
fireEvent.click(screen.getByTestId('upload-error-dismiss'));
expect(screen.queryByTestId('upload-error-banner')).toBeNull();
// Images render as masonry cards; a single click on the thumb opens the
// file in a workspace tab (there is no in-panel preview pane to land in).
const row = screen.getByTestId('design-file-row-mock.png');
const thumbButton = row.querySelector<HTMLButtonElement>('.df-card-thumb');
if (!thumbButton) throw new Error('Could not find file thumb button');
fireEvent.click(thumbButton);
await waitFor(() =>
expect(onTabsStateChange).toHaveBeenCalledWith(
expect.objectContaining({ active: 'mock.png' }),
),
);
});
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} />);
// Folder rows live behind the Folders category tab (the default tab is
// Pages whenever HTML files exist at the current level).
fireEvent.click(screen.getByTestId('design-files-tab-folders'));
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'),
]}
/>,
);
// #5517: the breadcrumb root falls back to designFiles.crumbs ("Project")
// instead of the removed workspace.allProjectFiles label.
expect(container.querySelector('.df-breadcrumb-current')?.textContent).toBe('Project');
expect(screen.getByTestId('design-file-row-home.html')).toBeTruthy();
});
// The workspace's `streaming` prop is the composer's "actions disabled"
// state: it is also true for a read-only viewer of a shared project with
// nothing running. The building preview must key off a real run, or that
// viewer sees their page under a cursor captioned "thinking".
it('keys the building preview off a run in flight, not the disabled-actions state', () => {
const baseProps: React.ComponentProps<typeof FileWorkspace> = {
projectId: 'project-a',
projectKind: 'prototype',
files: [workspaceFile('index.html')],
messages: [{ id: 'active-run', role: 'assistant', content: '', startedAt: 1699999999 }],
liveArtifacts: [],
onRefreshFiles: vi.fn(),
isDeck: false,
tabsState: { tabs: [], active: null },
onTabsStateChange: vi.fn(),
};
const { rerender } = render(<FileWorkspace {...baseProps} streaming />);
expect(screen.queryByTestId('design-files-building')).toBeNull();
expect(screen.getByTestId('design-file-row-index.html')).toBeTruthy();
rerender(<FileWorkspace {...baseProps} streaming runInFlight />);
expect(screen.getByTestId('design-files-building')).toBeTruthy();
expect(screen.queryByTestId('design-file-row-index.html')).toBeNull();
});
it('keeps an existing page in the grid until the current run writes HTML', () => {
const startedAt = 1_800_000_000_000;
const oldPage = { ...workspaceFile('index.html'), mtime: startedAt - 10_000 };
const props: React.ComponentProps<typeof FileWorkspace> = {
projectId: 'project-a', projectKind: 'prototype',
files: [oldPage], liveArtifacts: [], onRefreshFiles: vi.fn(), isDeck: false,
tabsState: { tabs: [], active: null }, onTabsStateChange: vi.fn(),
runInFlight: true,
messages: [{ id: 'run-1', role: 'assistant', content: '', startedAt, runStatus: 'running' }],
};
const { rerender } = render(<FileWorkspace {...props} />);
expect(screen.queryByTestId('design-files-building')).toBeNull();
expect(screen.queryByTestId('design-files-preview-toggle')).toBeNull();
expect(screen.getByTestId('design-file-row-index.html')).toBeTruthy();
rerender(<FileWorkspace {...props} files={[oldPage, {
...workspaceFile('notes.md'), kind: 'text', mtime: startedAt + 100,
}]} />);
expect(screen.queryByTestId('design-files-building')).toBeNull();
const writtenPage = { ...oldPage, mtime: startedAt + 200 };
rerender(<FileWorkspace {...props} files={[writtenPage]} />);
expect(screen.getByTestId('design-files-building')).toBeTruthy();
rerender(<FileWorkspace {...props} files={[writtenPage]} messages={[
...props.messages!, { id: 'next-user', role: 'user', content: 'Write notes' },
]} />);
expect(screen.queryByTestId('design-files-building')).toBeNull();
rerender(<FileWorkspace {...props} files={[writtenPage]} messages={[
{ id: 'run-2', role: 'assistant', content: '', startedAt: startedAt + 300, runStatus: 'running' },
]} />);
expect(screen.queryByTestId('design-files-building')).toBeNull();
});
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. In focus mode
// the project tab strip's dock host sits between them (the strip portals
// into it — see workspaceTabsDock.ts), so allow it in the order check.
expect(markup).toMatch(
/<div class="ws-tabs-shell">\s*<button[^>]*data-testid="workspace-focus-toggle"[\s\S]*?<\/button>\s*(?:<div class="ws-tabs-project-dock"[^>]*><\/div>\s*)?<div class="ws-tabs-bar"/,
);
});
it('keeps the pages switcher before opened file tabs', () => {
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('keeps the active HTML preview full-sized and prewarms file revisions across Design Files round-trips', async () => {
const file = workspaceFile('artifact.html');
mockedFetchProjectFileText.mockResolvedValue('<html><body>artifact</body></html>');
function Harness() {
const [mtime, setMtime] = useState(file.mtime);
const [tabsState, setTabsState] = useState<OpenTabsState>({
tabs: [file.name],
active: file.name,
});
return (
<>
<button type="button" data-testid="advance-artifact-revision" onClick={() => setMtime(2_000_000_000)}>
advance revision
</button>
<IframeKeepAliveProvider>
<CollabProvider value={collabValue(teamContext('workspace-a', 'member-a'))}>
<FileWorkspace
projectId="project-1"
projectKind="prototype"
files={[{ ...file, mtime }]}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
isDeck={false}
tabsState={tabsState}
onTabsStateChange={setTabsState}
/>
</CollabProvider>
</IframeKeepAliveProvider>
</>
);
}
const { container } = render(<Harness />);
await waitFor(() => {
expect(mockedFetchProjectFileText).toHaveBeenCalledTimes(1);
});
const firstFrame = screen.getByTestId('artifact-preview-frame');
const initialSrc = firstFrame.getAttribute('src');
const retainedViewer = screen.getByTestId('retained-file-viewer');
expect(retainedViewer.style.display).toBe('flex');
for (let round = 0; round < 10; round += 1) {
fireEvent.click(screen.getByTestId('design-files-tab'));
expect(screen.getByTestId('retained-file-viewer')).toBe(retainedViewer);
expect(retainedViewer.getAttribute('aria-hidden')).toBe('true');
expect(retainedViewer.hasAttribute('inert')).toBe(true);
expect(retainedViewer.hasAttribute('hidden')).toBe(false);
expect(retainedViewer.style.display).toBe('flex');
expect(retainedViewer.style.position).toBe('absolute');
expect(retainedViewer.style.inset).toBe('0px');
expect(retainedViewer.style.width).toBe('100%');
expect(retainedViewer.style.height).toBe('100%');
expect(retainedViewer.style.opacity).toBe('0');
expect(retainedViewer.style.visibility).toBe('');
// Main keeps ordinary previews warm; only Motion audio follows host visibility.
expect(firstFrame.getAttribute('data-od-active')).toBe('true');
expect(firstFrame.getAttribute('data-od-motion-active')).toBe('false');
expect(container.querySelector('.iframe-keep-alive-pool iframe')).toBeNull();
if (round === 0) {
fireEvent.click(screen.getByTestId('advance-artifact-revision'));
await waitFor(() => expect(firstFrame.getAttribute('src')).toContain('v=2000000000'));
}
const prewarmedSrc = firstFrame.getAttribute('src');
fireEvent.click(screen.getByRole('tab', { name: /artifact\.html/i }));
expect(screen.getByTestId('artifact-preview-frame')).toBe(firstFrame);
expect(firstFrame.getAttribute('data-od-active')).toBe('true');
expect(firstFrame.getAttribute('data-od-motion-active')).toBe('true');
expect(screen.getByTestId('retained-file-viewer')).toBe(retainedViewer);
expect(retainedViewer.style.display).toBe('flex');
expect(retainedViewer.style.opacity).toBe('');
expect(retainedViewer.style.visibility).toBe('');
expect(retainedViewer.hasAttribute('inert')).toBe(false);
expect(firstFrame.getAttribute('src')).toBe(prewarmedSrc);
}
expect(firstFrame.getAttribute('src')).not.toBe(initialSrc);
expect(mockedFetchProjectFileText).toHaveBeenCalledTimes(2);
});
it('keeps warmed HTML preview frames connected while switching between files', async () => {
const alpha = workspaceFile('alpha.html');
const beta = workspaceFile('beta.html');
mockedFetchProjectFileText.mockImplementation(async (_projectId, fileName) => (
`<html><body>${fileName}</body></html>`
));
function Harness() {
const [tabsState, setTabsState] = useState<OpenTabsState>({
tabs: [alpha.name, beta.name],
active: alpha.name,
});
return (
<IframeKeepAliveProvider>
<CollabProvider value={collabValue(teamContext('workspace-a', 'member-a'))}>
<FileWorkspace
projectId="project-1"
projectKind="prototype"
files={[alpha, beta]}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
isDeck={false}
tabsState={tabsState}
onTabsStateChange={setTabsState}
/>
</CollabProvider>
</IframeKeepAliveProvider>
);
}
const { container } = render(<Harness />);
await waitFor(() => expect(mockedFetchProjectFileText).toHaveBeenCalledTimes(1));
const alphaFrame = screen.getByTestId('artifact-preview-frame');
fireEvent.click(screen.getByRole('tab', { name: /beta\.html/i }));
await waitFor(() => expect(mockedFetchProjectFileText).toHaveBeenCalledTimes(2));
const betaFrame = screen.getByTestId('artifact-preview-frame');
expect(betaFrame).not.toBe(alphaFrame);
expect(container.querySelector('.iframe-keep-alive-pool iframe')).toBeNull();
expect(document.body.contains(alphaFrame)).toBe(true);
const retainedAfterBeta = screen.getAllByTestId('retained-file-viewer');
expect(retainedAfterBeta.map((viewer) => viewer.getAttribute('data-file-name'))).toEqual([
alpha.name,
beta.name,
]);
fireEvent.click(screen.getByRole('tab', { name: /alpha\.html/i }));
expect(screen.getByTestId('artifact-preview-frame')).toBe(alphaFrame);
expect(container.querySelector('.iframe-keep-alive-pool iframe')).toBeNull();
expect(document.body.contains(betaFrame)).toBe(true);
expect(screen.getAllByTestId('retained-file-viewer')).toEqual(retainedAfterBeta);
expect(mockedFetchProjectFileText).toHaveBeenCalledTimes(2);
});
it.each(['beta', 'design-files', 'initial-design-files', 'initial-route-pending'] as const)(
'S13 consumes same-file login update intent on %s selection without resurrecting on return to A',
async (destination) => {
const alpha = workspaceFile('alpha.html');
const beta = workspaceFile('beta.html');
const workspace = teamContext('workspace-a', 'member-a');
const authorizationScopeKey = fileViewerSourceAuthorizationScopeKey(false, workspace) ?? '';
if (!authorizationScopeKey) throw new Error('missing test workspace scope');
const onLoginUpdateRequestHandled = vi.fn();
const publishFetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url.includes('publish-public')) return new Response(JSON.stringify(init?.method === 'POST'
? { url: 'https://example.invalid/alpha', slug: 'alpha', fileName: alpha.name }
: { publication: { url: 'https://example.invalid/alpha', slug: 'alpha', fileName: alpha.name },
status: 'active', freshness: 'outdated' }), { status: 200 });
if (url.includes('/share-state')) return new Response(JSON.stringify({
projectId: 'project-1', hasEverShared: true, bindingExists: true,
publications: [{ sourceFilePath: alpha.name, slug: 'alpha', status: 'active' }],
}), { status: 200 });
if (url.includes('/integrations/vela/status')) return new Response(JSON.stringify({
loggedIn: true, loginInFlight: false, user: { id: 'account-1' },
}), { status: 200 });
return new Response('{}', { status: 200 });
});
if (destination === 'initial-design-files' || destination === 'initial-route-pending') {
vi.stubGlobal('fetch', publishFetch);
}
mockedFetchProjectFileText.mockImplementation(async (_projectId, fileName) => (
`<html><body>${fileName}</body></html>`
));
function Harness() {
const [tabsState, setTabsState] = useState<OpenTabsState>({
tabs: [alpha.name, beta.name],
active: destination.startsWith('initial-') ? null : alpha.name,
});
const [openRequest, setOpenRequest] = useState<{ name: string; nonce: number } | null>(null);
const [request, setRequest] = useState<ObservedShareUpdateRequest | null>({
nonce: 17, accountId: 'account-1', expiresAt: Date.now() + 60_000,
link: { status: 'active', projectId: 'project-1', filePath: alpha.name,
slug: 'alpha', url: 'https://example.invalid/alpha', workspaceId: workspace.workspaceId,
workspaceMemberId: workspace.workspaceMemberId, authorizationScopeKey, freshness: 'outdated' },
});
return <><button type="button" data-testid="open-requested-route-file"
onClick={() => setOpenRequest({ name: alpha.name, nonce: 1 })}>Open requested route file</button>
<IframeKeepAliveProvider>
<CollabProvider value={collabValue(workspace)}>
<FileWorkspace projectId="project-1" projectKind="prototype" files={[alpha, beta]}
liveArtifacts={[]} onRefreshFiles={vi.fn()} isDeck={false}
tabsState={tabsState} onTabsStateChange={setTabsState}
routeFileName={destination === 'initial-route-pending' ? alpha.name : null}
openRequest={openRequest}
loginUpdateRequest={request}
onLoginUpdateRequestHandled={(nonce) => {
onLoginUpdateRequestHandled(nonce);
setRequest(null);
}} />
</CollabProvider>
</IframeKeepAliveProvider></>;
}
render(<Harness />);
if (destination === 'initial-route-pending') {
expect(onLoginUpdateRequestHandled).not.toHaveBeenCalled();
fireEvent.click(screen.getByTestId('open-requested-route-file'));
await waitFor(() => expect(publishFetch.mock.calls.filter(([url, init]) =>
String(url).includes('publish-public') && init?.method === 'POST')).toHaveLength(1));
expect(onLoginUpdateRequestHandled).toHaveBeenCalledExactlyOnceWith(17);
return;
}
if (destination === 'initial-design-files') {
await waitFor(() => expect(onLoginUpdateRequestHandled).toHaveBeenCalledExactlyOnceWith(17));
fireEvent.click(screen.getByRole('tab', { name: /alpha\.html/i }));
await waitFor(() => expect(mockedFetchProjectFileText).toHaveBeenCalled());
expect(onLoginUpdateRequestHandled).toHaveBeenCalledTimes(1);
expect(publishFetch.mock.calls.filter(([url, init]) =>
String(url).includes('publish-public') && init?.method === 'POST')).toHaveLength(0);
return;
}
await waitFor(() => expect(mockedFetchProjectFileText).toHaveBeenCalled());
expect(onLoginUpdateRequestHandled).not.toHaveBeenCalled();
if (destination === 'beta') fireEvent.click(screen.getByRole('tab', { name: /beta\.html/i }));
else fireEvent.click(screen.getByTestId('design-files-tab'));
await waitFor(() => expect(onLoginUpdateRequestHandled).toHaveBeenCalledExactlyOnceWith(17));
fireEvent.click(screen.getByRole('tab', { name: /alpha\.html/i }));
expect(onLoginUpdateRequestHandled).toHaveBeenCalledTimes(1);
},
);
it('evicts the fourth HTML tab without reattaching the three surviving preview frames', async () => {
const files = ['alpha.html', 'beta.html', 'gamma.html', 'delta.html'].map(workspaceFile);
mockedFetchProjectFileText.mockImplementation(async (_projectId, fileName) => (
`<html><body>${fileName}</body></html>`
));
function Harness() {
const [tabsState, setTabsState] = useState<OpenTabsState>({
tabs: files.map((file) => file.name),
active: 'alpha.html',
});
return (
<IframeKeepAliveProvider>
<CollabProvider value={collabValue(teamContext('workspace-a', 'member-a'))}>
<FileWorkspace
projectId="project-1"
projectKind="prototype"
files={files}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
isDeck={false}
tabsState={tabsState}
onTabsStateChange={setTabsState}
/>
</CollabProvider>
</IframeKeepAliveProvider>
);
}
render(<Harness />);
fireEvent.click(screen.getByRole('tab', { name: /beta\.html/i }));
await waitFor(() => expect(mockedFetchProjectFileText).toHaveBeenCalledTimes(2));
fireEvent.click(screen.getByRole('tab', { name: /gamma\.html/i }));
await waitFor(() => expect(mockedFetchProjectFileText).toHaveBeenCalledTimes(3));
const survivingFrames = ['beta.html', 'gamma.html'].map((name) => (
document.querySelector(`iframe[title="${name}"][data-od-render-mode="url-load"]`)
));
expect(survivingFrames.every(Boolean)).toBe(true);
const appendSpy = vi.spyOn(Node.prototype, 'appendChild');
fireEvent.click(screen.getByRole('tab', { name: /delta\.html/i }));
await waitFor(() => expect(mockedFetchProjectFileText).toHaveBeenCalledTimes(4));
await waitFor(() => expect(document.querySelector('iframe[title="alpha.html"]')).toBeNull());
for (const [index, name] of ['beta.html', 'gamma.html'].entries()) {
const frame = survivingFrames[index];
expect(document.querySelector(
`iframe[title="${name}"][data-od-render-mode="url-load"]`,
)).toBe(frame);
expect(appendSpy.mock.calls.filter(([node]) => node === frame)).toHaveLength(0);
}
});
it('deletes the active HTML viewer without reattaching a surviving warm iframe', async () => {
const alpha = workspaceFile('alpha.html');
const beta = workspaceFile('beta.html');
mockedFetchProjectFileText.mockImplementation(async (_projectId, fileName) => (
`<html><body>${fileName}</body></html>`
));
function Harness({ files, generation }: { files: ProjectFile[]; generation: number }) {
const [tabsState, setTabsState] = useState<OpenTabsState>({
tabs: [alpha.name, beta.name],
active: alpha.name,
});
return (
<IframeKeepAliveProvider>
<CollabProvider value={collabValue(teamContext('workspace-a', 'member-a'))}>
<FileWorkspace
projectId="project-1"
projectKind="prototype"
files={files}
filesGeneration={generation}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
isDeck={false}
tabsState={tabsState}
onTabsStateChange={setTabsState}
/>
</CollabProvider>
</IframeKeepAliveProvider>
);
}
const { rerender } = render(<Harness files={[alpha, beta]} generation={1} />);
await waitFor(() => expect(document.querySelector(
'iframe[title="alpha.html"][data-od-render-mode="url-load"]',
)).not.toBeNull());
fireEvent.click(screen.getByRole('tab', { name: /beta\.html/i }));
await waitFor(() => expect(document.querySelector(
'iframe[title="alpha.html"][data-od-render-mode="url-load"]',
)).not.toBeNull());
const alphaFrame = document.querySelector(
'iframe[title="alpha.html"][data-od-render-mode="url-load"]',
);
expect(alphaFrame).not.toBeNull();
const readsBeforeDelete = mockedFetchProjectFileText.mock.calls.length;
const appendSpy = vi.spyOn(Node.prototype, 'appendChild');
rerender(<Harness files={[alpha]} generation={2} />);
await waitFor(() => expect(document.querySelector('iframe[title="beta.html"]')).toBeNull());
expect(document.querySelector(
'iframe[title="alpha.html"][data-od-render-mode="url-load"]',
)).toBe(alphaFrame);
expect(appendSpy.mock.calls.filter(([node]) => node === alphaFrame)).toHaveLength(0);
expect(mockedFetchProjectFileText).toHaveBeenCalledTimes(readsBeforeDelete);
});
it('keeps warmed HTML preview frames through equivalent context refreshes and transient empty file snapshots', async () => {
const alphaName = 'alpha.html';
const betaName = 'beta.html';
mockedFetchProjectFileText.mockImplementation(async (_projectId, fileName) => (
`<html><body>${fileName}</body></html>`
));
function Harness({
active,
files,
tabs,
workspaceContext,
filesRefreshKey = 0,
}: {
active: string;
files: ProjectFile[];
tabs: string[];
workspaceContext: WorkspaceCollabContext;
filesRefreshKey?: number;
}) {
return (
<IframeKeepAliveProvider>
<CollabProvider value={collabValue(workspaceContext)}>
<FileWorkspace
projectId="project-1"
projectKind="prototype"
files={files}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
isDeck={false}
tabsState={{ tabs, active }}
onTabsStateChange={vi.fn()}
filesRefreshKey={filesRefreshKey}
/>
</CollabProvider>
</IframeKeepAliveProvider>
);
}
const workspaceContext = teamContext('workspace-a', 'member-a');
const { rerender } = render(
<Harness
active={alphaName}
files={[workspaceFile(alphaName), workspaceFile(betaName)]}
tabs={[alphaName, betaName]}
workspaceContext={workspaceContext}
/>,
);
await waitFor(() => expect(mockedFetchProjectFileText).toHaveBeenCalledTimes(1));
const alphaFrame = screen.getByTestId('artifact-preview-frame');
rerender(
<Harness
active={betaName}
files={[workspaceFile(alphaName), workspaceFile(betaName)]}
tabs={[alphaName, betaName]}
workspaceContext={{ ...workspaceContext }}
/>,
);
await waitFor(() => expect(mockedFetchProjectFileText).toHaveBeenCalledTimes(2));
const betaFrame = screen.getByTestId('artifact-preview-frame');
// Ambient workspace refreshes can briefly publish an empty file snapshot.
// Open tabs are the durable witness that these files were not closed or
// deleted, so both warmed iframe nodes must stay connected through it.
rerender(
<Harness
active={betaName}
files={[]}
tabs={[alphaName, betaName]}
workspaceContext={{ ...workspaceContext }}
/>,
);
expect(document.body.contains(alphaFrame)).toBe(true);
expect(document.body.contains(betaFrame)).toBe(true);
expect(alphaFrame.closest('[data-testid="retained-file-viewer"]')).not.toBeNull();
expect(betaFrame.closest('[data-testid="retained-file-viewer"]')).not.toBeNull();
expect(document.querySelector('.iframe-keep-alive-pool iframe')).toBeNull();
rerender(
<Harness
active={alphaName}
files={[workspaceFile(alphaName), workspaceFile(betaName)]}
tabs={[alphaName, betaName]}
workspaceContext={{ ...workspaceContext }}
/>,
);
expect(screen.getByTestId('artifact-preview-frame')).toBe(alphaFrame);
expect(document.body.contains(betaFrame)).toBe(true);
expect(mockedFetchProjectFileText).toHaveBeenCalledTimes(2);
// Removing a tab is an explicit permanent close/delete witness and must
// still evict that viewer rather than retaining it forever.
rerender(
<Harness
active={alphaName}
files={[workspaceFile(alphaName), workspaceFile(betaName)]}
tabs={[alphaName]}
workspaceContext={{ ...workspaceContext }}
/>,
);
await waitFor(() => expect(document.querySelector('iframe[title="beta.html"]')).toBeNull());
expect(screen.getByTestId('artifact-preview-frame')).toBe(alphaFrame);
});
it('evicts a deleted HTML viewer after a committed file refresh even when its tab persists', async () => {
const alphaName = 'alpha.html';
const betaName = 'beta.html';
const workspaceContext = teamContext('workspace-a', 'member-a');
const tabs = [alphaName, betaName];
function Harness({ files, refreshKey }: { files: ProjectFile[]; refreshKey: number }) {
return (
<IframeKeepAliveProvider>
<CollabProvider value={collabValue(workspaceContext)}>
<FileWorkspace
projectId="project-1"
projectKind="prototype"
files={files}
filesRefreshKey={refreshKey}
filesGeneration={refreshKey}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
isDeck={false}
tabsState={{ tabs, active: alphaName }}
onTabsStateChange={vi.fn()}
/>
</CollabProvider>
</IframeKeepAliveProvider>
);
}
const initialFiles = [workspaceFile(alphaName), workspaceFile(betaName)];
const { rerender } = render(
<Harness files={initialFiles} refreshKey={0} />,
);
fireEvent.click(screen.getByRole('tab', { name: /beta\.html/i }));
await waitFor(() => expect(document.querySelector('iframe[title="beta.html"]')).not.toBeNull());
const betaFrame = document.querySelector('iframe[title="beta.html"]');
// Authorization-scoped keep-alive keys append metadata after `beta.html:`.
// A committed refresh that still contains the file must retain that frame.
rerender(<Harness files={initialFiles} refreshKey={1} />);
await waitFor(() => expect(document.querySelector('iframe[title="beta.html"]')).toBe(betaFrame));
fireEvent.click(screen.getByRole('tab', { name: /alpha\.html/i }));
rerender(<Harness files={[workspaceFile(alphaName)]} refreshKey={2} />);
await waitFor(() => expect(document.querySelector('iframe[title="beta.html"]')).toBeNull());
expect(screen.getByTestId('artifact-preview-frame').getAttribute('title')).toBe(alphaName);
});
describe('protected viewer deletion revalidation', () => {
const fileName = 'page.html';
const initialFiles = [workspaceFile(fileName)];
function Harness({
revalidatedFiles,
onFresh,
freshFailure = null,
acceptedGeneration = 3,
committedGeneration = acceptedGeneration,
}: {
revalidatedFiles: ProjectFile[];
onFresh: (options?: { fresh?: boolean }) => void;
freshFailure?: 'throw' | 'null' | null;
acceptedGeneration?: number;
committedGeneration?: number;
}) {
const [snapshot, setSnapshot] = useState({ files: initialFiles, generation: 1 });
return (
<IframeKeepAliveProvider>
<CollabProvider value={collabValue(teamContext('workspace-a', 'member-a'))}>
<button
type="button"
data-testid="commit-r1-missing"
onClick={() => setSnapshot({ files: [], generation: 2 })}
>
delete witness
</button>
<button
type="button"
data-testid="commit-later-missing"
onClick={() => setSnapshot({ files: [], generation: 4 })}
>
later missing witness
</button>
<FileWorkspace
projectId="project-1"
projectKind="prototype"
files={snapshot.files}
filesRefreshKey={7}
filesGeneration={snapshot.generation}
liveArtifacts={[]}
onRefreshFiles={async (options) => {
onFresh(options);
if (options?.fresh) {
if (freshFailure === 'throw') throw new Error('fresh read failed');
if (freshFailure === 'null') return { acceptedGeneration: null };
setSnapshot({ files: revalidatedFiles, generation: committedGeneration });
return { acceptedGeneration };
}
return { acceptedGeneration: null };
}}
isDeck={false}
tabsState={{ tabs: [fileName], active: fileName }}
onTabsStateChange={vi.fn()}
/>
</CollabProvider>
</IframeKeepAliveProvider>
);
}
async function enterManualEdit() {
const toggle = await screen.findByTestId('manual-edit-mode-toggle');
fireEvent.click(toggle);
await waitFor(() => expect(toggle.getAttribute('aria-pressed')).toBe('true'));
await waitFor(() => {
expect(screen.getByTestId('artifact-preview-frame').getAttribute('data-od-render-mode')).toBe('srcdoc');
});
return toggle;
}
it('purges a protected no-op editor only after the fresh R2 still reports it missing', async () => {
mockedFetchProjectFileText.mockResolvedValue('<html><body>Page</body></html>');
const onFresh = vi.fn();
render(<Harness revalidatedFiles={[]} onFresh={onFresh} />);
await enterManualEdit();
const frame = screen.getByTestId('artifact-preview-frame');
fireEvent.click(screen.getByTestId('commit-r1-missing'));
await waitFor(() => expect(onFresh).toHaveBeenCalledWith({ fresh: true }));
await waitFor(() => expect(document.body.contains(frame)).toBe(false));
});
it('keeps a successfully saved viewer when fresh R2 recreates it at the same refresh key', async () => {
const initialSource = '<html><body><p data-od-id="copy">Copy</p></body></html>';
mockedFetchProjectFileText.mockResolvedValue(initialSource);
let writes = 0;
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url.endsWith('/api/projects/project-1/files') && init?.method === 'POST') {
writes += 1;
return new Response(JSON.stringify({ file: workspaceFile(fileName) }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
if (url.includes('/versions')) {
return new Response(JSON.stringify({ versions: [] }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
if (url.includes('/raw/page.html')) return new Response(initialSource, { status: 200 });
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
});
vi.stubGlobal('fetch', fetchMock);
const onFresh = vi.fn();
render(<Harness revalidatedFiles={initialFiles} onFresh={onFresh} />);
await enterManualEdit();
const frame = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement;
act(() => {
window.dispatchEvent(new MessageEvent('message', {
source: frame.contentWindow,
data: {
type: 'od-edit-drag-commit',
id: 'copy',
transform: 'translate(12px, 8px)',
display: 'block',
},
}));
});
fireEvent.click(screen.getByTestId('commit-r1-missing'));
await waitFor(() => expect(writes).toBe(1));
await waitFor(() => expect(onFresh).toHaveBeenCalledWith({ fresh: true }));
expect(document.body.contains(frame)).toBe(true);
});
it('does not let the save-triggered ordinary refresh adjudicate deletion before fresh R2 completes', async () => {
const initialSource = '<html><body><p data-od-id="copy">Copy</p></body></html>';
mockedFetchProjectFileText.mockResolvedValue(initialSource);
let resolveFresh!: () => void;
const freshGate = new Promise<void>((resolve) => { resolveFresh = resolve; });
const refreshCalls = vi.fn();
function RacingHarness() {
const [snapshot, setSnapshot] = useState({ files: initialFiles, generation: 1 });
return (
<IframeKeepAliveProvider>
<CollabProvider value={collabValue(teamContext('workspace-a', 'member-a'))}>
<button
type="button"
data-testid="commit-racing-r1-missing"
onClick={() => setSnapshot({ files: [], generation: 2 })}
>
delete witness
</button>
<FileWorkspace
projectId="project-1"
projectKind="prototype"
files={snapshot.files}
filesRefreshKey={7}
filesGeneration={snapshot.generation}
liveArtifacts={[]}
onRefreshFiles={async (options) => {
refreshCalls(options);
if (!options?.fresh) {
// applyManualEdit -> onFileSaved performs this cached
// refresh before safeExit resolves.
setSnapshot({ files: [], generation: 3 });
return { acceptedGeneration: 3 };
}
await freshGate;
setSnapshot({ files: initialFiles, generation: 4 });
return { acceptedGeneration: 4 };
}}
isDeck={false}
tabsState={{ tabs: [fileName], active: fileName }}
onTabsStateChange={vi.fn()}
/>
</CollabProvider>
</IframeKeepAliveProvider>
);
}
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url.endsWith('/api/projects/project-1/files') && init?.method === 'POST') {
return new Response(JSON.stringify({ file: workspaceFile(fileName) }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
if (url.includes('/versions')) {
return new Response(JSON.stringify({ versions: [] }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
if (url.includes('/raw/page.html')) return new Response(initialSource, { status: 200 });
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
});
vi.stubGlobal('fetch', fetchMock);
render(<RacingHarness />);
await enterManualEdit();
const frame = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement;
act(() => {
window.dispatchEvent(new MessageEvent('message', {
source: frame.contentWindow,
data: {
type: 'od-edit-drag-commit',
id: 'copy',
transform: 'translate(12px, 8px)',
display: 'block',
},
}));
});
fireEvent.click(screen.getByTestId('commit-racing-r1-missing'));
await waitFor(() => expect(refreshCalls).toHaveBeenCalledWith(undefined));
await waitFor(() => expect(refreshCalls).toHaveBeenCalledWith({ fresh: true }));
expect(document.body.contains(frame)).toBe(true);
await act(async () => { resolveFresh(); });
await waitFor(() => expect(document.body.contains(frame)).toBe(true));
});
function FailedR2Harness({
mode,
ordinaryGate,
freshGate,
onRefresh,
}: {
mode: 'throw' | 'null';
ordinaryGate: Promise<void>;
freshGate: Promise<void>;
onRefresh: (options?: { fresh?: boolean }) => void;
}) {
const [snapshot, setSnapshot] = useState({ files: initialFiles, generation: 1 });
return (
<IframeKeepAliveProvider>
<CollabProvider value={collabValue(teamContext('workspace-a', 'member-a'))}>
<output data-testid="failed-r2-generation">{snapshot.generation}</output>
<button
type="button"
data-testid="failed-r2-r1-missing"
onClick={() => setSnapshot({ files: [], generation: 2 })}
>
R1 missing
</button>
<button
type="button"
data-testid="failed-r2-later-missing"
onClick={() => setSnapshot({ files: [], generation: 4 })}
>
later missing
</button>
<button
type="button"
data-testid="failed-r2-later-present"
onClick={() => setSnapshot({ files: initialFiles, generation: 4 })}
>
later present
</button>
<button
type="button"
data-testid="failed-r2-after-present-missing"
onClick={() => setSnapshot({ files: [], generation: 5 })}
>
after present missing
</button>
<FileWorkspace
projectId="project-1"
projectKind="prototype"
files={snapshot.files}
filesRefreshKey={7}
filesGeneration={snapshot.generation}
liveArtifacts={[]}
onRefreshFiles={async (options) => {
onRefresh(options);
if (!options?.fresh) {
setSnapshot({ files: [], generation: 3 });
await ordinaryGate;
return { acceptedGeneration: 3 };
}
await freshGate;
if (mode === 'throw') throw new Error('fresh read failed');
return { acceptedGeneration: null };
}}
isDeck={false}
tabsState={{ tabs: [fileName], active: fileName }}
onTabsStateChange={vi.fn()}
/>
</CollabProvider>
</IframeKeepAliveProvider>
);
}
function stubManualEditSave(source: string) {
mockedFetchProjectFileText.mockResolvedValue(source);
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url.endsWith('/api/projects/project-1/files') && init?.method === 'POST') {
return new Response(JSON.stringify({ file: workspaceFile(fileName) }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
if (url.includes('/versions')) {
return new Response(JSON.stringify({ versions: [] }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
if (url.includes('/raw/page.html')) return new Response(source, { status: 200 });
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
}));
}
async function dirtyActiveViewer(frame: HTMLIFrameElement) {
act(() => {
window.dispatchEvent(new MessageEvent('message', {
source: frame.contentWindow,
data: {
type: 'od-edit-drag-commit',
id: 'copy',
transform: 'translate(12px, 8px)',
display: 'block',
},
}));
});
}
it('waits beyond the pre-R2 save generation before a failed R2 can purge', async () => {
const source = '<html><body><p data-od-id="copy">Copy</p></body></html>';
stubManualEditSave(source);
let resolveOrdinary!: () => void;
const ordinaryGate = new Promise<void>((resolve) => { resolveOrdinary = resolve; });
const onRefresh = vi.fn();
render(
<FailedR2Harness
mode="throw"
ordinaryGate={ordinaryGate}
freshGate={Promise.resolve()}
onRefresh={onRefresh}
/>,
);
await enterManualEdit();
const frame = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement;
await dirtyActiveViewer(frame);
fireEvent.click(screen.getByTestId('failed-r2-r1-missing'));
await waitFor(() => expect(screen.getByTestId('failed-r2-generation').textContent).toBe('3'));
await act(async () => { resolveOrdinary(); });
await waitFor(() => expect(onRefresh).toHaveBeenCalledWith({ fresh: true }));
expect(document.body.contains(frame)).toBe(true);
fireEvent.click(screen.getByTestId('failed-r2-later-missing'));
await waitFor(() => expect(document.body.contains(frame)).toBe(false));
});
it('uses a later missing generation that overtakes an in-flight null R2', async () => {
const source = '<html><body><p data-od-id="copy">Copy</p></body></html>';
stubManualEditSave(source);
let resolveOrdinary!: () => void;
let resolveFresh!: () => void;
const ordinaryGate = new Promise<void>((resolve) => { resolveOrdinary = resolve; });
const freshGate = new Promise<void>((resolve) => { resolveFresh = resolve; });
const onRefresh = vi.fn();
render(
<FailedR2Harness
mode="null"
ordinaryGate={ordinaryGate}
freshGate={freshGate}
onRefresh={onRefresh}
/>,
);
await enterManualEdit();
const frame = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement;
await dirtyActiveViewer(frame);
fireEvent.click(screen.getByTestId('failed-r2-r1-missing'));
await waitFor(() => expect(screen.getByTestId('failed-r2-generation').textContent).toBe('3'));
await act(async () => { resolveOrdinary(); });
await waitFor(() => expect(onRefresh).toHaveBeenCalledWith({ fresh: true }));
fireEvent.click(screen.getByTestId('failed-r2-later-missing'));
expect(document.body.contains(frame)).toBe(true);
await act(async () => { resolveFresh(); });
await waitFor(() => expect(document.body.contains(frame)).toBe(false));
});
it('clears a failed R2 decision when a later generation contains the file', async () => {
const source = '<html><body><p data-od-id="copy">Copy</p></body></html>';
stubManualEditSave(source);
let resolveOrdinary!: () => void;
let resolveFresh!: () => void;
const ordinaryGate = new Promise<void>((resolve) => { resolveOrdinary = resolve; });
const freshGate = new Promise<void>((resolve) => { resolveFresh = resolve; });
const onRefresh = vi.fn();
render(
<FailedR2Harness
mode="null"
ordinaryGate={ordinaryGate}
freshGate={freshGate}
onRefresh={onRefresh}
/>,
);
await enterManualEdit();
const frame = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement;
await dirtyActiveViewer(frame);
fireEvent.click(screen.getByTestId('failed-r2-r1-missing'));
await waitFor(() => expect(screen.getByTestId('failed-r2-generation').textContent).toBe('3'));
await act(async () => { resolveOrdinary(); });
await waitFor(() => expect(onRefresh).toHaveBeenCalledWith({ fresh: true }));
fireEvent.click(screen.getByTestId('failed-r2-later-present'));
await act(async () => { resolveFresh(); });
await waitFor(() => expect(document.body.contains(frame)).toBe(true));
fireEvent.click(screen.getByTestId('failed-r2-after-present-missing'));
await waitFor(() => expect(document.body.contains(frame)).toBe(false));
});
it('purges when a later accepted missing snapshot overtakes the accepted fresh R2 generation', async () => {
mockedFetchProjectFileText.mockResolvedValue('<html><body>Page</body></html>');
const onFresh = vi.fn();
render(
<Harness
revalidatedFiles={[]}
onFresh={onFresh}
acceptedGeneration={3}
committedGeneration={4}
/>,
);
await enterManualEdit();
const frame = screen.getByTestId('artifact-preview-frame');
fireEvent.click(screen.getByTestId('commit-r1-missing'));
await waitFor(() => expect(onFresh).toHaveBeenCalledWith({ fresh: true }));
await waitFor(() => expect(document.body.contains(frame)).toBe(false));
});
it('retains the protected viewer and skips R2 when its dirty flush fails', async () => {
const initialSource = '<html><body><p data-od-id="copy">Copy</p></body></html>';
mockedFetchProjectFileText.mockResolvedValue(initialSource);
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url.endsWith('/api/projects/project-1/files') && init?.method === 'POST') {
return new Response(JSON.stringify({ error: { message: 'conflict' } }), {
status: 409,
headers: { 'Content-Type': 'application/json' },
});
}
if (url.includes('/versions')) {
return new Response(JSON.stringify({ versions: [] }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
if (url.includes('/raw/page.html')) return new Response(initialSource, { status: 200 });
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
});
vi.stubGlobal('fetch', fetchMock);
const onFresh = vi.fn();
render(<Harness revalidatedFiles={[]} onFresh={onFresh} />);
const toggle = await enterManualEdit();
const frame = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement;
act(() => {
window.dispatchEvent(new MessageEvent('message', {
source: frame.contentWindow,
data: {
type: 'od-edit-drag-commit',
id: 'copy',
transform: 'translate(12px, 8px)',
display: 'block',
},
}));
});
fireEvent.click(screen.getByTestId('commit-r1-missing'));
await waitFor(() => expect(fetchMock.mock.calls.some(([, init]) => init?.method === 'POST')).toBe(true));
expect(onFresh).not.toHaveBeenCalledWith({ fresh: true });
expect(document.body.contains(frame)).toBe(true);
expect(toggle.getAttribute('aria-pressed')).toBe('true');
});
});
it('keeps manual-edit viewers within the hard cap by flushing before each tab switch', async () => {
const files = ['alpha.html', 'beta.html', 'gamma.html', 'delta.html'].map(workspaceFile);
mockedFetchProjectFileText.mockImplementation(async (_projectId, fileName) => (
`<html><body><p data-od-id="copy">${fileName}</p></body></html>`
));
function Harness() {
const [tabsState, setTabsState] = useState<OpenTabsState>({
tabs: files.map((file) => file.name),
active: files[0]!.name,
});
return (
<IframeKeepAliveProvider>
<CollabProvider value={collabValue(teamContext('workspace-a', 'member-a'))}>
<FileWorkspace
projectId="project-1"
projectKind="prototype"
files={files}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
isDeck={false}
tabsState={tabsState}
onTabsStateChange={setTabsState}
/>
</CollabProvider>
</IframeKeepAliveProvider>
);
}
render(<Harness />);
for (let index = 0; index < files.length; index += 1) {
const currentName = files[index]!.name;
await waitFor(() => {
expect(screen.getByTestId('artifact-preview-frame').getAttribute('title')).toBe(currentName);
});
const activeViewer = document.querySelector<HTMLElement>(
`[data-testid="retained-file-viewer"][data-file-name="${currentName}"]`,
);
expect(activeViewer).not.toBeNull();
const editToggle = within(activeViewer!).getByTestId('manual-edit-mode-toggle');
fireEvent.click(editToggle);
await waitFor(() => {
expect(editToggle.getAttribute('aria-pressed')).toBe('true');
});
if (index < files.length - 1) {
const nextName = files[index + 1]!.name;
fireEvent.click(screen.getByRole('tab', { name: new RegExp(nextName.replace('.', '\\.')) }));
await waitFor(() => {
expect(screen.getByTestId('artifact-preview-frame').getAttribute('title')).toBe(nextName);
});
}
expect(screen.getAllByTestId('retained-file-viewer').length).toBeLessThanOrEqual(3);
}
expect(document.querySelector('iframe[title="alpha.html"]')).toBeNull();
expect(screen.getAllByTestId('manual-edit-mode-toggle').length).toBeLessThanOrEqual(3);
expect(screen.getAllByTestId('manual-edit-mode-toggle').filter(
(toggle) => toggle.getAttribute('aria-pressed') === 'true',
)).toHaveLength(1);
});
it('waits for the active inline edit before navigating away from the project', async () => {
const alpha = workspaceFile('alpha.html');
mockedFetchProjectFileText.mockResolvedValue(
'<html><body><p data-od-id="copy">alpha.html</p></body></html>',
);
window.history.replaceState(null, '', '/projects/project-1');
render(
<IframeKeepAliveProvider>
<CollabProvider value={collabValue(teamContext('workspace-a', 'member-a'))}>
<FileWorkspace
projectId="project-1"
projectKind="prototype"
files={[alpha]}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
isDeck={false}
tabsState={{ tabs: [alpha.name], active: alpha.name }}
onTabsStateChange={vi.fn()}
/>
</CollabProvider>
</IframeKeepAliveProvider>,
);
const toggle = await screen.findByTestId('manual-edit-mode-toggle');
fireEvent.click(toggle);
await waitFor(() => expect(toggle.getAttribute('aria-pressed')).toBe('true'));
const frame = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement;
act(() => {
window.dispatchEvent(new MessageEvent('message', {
source: frame.contentWindow,
data: { type: 'od-edit-text-session', id: 'copy', active: true },
}));
});
await act(async () => {
await Promise.resolve();
});
const beforeUnload = new Event('beforeunload', { cancelable: true });
expect(window.dispatchEvent(beforeUnload)).toBe(false);
expect(beforeUnload.defaultPrevented).toBe(true);
navigate({ kind: 'home', view: 'projects' });
expect(window.location.pathname).toBe('/projects/project-1');
act(() => {
window.dispatchEvent(new MessageEvent('message', {
source: frame.contentWindow,
data: {
type: 'od-edit-text-session',
id: 'copy',
active: false,
committed: true,
changed: false,
},
}));
});
await waitFor(() => expect(window.location.pathname).toBe('/projects'));
expect(toggle.getAttribute('aria-pressed')).toBe('false');
});
it('evicts the previous project preview pool when the workspace changes', async () => {
const workspaceContext = teamContext('workspace-a', 'member-a');
function Harness({ projectId, fileName }: { projectId: string; fileName: string }) {
const file = workspaceFile(fileName);
return (
<IframeKeepAliveProvider>
<CollabProvider value={collabValue(workspaceContext)}>
<FileWorkspace
projectId={projectId}
projectKind="prototype"
files={[file]}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
isDeck={false}
tabsState={{ tabs: [fileName], active: fileName }}
onTabsStateChange={vi.fn()}
/>
</CollabProvider>
</IframeKeepAliveProvider>
);
}
const { rerender } = render(<Harness projectId="project-1" fileName="old.html" />);
await waitFor(() => expect(document.querySelector('iframe[title="old.html"]')).not.toBeNull());
const oldFrame = document.querySelector('iframe[title="old.html"]');
rerender(<Harness projectId="project-2" fileName="new.html" />);
await waitFor(() => expect(document.querySelector('iframe[title="new.html"]')).not.toBeNull());
expect(document.body.contains(oldFrame)).toBe(false);
expect(document.querySelector('.iframe-keep-alive-pool iframe[title="old.html"]')).toBeNull();
});
it('evicts the least-recently-used HTML viewer after the fourth warm tab', async () => {
const files = ['alpha.html', 'beta.html', 'gamma.html', 'delta.html'].map(workspaceFile);
mockedFetchProjectFileText.mockImplementation(async (_projectId, fileName) => (
`<html><body>${fileName}</body></html>`
));
function Harness() {
const [tabsState, setTabsState] = useState<OpenTabsState>({
tabs: files.map((file) => file.name),
active: files[0]!.name,
});
return (
<IframeKeepAliveProvider>
<CollabProvider value={collabValue(teamContext('workspace-a', 'member-a'))}>
<FileWorkspace
projectId="project-1"
projectKind="prototype"
files={files}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
isDeck={false}
tabsState={tabsState}
onTabsStateChange={setTabsState}
/>
</CollabProvider>
</IframeKeepAliveProvider>
);
}
render(<Harness />);
for (const name of files.slice(1).map((file) => file.name)) {
fireEvent.click(screen.getByRole('tab', { name: new RegExp(name.replace('.', '\\.')) }));
await waitFor(() => expect(screen.getByTestId('artifact-preview-frame').getAttribute('title')).toBe(name));
}
await waitFor(() => {
expect(screen.getAllByTestId('retained-file-viewer').map((viewer) => viewer.getAttribute('data-file-name')))
.toEqual(['beta.html', 'gamma.html', 'delta.html']);
});
expect(document.querySelector('iframe[title="alpha.html"]')).toBeNull();
expect(mockedFetchProjectFileText).toHaveBeenCalledTimes(4);
fireEvent.click(screen.getByRole('tab', { name: /alpha\.html/i }));
await waitFor(() => expect(screen.getByTestId('artifact-preview-frame').getAttribute('title')).toBe('alpha.html'));
// The evicted frame remounts from its exact-version source snapshot. It
// must not add a fifth raw read before the file revision changes.
expect(mockedFetchProjectFileText).toHaveBeenCalledTimes(4);
await waitFor(() => {
expect(screen.getAllByTestId('retained-file-viewer').map((viewer) => viewer.getAttribute('data-file-name')))
.toEqual(['alpha.html', 'gamma.html', 'delta.html']);
});
});
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('shows Design Files when the persisted active file no longer exists', () => {
render(
<FileWorkspace
projectId="project-1"
projectKind="prototype"
files={[]}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
isDeck={false}
tabsState={{ tabs: ['missing.png'], active: 'missing.png' }}
onTabsStateChange={vi.fn()}
/>,
);
expect(screen.getByTestId('design-files-tab')).toHaveAttribute('aria-selected', 'true');
expect(screen.getByTestId('design-files-empty')).toBeTruthy();
expect(screen.queryByText(/Open a file from/i)).toBeNull();
expect(renderedTabLabels()).toEqual(['Design Files']);
});
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'));
const launcherMenu = within(screen.getByTestId('tab-launcher-menu'));
expect(launcherMenu.queryByRole('button', { name: /New Terminal/i })).toBeNull();
expect(launcherMenu.getByRole('button', { name: /New Browser/i })).toBeTruthy();
expect(launcherMenu.getByRole('button', { name: /New sketch/i })).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(screen.getByTestId('design-files-tab').textContent).toContain('Design Files');
// Design Files is a plain tab in the strip (role="tab"), so it is part of
// the rendered tab list rather than a dropdown trigger sitting outside it.
expect(renderedTabLabels()).toEqual([
'Design Files',
'Browser',
'New Terminal',
'Side chat',
]);
});
it('shows project sync progress on the Design Files root tab without hiding materialized files', () => {
render(
<FileWorkspace
projectId="project-1"
projectKind="prototype"
files={[workspaceFile('notes.txt')]}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
isDeck={false}
viewerOnly
fileSyncBadge="downloading"
tabsState={{ tabs: [], active: DESIGN_FILES_TAB }}
onTabsStateChange={vi.fn()}
/>,
);
const rootTab = screen.getByTestId('design-files-tab');
expect(rootTab.title).toContain('Downloading from the team');
expect(rootTab.getAttribute('aria-label')).toContain('Downloading from the team');
expect(rootTab.querySelector('svg')).toBeTruthy();
expect(screen.getByText('notes.txt')).toBeTruthy();
expect(screen.queryByTestId('design-files-syncing')).toBeNull();
});
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 toastAnchor = document.querySelector('.workspace-toast-anchor');
expect(toastAnchor).toBeTruthy();
expect(toastAnchor?.querySelector('.od-toast-browser-snapshot')).toBeTruthy();
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 the browser snapshot toast inside the workspace pane', async () => {
// The Download Page progress/result toast is workspace-owned UI. Rendered
// as a bare fixed .od-toast it centers on the whole viewport, drifting
// over the chat pane and covering the composer send area in split view;
// the anchor scopes it to the workspace pane instead.
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={vi.fn()}
/>,
);
fireEvent.click(screen.getByTestId('emit-browser-snapshot-success'));
const toast = await waitFor(() => {
const node = document.querySelector<HTMLElement>('.od-toast');
expect(node?.textContent).toContain('Saved page snapshot');
return node as HTMLElement;
});
expect(toast.closest('.workspace-toast-anchor')).toBeTruthy();
expect(toast.closest('[data-testid="file-workspace"]')).toBeTruthy();
});
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/',
},
];
const request = { name: 'landing.html', nonce: 1 };
function Host() {
const [tabs, setTabs] = useState<OpenTabsState>({ tabs: ['cover.html'], active: '__browser__:1', browserTabs });
return <FileWorkspace
projectId="project-1"
projectKind="prototype"
files={[workspaceFile('cover.html'), workspaceFile('landing.html')]}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
isDeck={false}
tabsState={tabs}
shareRequest={request}
onTabsStateChange={next => { onTabsStateChange(next); setTabs(next); }}
/>;
}
render(<Host />);
await waitFor(() => {
expect(onTabsStateChange).toHaveBeenCalledWith({
tabs: ['cover.html', 'landing.html'],
active: 'landing.html',
browserTabs,
});
});
expect(await screen.findByRole('heading', { name: 'Share', level: 2 })).toBeVisible();
});
it('F13: two artifact-card requests at the same clock tick reopen the real Share panel after close', async () => {
const onTabsStateChange = vi.fn();
let lastNonce = 100;
function Host() {
const [tabs, setTabs] = useState<OpenTabsState>({ tabs: ['cover.html'], active: '__browser__:1', browserTabs: [{ id: '__browser__:1', label: 'Browser 1', title: 'Dribbble', url: 'https://dribbble.com/' }] });
const [shareRequest, setShareRequest] = useState<{ name: string; nonce: number; anchorId: string } | null>({ name: 'landing.html', nonce: 100, anchorId: 'artifact-card' });
return <>
<button type="button" data-artifact-anchor="artifact-card" onClick={() => setShareRequest(previous => {
const nonce = nextShareRequestNonce(previous?.nonce, 100);
lastNonce = nonce;
return { name: 'landing.html', nonce, anchorId: 'artifact-card' };
})}>Artifact card Share</button>
<FileWorkspace projectId="project-1" projectKind="prototype" files={[workspaceFile('cover.html'), workspaceFile('landing.html')]}
liveArtifacts={[]} onRefreshFiles={vi.fn()} isDeck={false} tabsState={tabs}
shareRequest={shareRequest} onTabsStateChange={next => { onTabsStateChange(next); setTabs(next); }} />
</>;
}
render(<Host />);
await waitFor(() => expect(onTabsStateChange).toHaveBeenCalledWith(expect.objectContaining({ active: 'landing.html' })));
expect(await screen.findByRole('heading', { name: 'Share', level: 2 })).toBeVisible();
expect(lastNonce).toBe(100);
fireEvent.click(screen.getByRole('button', { name: 'Close' }));
await waitFor(() => expect(screen.queryByRole('heading', { name: 'Share', level: 2 })).toBeNull());
fireEvent.click(screen.getByRole('button', { name: 'Artifact card Share' }));
expect(lastNonce).toBe(101);
expect(await screen.findByRole('heading', { name: 'Share', level: 2 })).toBeVisible();
expect(onTabsStateChange).toHaveBeenCalledTimes(2);
expect(onTabsStateChange).toHaveBeenLastCalledWith(expect.objectContaining({ active: 'landing.html', browserTabs: [expect.objectContaining({ url: 'https://dribbble.com/' })] }));
});
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('S14 design-system project delete requires authoritative active-share warning before backing-project deletion', async () => {
const onDeleteDesignSystemProject = vi.fn(async () => false);
const request = vi.fn(async (url: RequestInfo | URL) => {
if (String(url) === '/api/projects/project-1/share-state') return Response.json({
projectId: 'project-1', bindingExists: true, hasEverShared: true,
publications: [{ slug: 'shared-kit', sourceFilePath: 'kit.html', status: 'active' }],
});
return new Response('', { status: 200 });
});
vi.stubGlobal('fetch', request);
vi.stubGlobal('confirm', vi.fn(() => true));
render(
<CollabProvider value={collabValue(teamContext('workspace-a', 'member-a'))}>
<FileWorkspace
projectId="project-1" projectKind="prototype" files={[workspaceFile('kit.html')]}
liveArtifacts={[]} onRefreshFiles={vi.fn()} isDeck={false}
tabsState={{ tabs: [], active: '__design_system__' }} onTabsStateChange={vi.fn()}
designSystemProject={{ id: 'neutral-modern', title: 'Neutral Modern', category: 'Starter', source: 'bundled', updatedAt: 1 } as never}
designSystemEditable onDeleteDesignSystemProject={onDeleteDesignSystemProject}
/>
</CollabProvider>,
);
fireEvent.click(await screen.findByRole('button', { name: 'More actions' }));
fireEvent.click(screen.getByRole('menuitem', { name: 'Delete Neutral Modern' }));
const dialog = await screen.findByTestId('project-delete-confirm-dialog');
await waitFor(() => expect(within(dialog).getByText(/1 page.*shared|1 active/i)).toBeTruthy());
expect(onDeleteDesignSystemProject).not.toHaveBeenCalled();
fireEvent.click(within(dialog).getByTestId('project-delete-confirm-accept'));
await waitFor(() => expect(onDeleteDesignSystemProject).toHaveBeenCalledWith('project-1'));
});
it.each(['missing workspace identity', 'share-state HTTP failure'])(
'S14 design-system delete blocks unknown active-share count: %s', async (failure) => {
const onDeleteDesignSystemProject = vi.fn(async () => true);
const request = vi.fn(async (_url: RequestInfo | URL) => new Response('', { status: 503 }));
vi.stubGlobal('fetch', request);
vi.stubGlobal('confirm', vi.fn(() => true));
const panel = (
<FileWorkspace
projectId="project-1" projectKind="prototype" files={[workspaceFile('kit.html')]}
liveArtifacts={[]} onRefreshFiles={vi.fn()} isDeck={false}
tabsState={{ tabs: [], active: '__design_system__' }} onTabsStateChange={vi.fn()}
designSystemProject={{ id: 'neutral-modern', title: 'Neutral Modern', category: 'Starter', source: 'bundled', updatedAt: 1 } as never}
designSystemEditable onDeleteDesignSystemProject={onDeleteDesignSystemProject}
/>
);
render(failure === 'missing workspace identity' ? panel
: <CollabProvider value={collabValue(teamContext('workspace-a', 'member-a'))}>{panel}</CollabProvider>);
fireEvent.click(await screen.findByRole('button', { name: 'More actions' }));
fireEvent.click(screen.getByRole('menuitem', { name: 'Delete Neutral Modern' }));
const dialog = await screen.findByTestId('project-delete-confirm-dialog');
await waitFor(() => expect(within(dialog).getByTestId('project-delete-confirm-accept').hasAttribute('disabled')).toBe(true));
expect(onDeleteDesignSystemProject).not.toHaveBeenCalled();
if (failure === 'missing workspace identity') {
expect(request.mock.calls.some(([url]) => String(url).endsWith('/share-state'))).toBe(false);
} else {
await waitFor(() => expect(request.mock.calls.some(([url]) => String(url).endsWith('/share-state'))).toBe(true));
}
},
);
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('reloads design-system source files under the complete pinned Workspace identity', async () => {
const workspaceA = teamContext('workspace-a', 'member-a');
const workspaceB = teamContext('workspace-b', 'member-b');
const props = {
projectId: 'project-1',
projectKind: 'prototype' as const,
files: [workspaceFile('DESIGN.md'), workspaceFile('brand.json')],
liveArtifacts: [],
onRefreshFiles: vi.fn(),
isDeck: false,
tabsState: { tabs: [], active: '__design_system__' },
onTabsStateChange: vi.fn(),
designSystemProject: {
id: 'neutral-modern',
title: 'Neutral Modern',
category: 'Starter',
source: 'bundled',
updatedAt: 1,
} as never,
};
const { rerender } = render(
<CollabProvider value={collabValue(workspaceA)}>
<FileWorkspace {...props} />
</CollabProvider>,
);
await waitFor(() => {
expect(mockedFetchProjectFileText).toHaveBeenCalledWith(
'project-1',
'DESIGN.md',
{ cache: 'no-store', workspaceContext: workspaceA },
);
});
mockedFetchProjectFileText.mockClear();
rerender(
<CollabProvider value={collabValue(workspaceB)}>
<FileWorkspace {...props} />
</CollabProvider>,
);
await waitFor(() => {
expect(mockedFetchProjectFileText).toHaveBeenCalledWith(
'project-1',
'DESIGN.md',
{ cache: 'no-store', workspaceContext: workspaceB },
);
expect(mockedFetchProjectFileText).toHaveBeenCalledWith(
'project-1',
'brand.json',
{ cache: 'no-store', workspaceContext: workspaceB },
);
});
});
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"
projectKind="prototype"
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('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', () => {
// `.split`'s grid-template-columns is always
// `var(--project-chat-panel-width) var(--project-chat-handle-width) var(--project-workspace-panel-track)`
// (see shell.css), so the style object only needs to carry the three
// custom properties — no more concatenated `gridTemplateColumns` string.
expect(projectSplitStyle(false, 512, 'minmax(420px, 1fr)')).toEqual({
'--project-chat-panel-width': '512px',
'--project-chat-handle-width': '4px',
'--project-workspace-panel-track': 'minmax(420px, 1fr)',
});
expect(projectSplitStyle(true, 512, 'minmax(420px, 1fr)')).toBeUndefined();
});
it('starts an uncustomized wide project at an equal chat/preview split', () => {
// 1600 total − the 4px handle (Demo, OPEND-2553 S6) = two 798px content
// columns. This must not regress to the old fixed 460px default or its
// former 720px ceiling.
expect(defaultChatPanelWidthForSplit(1600)).toBe(798);
});
it('keeps the workspace minimum when the viewport is too narrow for 1:1', () => {
// At this width an exact half would leave the preview below its existing
// 400px minimum, so the established drag boundary wins (760 − 4 − 400).
expect(defaultChatPanelWidthForSplit(760)).toBe(356);
});
});
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('measureWorkspaceTabBarAfterResize', () => {
it('scrolls the complete active file tab back into view after the workspace narrows', () => {
const activeTab = {
getBoundingClientRect: () => ({ left: 248, right: 358 }),
} as HTMLElement;
const tabBar = {
clientWidth: 260,
scrollLeft: 0,
scrollWidth: 520,
getBoundingClientRect: () => ({ left: 0, right: 260 }),
querySelector: (selector: string) => selector === '.ws-tab.active' ? activeTab : null,
} as unknown as HTMLDivElement;
expect(measureWorkspaceTabBarAfterResize(tabBar)).toBe(true);
// Only 12px of the active tab was visible after the drag. Preserve the
// tab's ellipsis protection, but reveal the whole 110px tab viewport so
// its filename can show the longest prefix that actually fits.
expect(tabBar.scrollLeft).toBe(98);
});
it('does not move the strip when the active tab already fits after widening', () => {
const activeTab = {
getBoundingClientRect: () => ({ left: 82, right: 192 }),
} as HTMLElement;
const tabBar = {
clientWidth: 360,
scrollLeft: 40,
scrollWidth: 520,
getBoundingClientRect: () => ({ left: 0, right: 360 }),
querySelector: () => activeTab,
} as unknown as HTMLDivElement;
expect(measureWorkspaceTabBarAfterResize(tabBar)).toBe(true);
expect(tabBar.scrollLeft).toBe(40);
});
});
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', () => {
it('shows the first-materialization syncing surface instead of mounting a cached workspace tab', () => {
render(
<FileWorkspace
projectId="project-1"
projectKind="prototype"
files={[workspaceFile('stale.html')]}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
isDeck={false}
tabsState={{ tabs: ['terminal:stale'], active: 'terminal:stale' }}
onTabsStateChange={vi.fn()}
materializationPending
/>,
);
expect(screen.getByTestId('design-files-syncing')).toBeTruthy();
expect(screen.queryByTestId('design-files-empty')).toBeNull();
});
// OPEND-2283. An empty list and a list that has not arrived read identically
// here, and the CTAs below ("新建草图" / "上传") actively mislead someone whose
// project does have files. The panel already draws this distinction for a
// team mirror that is still downloading; a local list that has not returned
// yet deserves the same treatment.
it('does not offer the empty-project CTAs before an authoritative file list arrives', () => {
render(
<FileWorkspace
projectId="project-1"
projectKind="prototype"
files={[]}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
isDeck={false}
tabsState={{ tabs: [], active: null }}
onTabsStateChange={vi.fn()}
filesAuthoritative={false}
/>,
);
expect(screen.queryByTestId('design-files-empty')).toBeNull();
});
// Suppressing the empty-state CTAs removed a false claim but left the panel
// blank, which reads as "stuck" rather than "loading" -- a worse first
// impression than the wrong copy it replaced. Say we are working instead.
it('shows a loading placeholder while the file list is still unknown', () => {
render(
<FileWorkspace
projectId="project-1"
projectKind="prototype"
files={[]}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
isDeck={false}
tabsState={{ tabs: [], active: null }}
onTabsStateChange={vi.fn()}
filesAuthoritative={false}
/>,
);
expect(screen.queryByTestId('design-files-empty')).toBeNull();
expect(screen.getByTestId('design-files-loading')).toBeTruthy();
});
// OPEND-3334. A project the Home send just created keeps the empty state its
// hand-off frame drew instead of detouring through "Loading…", but only as
// a picture: the authority gate still holds, so nothing in it can act before
// the first listing is accepted (Home attachments are still uploading then,
// or the read may fail).
it('draws an inert empty state, with no CTA on offer, before the first listing of a Home-created project', () => {
const { rerender } = render(
<FileWorkspace
projectId="project-1"
projectKind="prototype"
files={[]}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
isDeck={false}
tabsState={{ tabs: [], active: null }}
onTabsStateChange={vi.fn()}
filesAuthoritative={false}
emptyLookBeforeFirstListing
/>,
);
const standIn = screen.getByTestId('design-files-empty-unconfirmed');
expect(standIn.hasAttribute('inert')).toBe(true);
expect(standIn.getAttribute('aria-busy')).toBe('true');
expect(screen.queryByTestId('design-files-loading')).toBeNull();
expect(screen.queryByTestId('design-files-empty')).toBeNull();
expect(screen.queryByTestId('design-files-empty-new-sketch')).toBeNull();
expect(screen.queryByTestId('design-files-upload-trigger')).toBeNull();
// The staged Home attachment lands in the first accepted listing: files,
// not an empty-project offer.
rerender(
<FileWorkspace
projectId="project-1"
projectKind="prototype"
files={[workspaceFile('brief.png')]}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
isDeck={false}
tabsState={{ tabs: [], active: null }}
onTabsStateChange={vi.fn()}
filesAuthoritative
emptyLookBeforeFirstListing
/>,
);
expect(screen.queryByTestId('design-files-empty-unconfirmed')).toBeNull();
expect(screen.queryByTestId('design-files-empty')).toBeNull();
});
it('hands over to the live empty state once the first listing confirms the project is empty', () => {
render(
<FileWorkspace
projectId="project-1"
projectKind="prototype"
files={[]}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
isDeck={false}
tabsState={{ tabs: [], active: null }}
onTabsStateChange={vi.fn()}
filesAuthoritative
emptyLookBeforeFirstListing
/>,
);
expect(screen.queryByTestId('design-files-empty-unconfirmed')).toBeNull();
const empty = screen.getByTestId('design-files-empty');
expect(empty.hasAttribute('inert')).toBe(false);
expect(screen.getByTestId('design-files-empty-new-sketch')).toBeTruthy();
});
it('offers them once the list is known to be empty', () => {
render(
<FileWorkspace
projectId="project-1"
projectKind="prototype"
files={[]}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
isDeck={false}
tabsState={{ tabs: [], active: null }}
onTabsStateChange={vi.fn()}
filesAuthoritative
/>,
);
expect(screen.getByTestId('design-files-empty')).toBeTruthy();
});
// OPEND-2283. Read-only is fail-closed while ownership is still unproven, and
// that is correct — but the banner states a FACT ("this is a shared project"),
// and during that window the fact is unknown. Entering your own personal
// project cold showed it for seconds before the workspace context resolved.
// Disable the controls, do not assert the reason.
it('does not claim a project is shared while ownership is still unproven', () => {
const file = workspaceFile('artifact.html');
render(
<FileWorkspace
projectId="project-1"
projectKind="prototype"
files={[file]}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
isDeck={false}
tabsState={{ tabs: [file.name], active: file.name }}
onTabsStateChange={vi.fn()}
viewerOnly
/>,
);
expect(document.querySelector('.workspace-readonly-notice')).toBeNull();
});
it('states the reason once the project is confirmed shared', () => {
const file = workspaceFile('artifact.html');
render(
<FileWorkspace
projectId="project-1"
projectKind="prototype"
files={[file]}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
isDeck={false}
tabsState={{ tabs: [file.name], active: file.name }}
onTabsStateChange={vi.fn()}
viewerOnly
readonlyNotice="这是 麻薯 创建的共享项目。"
/>,
);
const notice = document.querySelector('.workspace-readonly-notice');
expect(notice).not.toBeNull();
expect(notice?.textContent).toContain('麻薯');
});
it('keeps an already-materialized viewer and header actions mounted during route revalidation', () => {
const file = workspaceFile('artifact.html');
const tabsState = { tabs: [file.name], active: file.name };
const onTabsStateChange = vi.fn();
const renderWorkspace = (materializationPending: boolean) => (
<FileWorkspace
projectId="project-1"
projectKind="prototype"
files={[file]}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
isDeck={false}
tabsState={tabsState}
onTabsStateChange={onTabsStateChange}
materializationPending={materializationPending}
fileActionsBefore={<button type="button">Reveal artifact</button>}
headerActions={<button type="button">Project action</button>}
/>
);
const { rerender } = render(renderWorkspace(false));
const retainedViewer = screen.getByTestId('retained-file-viewer');
rerender(renderWorkspace(true));
expect(screen.getByTestId('retained-file-viewer')).toBe(retainedViewer);
expect(screen.queryByTestId('design-files-syncing')).toBeNull();
expect(screen.getByRole('button', { name: 'Reveal artifact' })).toBeTruthy();
expect(screen.getByRole('button', { name: 'Project action' })).toBeTruthy();
});
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.queryByTestId('preview-run-status')).toBeNull();
expect(screen.getByTestId('design-files-empty')).toBeTruthy();
},
);
it('keeps delivery recovery in Chat without mounting status over existing preview files', () => {
render(
<FileWorkspace
projectId="project-1"
projectKind="prototype"
files={[workspaceFile('previous-design.html')]}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
isDeck={false}
tabsState={{ tabs: [], active: DESIGN_FILES_TAB }}
onTabsStateChange={vi.fn()}
messages={[
{
...assistantMessage('failed'),
id: 'delivery-failure',
runStatus: 'succeeded',
resultDeliveryState: 'no_result',
sessionMode: 'design',
endedAt: 1_700_000_012_000,
},
]}
/>,
);
expect(screen.queryByTestId('preview-run-status')).toBeNull();
expect(screen.queryByTestId('preview-run-status-retry')).toBeNull();
expect(screen.queryByTestId('preview-run-status-view-details')).toBeNull();
});
it('does not mount main-preview delivery feedback over a browser tab', () => {
render(
<FileWorkspace
projectId="project-1"
projectKind="prototype"
files={[workspaceFile('previous-design.html')]}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
isDeck={false}
tabsState={{
tabs: ['previous-design.html'],
active: '__browser__:1',
browserTabs: [{ id: '__browser__:1', label: 'Browser', url: 'https://example.com' }],
}}
onTabsStateChange={vi.fn()}
messages={[
{
...assistantMessage('failed'),
id: 'browser-delivery-failure',
runStatus: 'succeeded',
resultDeliveryState: 'delivery_failed',
sessionMode: 'design',
endedAt: 1_700_000_012_000,
},
]}
/>,
);
expect(screen.getByTestId('design-browser-panel')).toBeTruthy();
expect(screen.queryByTestId('preview-run-status')).toBeNull();
});
it('does not mount a delivered confirmation over the preview canvas', () => {
const now = 1_700_000_012_500;
vi.spyOn(Date, 'now').mockReturnValue(now);
render(
<FileWorkspace
projectId="project-1"
projectKind="prototype"
files={[workspaceFile('delivered-design.html')]}
liveArtifacts={[]}
onRefreshFiles={vi.fn()}
isDeck={false}
tabsState={{ tabs: ['delivered-design.html'], active: 'delivered-design.html' }}
onTabsStateChange={vi.fn()}
messages={[
{
...assistantMessage('failed'),
id: 'delivery-succeeded',
runStatus: 'succeeded',
resultDeliveryState: 'delivered',
sessionMode: 'design',
startedAt: now - 4_000,
endedAt: now - 1_000,
},
]}
/>,
);
expect(screen.queryByTestId('preview-run-status')).toBeNull();
});
});