Files
open-design/apps/web/tests/components/FileWorkspace.test.tsx
T
CaprikaGitHublefarcena1chztPerishCodeopen-design-crew[bot] <299007234+open-design-crew[bot]@users.noreply.github.com>alchemist
2b41bc5c3d feat(share): publish files as public links with stop/resume and synced comments (#8408)
* wip: wire deleted-file stop retry and cover owner-scoped HTTP behavior

* wip: persist published comment backfill state

* wip: connect backfill delivery receipts and preserve completed batches

* wip: expose exact-file backfill state and retry through HTTP and CLI

* wip: wire scoped comment sync pause into preview panel

* test: cover backfill terminal rejection and stale publication receipts

* wip: separate published content from unavailable viewer origin

* feat: distinguish publication success from unavailable viewer links

* fix(web): gate export share guide on authoritative project history

* test: restore publish backfill HTTP proof with current publisher pipeline

* fix(web): show authoritative stopped-share guidance

* fix(web): restore publish help before creating a share

* fix(web): show unread comments on collapsed rail

* fix: share annotated selectors across URL and srcdoc bridges

* test: verify saved comment selector reanchors in Chromium

* fix(web): warn about active shares before project deletion

* fix: authorize and bootstrap public share project bindings

* wip: prepare dispatched live share-chain probe (not run)

* fix(web): deliver per-file share residuals after deletion

* fix(web): prevent stopping shares in read-only viewers

* fix(e2e): retain complete share probe HTTP evidence

* fix(web): name shared projects in delete confirmation titles

* docs(e2e): clarify live share acceptance and human login boundaries

* fix(e2e): preserve share query context in diagnostic evidence

* fix(web): restore share-panel copy and comment-pin visuals per board8

chain1-publish G4: delete the stale "仅支持分享单个文件,内嵌图片等本地
资源暂不支持" sentence from fileViewer.publishSingleFileDescription (19
locales) — it contradicts the current multi-resource share and G4 required
its removal.

chain1-publish S1: add the missing "链接访问" title row + its static
description ("接收者可查看预览和已有评论,不包含对话和代码。") to
ShareTab's public-share section, as new fileViewer.linkAccessTitle /
linkAccessDescription keys (19 locales). No switch added — that control
overlaps the existing "停止分享" button and is left as a 需确认 item.

chain4-owner 4.2b: canvas comment pins go from 20x20/weight 600/2px white
ring/fixed #282828 to 28x28/weight 500/no ring, and now derive their
background/foreground from the same per-author color used by that author's
sidebar avatar (commentAuthorAvatarColor), both pure local CSS/TSX with no
packages/ or protocol change.

chain4-owner O7: formatCommentTime now buckets by calendar day (adds
"昨天", drops the "N 天前/N 周前" buckets, switches the hour bucket to
same-day instead of <24h elapsed, and formats older dates via
toLocaleDateString with year only when it differs from the current year),
and comment-side-time gets a title attribute with the full absolute
timestamp on hover.

Verification: pnpm --filter @open-design/web typecheck passes. Not run
through a real renderer — no pixel-level claim.

* fix(web): share entry icon, delete-confirm title, OP2 read-only hint

chain1-publish E0: swap the share toolbar entry's icon from RemixIcon
share-forward-line to the board's own upload-arrow glyph (13px path
"M12 4v11M7 9l5-5 5 5M5 14v5h14v-5") — same size/shape as the board's
.shareBtn icon, glyph only, no behavior change.

chain6-dialogs D-04: drop the extra "确定" from designs.deleteConfirm's
zh-CN/zh-TW title ("确定删除「{name}」?" → "删除「{name}」?") to match
the board and every other locale, which already read "Delete "{name}"?"
without an "are you sure" prefix.

chain4-owner OP2: add the missing "分享页评论不可编辑" explanation line
under a comment's textarea specifically when the viewer can't edit it
AND the comment is a share-page (external) one — team-member-reads-
external-comment (OP3) is unaffected, matching the board's OP2-only
scope. New comment.sharePageCommentReadOnly key, 19 locales.

Verification: pnpm --filter @open-design/web typecheck passes. Not run
through a real renderer — no pixel-level claim.

* test(web): update FileViewer specs for the G4/O7/4.2b restorations

Three specs encoded the exact pre-fix behavior the previous two commits
intentionally changed, so they went red:

- "reads the publish help without publishing" located the help bubble by
  the now-deleted stale sentence ("Only a single file can be shared for
  now..."); relocates it by the sentence that remains.
- "keeps relative comment-time boundaries stable across clock boundaries"
  asserted the old <24h-elapsed hour bucket; the O7 rewrite buckets by
  calendar day, so the fixture's 23h-ago comment (which crosses this
  fake clock's midnight) now correctly reads "昨天" instead of "N 小时前".
- "keeps D1-D4 marker and deck-page presentation on the rendered
  selectors" asserted the old 20px/weight 600/white-ring pin CSS; updated
  to the board's 28px/weight 500/no-ring geometry.

Verification: pnpm --filter @open-design/web typecheck passes;
tests/components/FileViewer.test.tsx: 334/334 passed (via
node_modules/vitest/vitest.mjs --pool=forks, run directly against the
node 24.18.0 binary — this shell's `npx`/`node` wrap nvm in a way that
blows bash's FUNCNEST under vitest's fork pool).

* feat(web): draw the export guide's countdown, title and description

The 10s countdown already existed as state - remainingMs, checkedAt and
paused were computed and consumed - but nothing rendered it, so the guide
showed no sign that it was about to dismiss itself.

The bar is one CSS width transition per run rather than a per-frame timer:
the remaining time is already known when the run starts, so animating to
zero over exactly that duration needs no re-render, and a pause just freezes
the transition.

Board copy added across all 19 locales.

* test(web): update AfterExportShareGuide specs for the P1 card restoration

Three assertions encoded the pre-restoration visual/DOM shape that
d2c72f1624 intentionally changed to match Board8 Chain-6-dialogs.dc.html's
P1 ("导出完成 · 分享引导条", `.s-m1eexportprompt .prompt[role=status]`)
mockup:

- role query changed region -> status: P1's `.prompt` div in the board
  markup carries `role="status"`, not the implicit `role="region"` that
  came from the old bare `<section aria-label>`.
- button count 3 -> 2, and the close-button-then-never-show sequence was
  replaced: the board's P1 card has no `.pclose` element (unlike the
  discarded EX1 alternative, which does have one) - its only controls are
  the `.pfoot` pair (`.pghost` "不再提示" / never-show, `.paction` "去分享"
  / open-share). The guide is still dismissible via never-show, open-share,
  or the unchanged 10s auto-collapse in after-export-share-guide.ts.
- dropped the dead `awaitingDesign` fixture key/assertion now that the P1
  design reference is in hand and the placeholder key has been removed.

Scoped run: 3 files / 40 tests passed via direct-node vitest (avoids the
nvm-wrapped node FUNCNEST issue under the fork pool noted in
od-side.md).

* feat(web): render the backfill, align, and shareStopped comment sync banners

CommentSyncBanner.tsx previously only rendered K8 (sessionMissing). The
backfill (K2), align, and shareStopped fields already existed on
CommentSyncState with i18n copy standing by, but no render branch consumed
them — this was flagged in .tmp/share-p0/visual-static/chain1-publish.md (K2)
and chain4-owner.md ("补传横幅/评论对齐横幅/分享已停用横幅").

Adds three branches, single-slot priority sessionMissing > shareStopped >
backfill(failed) > align(diverged), respecting the hard semantics documented
on packages/contracts/src/api/share.ts's CommentSyncState:
- backfill absent/pending/succeeded never renders a warning (absence is
  "never attempted", not success); the server already filters stale-revision
  backfill records, so any backfill this component sees is for the file's
  current publication.
- align absent or 'unknown' never renders as aligned or as a problem; only
  'diverged' is actionable.
- shareStopped: null (could not be read) never renders as stopped nor as
  healthy; only `true` renders.

Retry buttons call the existing retry endpoints (POST comment-sync-state for
backfill, POST comments/align for align) and re-fetch via a new
`refreshToken` option on useCommentSyncState. The backfill retry button is
only shown when `retryable === false`, per the field's own doc comment:
retryable:true means the server is already retrying on its own.

useCommentSyncState also gained an optional `filePath` param, threaded from
HtmlViewer's `file` prop through CommentSideDock -> CommentSidePanel, because
the backfill answer is per-file and the endpoint requires filePath to compute
it (readPublishedCommentBackfill returns undefined without one) — without
this wiring the backfill branch would be permanently unreachable regardless
of the new rendering code. This is plumbing an existing parameter to an
existing endpoint, not new triggering behavior.

Copy is the parent-approved text verbatim, added as three new i18n keys
across all 19 locales (fileViewer.commentSync.backfillFailedTitle/Body,
alignFailedBody); the retry button reuses the existing preview.retry key.

Tests: new tests/components/share/CommentSyncBanner.test.tsx (12 cases)
covering all silence/render rules above. Full apps/web typecheck, guard, and
tests/components/FileViewer.test.tsx (334/334) all pass.

Not done in this commit: the ShareTab panel's own shareStopped-driven hint
(a separate, pre-existing `publicationStatus === 'stopped'` render in
ShareTab.tsx using project-share-history data, not CommentSyncState) is
untouched — it is a different signal for a different surface.

* feat(web): add the board's "link access" switch to ShareTab

chain1-publish.md's S1/S4 rows show a `role=switch` "链接访问" toggle as
part of the panel's fixed structure that the current implementation never
had (flagged under chain1-publish.md's "需要人确认" #1 and chain6-dialogs.md
D-00). The user explicitly authorized adding it and asked me to resolve its
semantic overlap with the existing "Stop sharing" text button.

Resolution: the switch reads and drives the exact same `filePublished` state
through the exact same handlers already used by the primary button/ghost
button (publishCurrentFilePublic / unpublishCurrentFilePublic) — on -> off
calls unpublish, off -> on calls publish. Because both controls act through
the same two functions on the same state, they can never show or produce
inconsistent results; this is presentation only, not a new trigger path.

Did NOT remove the existing "Stop sharing" ghost button, even though the
board's own S4 mockup has no separate one — chain1-publish.md's own
"需要人确认" #2 explicitly warns not to delete it off this board alone,
since it may belong to a different board's scope (chain2's handoff notes
mention Chain-5). Keeping both is redundant but not contradictory, per the
task's own escape hatch ("你判断不该同时存在,那就说明理由并只做一个" —
here the judgment is the opposite: keep both, and make sure they can't
diverge).

Geometry/colors from chain6-dialogs.md D-00: 32x19 track, r999, off #D6D6D6,
on #282828, 15x15 white thumb. No new i18n keys — the switch's accessible
name reuses the existing `fileViewer.linkAccessTitle` key.

Also updates the pre-existing frozen DOM-baseline fixture
tests/fixtures/share-tab/share-panel.team.html. Confirmed via `git stash`
bisection that this fixture was ALREADY stale/failing before this commit —
the earlier "链接访问" title+description restoration (commit 721cb976cf,
prior round) changed the panel's markup without updating this baseline, and
nothing in CI caught it because this file wasn't in that round's targeted
test run. This commit's switch adds one more, unrelated divergence on top of
that pre-existing one, so the fixture is regenerated from the component's
actual current output rather than hand-edited, and captures both changes at
once. The `personal` fixture is untouched: canPublishPublic is false in that
scenario (no workspace context at all), so the entire link-access section
(switch included) never renders there, exactly as before.

Verification: pnpm --filter @open-design/web typecheck and pnpm guard both
pass. tests/components/FileViewer.share-tab-extraction.test.tsx (22/22),
ShareTab.copy-button/first-publish/publish-error/scope-keyboard.test.tsx,
tests/styles/share-scope-design.test.ts, and FileViewer.test.tsx
(394 tests total) all pass. Not rendered in a real browser — see the
evidence file for the "未经真实渲染验证" caveat.

* fix: scope deletion stops to the selected published file

* feat(contracts): define share bridge v2 geometry boundary

Define authoritative OD v2 viewport geometry, fallback pin bounds, display tokens and geometry-update gates. Add 16 passing boundary tests; root guard and typecheck passed.

Known pre-existing failure: share-contract.test.ts expects SHARE_SNAPSHOT_DISCOVERY_IN_P0=false while baseline bd39802907 exports true. The identical assertion was reproduced against unchanged HEAD source; this unrelated assertion is not modified. Vela runtime and browser acceptance remain separate.

* fix(comments): create anchors for personal public shares

* fix(comments): create anchors for personal public shares

* fix(web): prefer readable element names in comment titles

Use screen labels then element-text snapshots before localized type fallbacks. Keep synthetic pin/page titles and hide internal IDs. Export a pure resolver for share-page parity without new locale strings.

Verified 10 contract tests, all 334 FileViewer tests, all 19 existing locale key sets, root guard and root typecheck. Vela adoption and browser visual acceptance remain separate.

* fix: stop published files before file deletion

* fix: explicitly resume stopped publication generations

* Restore main comment UI styling while retaining share flows

* test: verify guarded resume through real Vela CLI

* fix(web): match main comment composer surface

* fix: register personal project owner before share history lookup

* fix(web): align comment avatar colors with shared page

* fix(share): revoke deleted sources through atomic resource removal

* fix(comments): seed client avatar colors only from authoritative author key

* test(share): expect the additive publish failure detail after merging main

#8402 adds an optional closed-token `failure` to failed publish responses.
The share-origin HTTP test compared the 502 body exactly, so it now also
expects { stage: 'push', reason: 'unknown' } for a synthetic upload error.

* test(contracts): assert deployed snapshot discovery in P0

* fix(share): revoke folder publications before deleting descendants

* fix(web): normalize share controls to supported font weights

* test(web): assert manual copy fallback after clipboard rejection

* test(share): align route fixtures with original owner authority

* feat(comments): expose explicit visitor comment pull to UI and CLI

* feat(cli): expose explicit project share resume command

* fix(cli): emit JSON for files delete

* refactor(share): name the unpublish response contract

* fix(comments): preserve Vela author key in inbound avatar identity

* test(share): use valid sharedAt timestamps in owner fixtures

* fix(share): use promise stat with production folder route fs

* fix(comments): use defined theme red for unread indicators

* fix(cli): preserve workspace context error codes

* fix(files): hide internal paths in deletion failures

* fix(comments): persist local author name and reconcile cloud author key

* fix(share): align owner sharing flows with final UI audit

* test(share): verify unboxed comment sync across owner states

* fix(share): apply second-round final design review to owner share UI

- Share panel: remove every yellow warning; show the public link row above
  the copy button in published states; oversized shares stay blocked and
  explain why in the red error row.
- Global toast for update success/failure and residual stop failure, with
  final design icons and copy; share panel buttons match the design.
- Comment sync: K1 turns the primary action into the sync progress bar, K4
  shows a busy "正在同步评论…" button, K5 retry is red.
- Deployment menu layers above the share popover; export-complete guide
  rebuilt to the final design.
- Share bridge pin label accepts exactly one letter so a CJK author's
  initial can label their pin.
- Update ui-audit capture specs for the intended copy/structure changes.

* fix(share): apply third-round design review to owner share UI

- Share toast rebuilt as ShareFeedbackToast to the design: solid red/green
  badge, plain underlined 重试, close; used for update success/failure and
  the deleted-project stop failure.
- The ··· deploy menu drops down under the trigger, right-aligned to the
  header tools, over the share panel.
- Signed-out share button reads 「登录后分享」; update notice loses its box;
  更新链接 / 登录后更新 / K5 retry use the design's blue and retry icon.
- Signed-out observed share reuses the published link row and copy button
  (chain icon, green check 已复制) and no longer overflows its card.
- Capture specs follow the intended copy and placement changes.

* fix(share): keep the signed-out share panel instead of re-homing on sign-out

Signing out on a shared file page intermittently landed on Home instead of
the signed-out share panel (Owner-S13), depending on which re-read returned
first:

1. The workspace-context refresh re-adopted the route's bootstrap witness
   from the previous account, re-keying ProjectView on the same file.
2. FileWorkspace treated that remount as a file switch and cleared the
   observed public link.
3. With no link left, the tab scope reset fell back to Home.

- useProjectRouteWorkspaceContext: a bootstrap witness issued before an
  identity refresh is retired and never adopted after it (the hook's
  documented rule for resolved authority now covers the witness too).
- isObservedShareFileSwitch: mounting or remounting a file view is not a
  file switch; only a change to a different file clears the observed link.

Red specs: hook re-adoption (red on previous source) and FileWorkspace
remount (red on previous source). S13 capture: 8/8 passes, was 2/7.

* fix(share): align remaining owner share copy and K1 sync count with final design

- S0: signed-out description uses the design copy via its own key; the
  publish-failure message keeps its original text.
- S5: update notice reads 「文件有新改动,访问者看到的还是上一版。」.
- K2/K5: the failed-sync notice drops the 「分享已发布」 title and uses the
  design copy; terminal variants keep their own wording.
- K1: first-publication sync shows 「同步已有评论 · N 条」. The backfill
  state carries optional total/synced from the daemon's backfill members
  table, driving a determinate bar when progress is known; older daemons
  fall back to the previous copy and the indeterminate sweep.
- Refresh the PR screenshot of the Share popup comment-sync state.

* chore(web): restore generated next-env.d.ts accidentally included in 30361fd670

* refactor(share): give each owner share UI element one owning component

Behavior-preserving consolidation of the owner share surfaces, verified by
pixel diff of all 17 Owner capture specs against the pre-refactor baseline
(every state within the known 0-0.25% noise from timestamps, placeholder
animation and the dev badge; the share panel itself is identical).

- Shared components: LinkAccessRow, ShareNoticeRow, ShareProgressButton
  (S2 upload + K1 sync), ShareErrorRow, ShareButton (primary / soft /
  soft-error / dark-sm on @open-design/components Button), ShareSignInButton
  over a new useCloudSignIn hook shared with CloudSignInTip.
- The popover skin moves from a :has(.panel) override to an explicit
  .chrome-unified-popover--share class; SignedOutObservedShare reuses
  SharePanelHeader and the extracted rows.
- Icons: every inline share SVG moves into components/Icon.tsx with
  byte-identical path data.
- Tokens: styles/share-tokens.css holds the share palette and sizes as fixed
  light values (not app theme tokens, so dark mode renders as before); share
  CSS modules reference them. Single-use decorative values stay local.
- Delete the unreachable React-component share popover (canShareCurrentFile
  is false by design, Owner-G3) and its dead state and handlers.
- Toasts: share-adjacent feedback uses ShareFeedbackToast; top toasts share
  one stacking order instead of overlapping at top:64px.
- Static CSS-contract tests read share styles through readShareCss, which
  resolves share tokens to the design values they assert.

Allowed visual changes (existing defects):
- The sign-in entry is a real button; signing/error messages render below
  it instead of inside a nested button (UI-040).
- The comment side panel's retry-failed message is red (UI-042).
- Simultaneous top toasts stack instead of overlapping (UI-039).
- 「正在开启…」 goes through i18n (UI-041).

* feat(daemon): resolve share links only from the Viewer origin

Public share links come from OD_SHARE_VIEWER_URL(S) (HTTPS root origin)
as https://<viewer>/artifact/{projectId}/{slug}; the console origin is
no longer a fallback. GET publish-public derives the URL from the
resolver instead of the stored row, so pre-upgrade console URLs are
never echoed, and reports link unavailable plus the slug when no Viewer
origin is configured. Remotely stopped rows are not live. Manual-revoke
recovery data carries projectId and a nullable url. od project share
prints the slug in the unavailable state. Test fixtures move from
OD_VELA_WEB_URL to OD_SHARE_VIEWER_URL.

* feat(web): show a link-unavailable state for URL-less publications

A publication is published when it has a slug, not a URL. Without a
Viewer origin the share panels show a translated link-unavailable
status with Stop sharing, never the Publish CTA, so the file is not
uploaded twice. registry normalizes the publish and read responses to
{slug, url|null}; auto-copy is skipped without a URL.

* feat(tools-dev): inject a default share Viewer origin for local daemons

The default stays empty until the test Viewer domain is decided
(decision 67 #7), so local shares report link unavailable. An operator
OD_SHARE_VIEWER_URL(S) always overrides it.

* feat(daemon): parse C3-LITE member comment pages in both collab transports

Add a single-page member pull to the Vela CLI and HTTP collab clients
that returns either a typed page or a rebuild-required signal.

- The page envelope is validated strictly; malformed comments are
  skipped per item and reported, so one bad stored payload cannot
  block the cursor.
- Minimal tombstones ({id, seq, deleted}) are accepted and get the
  requested projectId.
- INVALID_CURSOR / CURSOR_STALE / SCOPE_CHANGED map to
  rebuild-required; INVALID_PAGE_REQUEST stays an error.
- Paged requests send no If-None-Match.

* feat(daemon): drain member comment pages into SQLite atomically

Team projects on the Vela CLI transport now receive member comments
through bounded C3-LITE pages instead of the unbounded legacy pull.

- Each page's merges, tombstone deletes and cursor advance commit in
  one immediate transaction; the persisted cursor is the cloud's
  stateless page/resume token.
- The drain continues through empty hasMore pages, hands a finished
  snapshot off to an incremental round, and stops at the end of an
  incremental round.
- A remote tombstone deletes a row that has a pin_seq; unknown ids
  are a no-op.
- A wake during a pull triggers exactly one trailing rerun.
- Merge notifications fire only for committed, changed rows.
- Share-page (user) comments stay on the legacy pull until public
  paging exists. The legacy pull also keeps carrying member comments
  whenever paging did not complete, and a cursor that needs a
  rebuild is latched until the rebuild path lands.

* feat(daemon): rebuild member comment cursors and isolate sync scopes

An unusable member cursor is no longer latched. It is reset and rebuilt
from a fresh snapshot in the same drain.

- A per-scope ledger records the ids the member stream delivered. The
  terminal snapshot page, in the same transaction as the handoff cursor,
  removes cloud-origin rows the snapshot no longer contains. Rows the
  stream never delivered (never-synced local comments, share-page
  comments, rows another account learned) are never pruned, and
  nothing is deleted before the snapshot completes.
- Writes committed during the snapshot arrive through the handoff
  incremental round and are not pruned.
- Scope is (workspace, member, team, project). A scope must prove it is
  current before taking a project over. Every page is re-checked after
  its await and before its commit (active scope, team-share binding,
  bound context), so an old account's or unshared project's in-flight
  page is discarded.
- Account switch and stop sharing drop that scope's cursor and ledger
  but keep the local comment rows as local copies.
- INVALID_PAGE_REQUEST and repeated transport failures back off per
  scope (bounded exponential, capped at 5 minutes). A refused rebuild
  snapshot backs off instead of looping.

* feat(daemon): announce comment merges once per pull round

A member page drain used to fire `onMerged` after every committed page,
and the legacy pull fired once more, so one 3-page drain of 250 comments
pushed three or four `comment-changed` events and cost the open web view
as many full list reads.

- Every row a round commits (member pages, prune, tombstones, legacy
  share-page rows) is summed into one `onMerged` call when the round
  settles, including rounds that pause, lose their scope or throw after
  some pages committed. A trailing rerun is its own round.
- A throwing listener is reported through `onError` and neither masks
  the round's own error nor turns a successful round into an unredeemed
  wake.

* feat(web): single-flight the comment list refresh and keep unchanged comments

Every comment trigger (project SSE, fallback poll, visibility, stream
reconnect, status patch) started its own full list read; the generation
guard only discarded stale answers. A burst of 10 `comment-changed`
events cost 10 reads, and a refresh that found nothing new still
published a new list and re-rendered.

- `refreshPreviewComments` goes through a single-flight runner keyed on
  project and conversation: a trigger during a read buys exactly one
  trailing read with the latest closure; a different conversation starts
  its own read and the old loop stops rerunning.
- Fresh reads are reconciled into the current state: unchanged comments
  keep their objects, an unchanged list keeps its array (no re-render),
  and attached comments follow the same rule. The initial conversation
  read reconciles too.
- The remote half of an explicit pull has a 15s deadline and falls back
  to the local list, so a stalled daemon pull cannot hold every later
  refresh behind it.

Measured in ProjectView tests (10 SSE events during one read): 10 -> 2
list reads; an equal-data refresh: 1 -> 0 new lists/renders.

* fix(daemon): make a published private team project team-visible before its comment backfill

Publishing a public link for a private project in a team workspace registers
it in the team catalog, but only the later background reconcile marked the
local row team-visible. Until then the row was personal in a team workspace,
which is neither Team- nor personal-relayable: the publish backfill was
cancelled (BACKFILL_DELIVERY_DISCARDED) and comments written right after
publishing were never queued.

- publish-public marks the creator's private row team-visible right after
  catalog registration, before the publication transaction enqueues the
  backfill (decision 67 #11); the response carries madeTeamVisible.
- The relay re-queues a personal record as Team relay once its creator's
  project turned team-visible, and defers (bounded) a personal record in a
  team workspace whose publication is still current instead of cancelling it.
- A cloud seq is never adopted as pin_seq when another confirmed row of the
  file already holds it, which produced two pins numbered 1.

* feat(share): tell the owner a public link makes a private team project team-visible

The share panel (and the React component viewer's share menu) shows
"After publishing, team members will be able to see this project" before
the first publish of a private project in a team workspace, in all 19
locales, and updates the scope control and team project views once the
daemon reports madeTeamVisible. `od project share publish` prints the same
notice to stderr and --json keeps the madeTeamVisible field.

* feat(packaged): hand the daemon its channel's share Viewer origin

Packaged desktop and headless daemons get the share Viewer origin for
their release channel: stable sends OD_SHARE_VIEWER_URLS with the prod
profile mapped to https://open-design.app; other channels send nothing
until the test Viewer domain is decided (decision 67 #10), so shares
report link unavailable. An explicit OD_SHARE_VIEWER_URL(S) at launch is
forwarded unchanged instead. The daemon resolver stays the only
validator. The legacy payload-desktop handoff strips both keys so a
channel default never becomes a sticky override.

* fix(daemon): report madeTeamVisible when a catalog reconcile marked the project first

Registering a private team-workspace project in the team catalog lets a
concurrent catalog reconcile (hub team-projects-changed, a project list
read) rebind the local row to team before publish-public marks it. The
mark then changed nothing and the response omitted madeTeamVisible, so the
share panel kept showing "only me" and the CLI printed no notice. Seen in
the local integration rerun on the first UI publish.

publish-public now reads whether the row is the creator's private team row
before registration, and reports madeTeamVisible when that row is the
creator's team row afterwards, whichever writer flipped it. A project that
was already team-visible still reports nothing.

* fix(daemon): order synced comment revisions by cloud seq so replays never undo newer state

A share-page comment resolved at cloud seq 10 reverted to open after a daemon
restart: the legacy pull cursor is in memory, so the restarted daemon replays
from seq 0, and the merge stamped Date.now() on the share-page create (which
carries no updatedAt), making the replayed create look newest.

Each preview_comments row now records the highest cloud revision seq it
reflects (cloud_revision_seq) and the seq space it came from
(cloud_revision_stream: relay team + member stream epoch). An incoming
revision at or below that seq in the same stream is a replay and never
overwrites the row. Unseen revisions keep updatedAt last-writer-wins when they
carry an updatedAt; one without updatedAt applies in seq order and is stored
with its createdAt, never the local clock. pin_seq is untouched.

* feat(share): take the canonical share link from AMR

The daemon no longer builds the public share address from a hardcoded or
per-channel Viewer origin. AMR reports it: `vela share publish|bind|resume|
project-status --json` carry `url` (https://<share origin>/artifact/{projectId}/{slug})
for an active binding, or `link: {status: "unavailable", code}`.

Link rule, one place (resolvePublicShareLink): an explicit
OD_SHARE_VIEWER_URL(S) override wins (a present invalid override is
unavailable, never a fallback); otherwise the AMR address, accepted only when
it is byte-for-byte the canonical HTTPS root-origin address of this project
and slug; otherwise unavailable, passing AMR's code
(PUBLIC_SHARE_WEB_URL_UNAVAILABLE / PUBLIC_SHARE_IDENTITY_INVALID) through.
A rejected address is logged without echoing it.

The verified AMR address is persisted with the publication and refreshed on
publish, foreground bind/resume, the background binding retry and every
publish-public status read, without rotating the publication witness. When
the status read fails, GET falls back to the persisted link marked
`stale: true` (not for a queued stop); a stored legacy console URL fails
re-verification and stays hidden until the next online read replaces it.

Packaged daemons no longer get a per-channel Viewer origin and tools-dev has
no default; an explicit launch override is still forwarded. `od project share`
prints the same link, a stale notice on stderr, and says when an alias has no
Viewer address.

* fix(share): keep the link access switch thumb at the left when off

* fix(share): settle the share status after a stop or resume in the open panel

* fix(comments): keep a comment that was handed to the agent

Sending a saved comment to the agent deleted it as soon as the send was
queued, so a run that failed or was stopped left nothing to retry and
pushed a tombstone to the share page.

The hand-off is now carried by status alone. The three send paths no
longer delete after queue acceptance; they only clear the composer and
the selection for the accepted ids. A run that fails, is reported
canceled, or is stopped by the user releases its comments back to open
through releaseCommentsAfterUnsuccessfulRun. A successful run still ends
in needs_review.

* fix(comments): keep an unsent comment draft when another element is picked

Picking another element, lassoing, opening a saved pin, or selecting a
comment in the side panel retargeted the composer at once and dropped
the draft, queued notes, and context without a word.

All four pick paths now retarget through requestComposerRetarget. While
the composer holds unsent work the first pick changes nothing and shows
a notice inside the composer; the next pick confirms the discard and
retargets. Editing the draft disarms the pending discard. Closing the
composer, Escape, and switching files behave as before.

* fix(daemon): walk nested HTML in share/deploy plans and refuse root index.html conflicts

Referenced .html/.htm files (iframes, frames) are now walked like the entry:
their references resolve against their own directory at any depth, so their
stylesheets, images and child pages ship and missing/invalid ones are
reported. Share mode rewrites their root-absolute references relative to
their own directory and maps references to the entry to the published
index.html; standalone deploy keeps their bytes.

Parsing is deduped on each file's real path, which bounds the walk even with
in-project symlink loops (x -> .) and also fixes the existing unbounded CSS
@import recursion through such links.

A reference to an existing root index.html while sharing another page no
longer overwrites the entry: the plan records the conflict (referrer file,
attribute, line, suggested free name) and deploy refuses it with 409
ENTRY_INDEX_CONFLICT carrying a copy-to-agent fix prompt.

* fix(daemon): refuse share entry/index.html conflicts before reserving

The share plan summary carries entry-index-conflict blockers (with the agent
prompt), and publish-public now builds the plan, and refuses on size or
blockers, before reserving an alias or preparing Vela. A conflict returns
409 SHARE_ENTRY_INDEX_CONFLICT with the details in error.data. Freshness is
not compared against a blocked plan.

* fix(daemon): print share blockers and agent prompt in the od CLI

od project share preflight prints each blocker with its copy-to-agent
instruction and exits 1 when blockers exist (also with --json). A publish
refused with SHARE_ENTRY_INDEX_CONFLICT prints the instruction readably in
text mode before the structured error envelope.

* fix(web): explain share index.html conflicts with a copy-to-agent action

The share panel shows the entry-index-conflict blocker from the share plan or
a 409 publish refusal, disables publishing while it stands, and copies the
daemon's agent prompt (falling back to showing it for manual copy). New
strings are translated in all 19 locales.

* fix(comments): release a comment whose queued send never becomes a run

A saved comment is written 'applying' when its send is queued, before any run exists. The release added for unsuccessful runs only fired on run end, cancel and stop, so a send held by the pre-run balance gate, stopped or failed before its run was created, or taken out of the queue left the comment 'applying' and out of the comment list for good.

Queue drains now go through startQueuedChatSend, which releases the comments of a send that ended without a run. A send the gate holds no longer reserves its comments, and removing or editing a queued send releases what it carried. A held send that starts later reserves them again when its run begins.

* fix(share): keep a reopened share busy until its link has been read

A resume answers without a link, so the panel re-reads the share state. The busy state ended with the resume response, and for the seconds the re-read took the panel said the link was temporarily unavailable before swapping to the link.

settlePublicShareAfterMutation now writes the active state only once the re-read has settled, and the publish flow stays busy on it. A re-read that fails or carries no link still ends on link unavailable, without an error.

* fix(web): size the copy-for-agent button to its longest label

The soft share button is a fixed 88px slot sized for short labels, so
"Copy fix for agent" overflowed in English and longer locales. Let the
button grow to fit, and stack both labels in one grid cell so the width
stays put while "Copied" shows.

* test(e2e): reopen a stopped share through the switch in the share audit specs

The share audit specs stopped a share inside the panel and then clicked
the publish button to bring it back, which is the behaviour the panel no
longer has: a stopped share reads as stopped and reopens through the link
access switch with a resume request.

The mocked publish responses also lacked the receipt the client parses,
so they now return the real response shape.

* test(share): align CI cases with current share UI

* test(share): align CI cases with current share UI

* test(web): pin comment time boundary to local noon

* fix(share): allow reopening saved comment after save

* fix(daemon): wallet 401/403 no longer expires the AMR session

Billing is display-only (OPEND-3553). Vela maps internal billing
failures to 403, so a wallet rejection now only reports the balance as
unavailable instead of marking the credential expired, which dropped
workspace context and redirected to sign-in.

* fix(daemon): billing summary failure no longer expires the AMR session

A failed 'vela billing summary' (even an auth-shaped 401) only drops the
optional plan/balance from /status. Credential expiry stays owned by the
non-billing workspace directory path (OPEND-3553).

* fix(daemon): billing projection 403 no longer flushes workspace authority

A rejected workspace billing snapshot only marks the billing runtime
entry. It no longer invalidates the workspace directory, exact authority,
or exact context caches with auth_reject; Vela also maps internal billing
failures to 403, and membership revocation stays owned by the directory
and the hub workspace-access-revoked event (OPEND-3553).

* fix(daemon): carry share content fingerprint across stop and resume

Resume reopens Vela's saved version of the same alias but writes a new
local publication revision, so the fingerprint row (keyed to the old
revision) made freshness permanently 'unknown' and hid the update entry.
Rebind the stored fingerprint to the resumed revision of the same slug so
later edits read as 'outdated' (OPEND-3517).

* fix(daemon): redact identity argv from vela command failures

Node's exec error repeats the full command line in message, cmd and
stack, and presence/member/catalog callers logged it verbatim, leaking
--display-name and activity payloads. The vela command runner now
rewrites the values of --display-name, --name, --email, --activity-json
and --metadata-json before rejecting (stderr and the upstream status
signal are untouched), and the presence failure log records only the
classification and upstream status (OPEND-3520).

* fix(daemon): refuse plain publish of a stopped share before uploading

When Vela reports the file's share as stopped, a plain publish-public POST
now answers 409 SHARE_STOPPED_RESUME_REQUIRED before pushing bytes, since
Vela rejects it with 409 share_binding_stopped anyway; reopening the link
is the explicit { mode: 'resume' } request. The share publish wrapper also
keeps the upstream 'API request failed with status NNN: code' line so the
failure classifies as upstream_http instead of reason=unknown (OPEND-3510).

* test(daemon): satisfy test typecheck for batch-2 share fixes

* fix(daemon): keep vela argv redaction from crashing on non-string args

Some media callers pass optional argv slots through as undefined. The
redaction must never replace the real command failure, so it skips
non-string entries (caught by vela-media-safety-process tests).

* fix(daemon): increase comment id entropy

* fix(share): keep deployment menu alive through pointer press (OPEND-3537)

* fix(share): unify owner Share entry glyphs (OPEND-3535)

* fix(share): show pending link-stop feedback (OPEND-3513)

* fix(share): retain scoped preflight across panel reopen (OPEND-3536)

* fix(comments): wait for preview targets before lost writeback (OPEND-3516)

* fix(share): restore Owner toolbar hierarchy (OPEND-3580)

* test(share): align toolbar style contract with C0 (OPEND-3580)

* fix(web): preserve owner scroll after comment location

* fix(cli): distinguish share size refusals from connection failures

* fix(web): separate share eligibility and preserve comment identity UX

* test(cli): preserve daemon connection failure exit code

* fix(share): expose owner-scoped persisted stop tasks

* fix(contracts): bound share anchors at original selector lengths

* fix(web): recover pending deleted-share stop notices

* fix(contracts): define atomic share pin snapshot metadata

* test(web): provide retry preflight fixture and await completion

* fix(web): keep billing upgrade outside account identity boundary

* fix(share): protect staging and restore client continuations (#8675)

* fix share staging and client continuations

* docs: clarify share batch verification baseline

* fix(web): notify when sending to a deleted project or conversation

* fix(share): dismiss card share popover on conversation scroll (OPEND-3784) (#8689)

* fix(share): keep anchored popover geometry current (OPEND-3783) (#8694)

* fix(share): expose freshness checks and parallelize state reads (OPEND-3791) (#8708)

* fix(share): preserve safe publish errors and actionable messages (OPEND-3796) (#8709)

* fix(share): preserve safe publish failure details and actionable messages

* test(share): align failure copy expectations and cover classified messages

* fix(share): retain Button binding required by CI merge

* fix(share): add bounded state-read and resume diagnostics (#8710)

* Revert "fix(share): expose freshness checks and parallelize state reads (OPEND-3791)" (#8711)

This reverts commit 8f6c09db39.

The reported latency bottleneck remains unproven. Revert the full PR at the user request and defer presentation optimization. Preserve the subsequent OPEND-3796 changes.

* fix(share): preserve entry binding checks when opening Share (OPEND-3594) (#8712)

* fix(share): report preflight failures and allow explicit retry (OPEND-3771) (#8716)

* fix(share): use green for update-link action (OPEND-3790) (#8717)

* fix(share): retain confirmed PDF export guide during history refresh (#8719)

* fix(analytics): add passive share and comment observations (#8723)

* fix(analytics): define passive share observations and contain measurement failures

* fix(analytics): observe share receipts and selected comments without changing actions

* fix(web): isolate after-export guide observation from actions

* fix(analytics): connect passive guide observations and document minimized fields

* fix(analytics): observe saved comment creation and run handoff passively

* fix(analytics): exclude edited composer drafts from saved-comment attribution

* fix(e2e): align Team run fixtures with authoritative workspace catalog

* fix(analytics): wait for comment acceptance readiness in regressions

* fix(share): 修复超限评论恢复与资源读取失败,补验四项验收问题 (#8721)

* fix(share): recover oversized comments and reject unreadable dependencies

* fix(share): close real export completion and cloud failure gaps

* test(share): align export and delete relay regression assertions

* fix(export): handle interrupted ZIP saves in preview actions

---------

Co-authored-by: lefarcen <935902669@qq.com>
Co-authored-by: a1chzt <chizblank@gmail.com>

* test(share): align export failure assertions with current copy

* fix(share): guard user-scoped export guide dismissal (OPEND-3579) (#8738)

* fix(share): guard user-scoped export guide dismissal (OPEND-3579)

* test(web): align export guide failure assertions with product copy

* test(web): settle restored share state before publish assertions

* fix(chat): keep artifact Share actions static (OPEND-3854) (#8747)

Keep ChatPanel artifact Share actions static and remove card-only publication state tracking. Preserve the existing share panel and readonly/generation gates.

* Merge pull request #8750 from nexu-io/fix/opend-3888-comment-age

fix(web): preserve comment creation age (OPEND-3888)

* fix(comments): persist new static comment identities (OPEND-3516) (#8768)

* fix(comments): preserve authored identities through manual and AI edits

* fix(comment): prepare durable static source identities (OPEND-3516)

* fix(comments): integrate durable identity lifecycle (OPEND-3516)

* fix(comment): validate authored HTML identity tokens (OPEND-3516)

* fix(comment): mask raw text before authored tokenization (OPEND-3516)

* fix(comments): enforce prepared document and publication barriers (OPEND-3516)

* fix(comments): revalidate pending documents and guard updates (OPEND-3516)

* fix(comments): require app-owned source preparation consent (OPEND-3516)

* fix(comments): contain preparation consent focus (OPEND-3516)

* fix(sharing): diagnose project bootstrap failures safely (OPEND-3516)

* fix(comments): retire stale publication guard alerts on explicit retry (OPEND-3516)

* fix(comments): locate Personal identities in the live preview (OPEND-3516)

* fix(comments): surface lost Owner identity anchors (OPEND-3516)

* fix(comments): unblock publication after strict target loss

* fix sharing to keep file publication independent of team visibility (OPEND-3855) (#8759)

* fix sharing to keep file publication independent of team visibility (OPEND-3855)

* refactor sharing to one explicit policy path (OPEND-3855)

* test: isolate metadata snapshots from the trailing identity directive

* fix: align collaboration consumers with explicit project visibility

Carry trusted catalog roots through content receivers and suppress stale Team rows after explicit privacy changes. Read shared design-system brand assets from their resource snapshots. Align stopped-publication ordering and protocol/browser fixtures with the existing contracts.

* test: await explicit comment preparation and independent UI states

Complete the existing source-version consent and real frame-readiness flow before opening the comment sidebar. Bind the response waiter to the UI actions so failures retain their real cause. Await the independently loaded account callout and assert exact catalog-bound materialization wiring.

* test: expect private policy when unsharing a team project

Await the explicit visibility-private command instead of deleting the retained root registration. Assert that remove was not invoked and preserve all member quarantine, directory and file-read privacy checks.

* fix(comments): synchronize file lifetimes across member and public shares (#8764)

* fix sharing to keep file publication independent of team visibility (OPEND-3855)

* fix(comments): synchronize current file lifetimes across members and shares

OPEND-3870 / OPEND-3871. Stack on published OPEND-3855 dependency; preserve reviewed source tree exactly. Local validation does not establish PostgreSQL or native acceptance.

* refactor sharing to one explicit policy path (OPEND-3855)

* fix(collab): adapt comment sync to converged project lifecycle

* test: isolate metadata snapshots from the trailing identity directive

* fix(collab): preserve nested publication and pulled file authority

* fix: align collaboration consumers with explicit project visibility

Carry trusted catalog roots through content receivers and suppress stale Team rows after explicit privacy changes. Read shared design-system brand assets from their resource snapshots. Align stopped-publication ordering and protocol/browser fixtures with the existing contracts.

* fix(collab): preserve relay identity and retire captured file batches atomically

* test: await explicit comment preparation and independent UI states

Complete the existing source-version consent and real frame-readiness flow before opening the comment sidebar. Bind the response waiter to the UI actions so failures retain their real cause. Await the independently loaded account callout and assert exact catalog-bound materialization wiring.

* test: expect private policy when unsharing a team project

Await the explicit visibility-private command instead of deleting the retained root registration. Assert that remove was not invoked and preserve all member quarantine, directory and file-read privacy checks.

* fix(collab): retain the actual native root during mirror materialization

* fix(collab): fail closed on unbound active share identity

* fix(collab): retire comment lifetimes after local mutation commits

* fix(collab): commit local artifact invalidation with working-root changes

* fix(collab): reconcile committed root retirement without stopping new links

* test(collab): align CI fixtures with admitted file identities and receipts

* test(collab): model receipt-backed root file admission in the shared hub

* test(collab): distinguish bound and unbound publication recovery

* chore(pack): pin compatible test CLI and exclude dependency dotenv files

* test(share): verify paired CLI policy and immutable share contracts

* fix(share): remove comment preparation interruptions

* fix(web): offer after-export guide when sharing history fails

* fix(comments): preserve unknown ages and repair synced creation times

* style(web): restore comment reference visuals without changing behavior

* test(web): verify colored avatars and document deployed parity

* fix(share): collect literal JavaScript image dependencies

* fix(web): match canvas comment pins to author avatar colors

* test(comments): align existing UI and E2E color expectations

* test(share): align collab flow and snapshot with direct comments UI

* test(campaign): await verification before advancing impression frames

* test(cms): await verification and mount before impression frames

* fix(web): match canvas comment initials to sidebar avatars

---------

Co-authored-by: PerishCode <perishcode@gmail.com>
Co-authored-by: open-design-crew[bot] <299007234+open-design-crew[bot]@users.noreply.github.com>
Co-authored-by: lefarcen <935902669@qq.com>
Co-authored-by: alchemist <alchemist@alchemistdeMacBook-Pro.local>
2026-10-19 06:15:28 +00:00

5054 lines
188 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @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();
});
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();
});
});