Files
open-design/e2e/ui/project-management-flows.test.ts
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

5333 lines
216 KiB
TypeScript

import { expect, test } from '@/playwright/suite';
import { ACTIVE_ARTIFACT_PREVIEW_SELECTOR } from '@/playwright/artifact-preview';
import { ensureRailOpen, openNewProjectModal } from '@/playwright/rail';
import { openAllProjectFiles } from '@/playwright/workspace';
import { T } from '@/timeouts';
import type { Locator, Page, Request } from '@playwright/test';
import { routeAgents, routeSuccessfulRuns, suppressWhatsNew } from '../lib/playwright/mock-factory.js';
import {
AMR_PERSONAL_WORKSPACE_CONTEXT,
AMR_PERSONAL_WORKSPACE_HEADERS,
mockAmrPersonalWorkspace,
openSettingsDialog,
settingsSurface,
} from '../lib/playwright/amr.js';
// The `/projects` view in `EntryShell` renders a `CenteredLoader` until
// `projectsLoading || skillsLoading || designSystemsLoading` all clear
// (`apps/web/src/components/EntryShell.tsx`). Tests that land on `/projects`
// should stub the catalog endpoints that are unrelated to the project-list
// behavior under test; otherwise a large registry response can keep the first
// assertion gated on daemon/catalog timing instead of the UI contract.
async function stubCatalogsEmpty(page: Page): Promise<void> {
await page.route('**/api/design-templates', async (route) => {
await route.fulfill({ json: { designTemplates: [] } });
});
await page.route('**/api/design-systems', async (route) => {
await route.fulfill({ json: { designSystems: [] } });
});
}
const STORAGE_KEY = 'open-design:config';
function projectDesignSystemTrigger(page: Page): Locator {
return page
.getByTestId('chat-composer')
.getByTestId('home-hero-design-system-trigger');
}
const AGENTS = [
{
id: 'codex',
name: 'Codex CLI',
bin: 'codex',
available: true,
version: '0.134.0',
models: [
{ id: 'default', label: 'Default (CLI config)' },
{
id: 'gpt-5.5',
label: 'GPT 5.5',
additionalSpeedTiers: ['fast'],
serviceTierOptions: [{ id: 'priority', label: 'Fast' }],
},
],
},
{
id: 'claude',
name: 'Claude Code',
bin: 'claude',
available: true,
version: '2.1.131',
models: [
{ id: 'default', label: 'Default (CLI config)' },
{ id: 'sonnet', label: 'Sonnet (alias)' },
{ id: 'opus', label: 'Opus (alias)' },
],
},
];
const DESIGN_SYSTEMS = [
{
id: 'nexu-soft-tech',
title: 'Nexu Soft Tech',
category: 'Product',
summary: 'Warm utility system for product interfaces.',
swatches: ['#F7F4EE', '#D6CBBF', '#1F2937', '#D97757'],
},
{
id: 'editorial-noir',
title: 'Editorial Noir',
category: 'Editorial',
summary: 'High-contrast editorial system with expressive type.',
swatches: ['#111111', '#F6EFE6', '#C44536', '#F2C14E'],
},
{
id: 'data-mist',
title: 'Data Mist',
category: 'Analytics',
summary: 'Calm dashboard system for dense data products.',
swatches: ['#EAF4F4', '#5EAAA8', '#05668D', '#0B132B'],
},
];
async function stubEmptyProjectsNewProjectData(page: Page): Promise<void> {
await page.route('**/api/skills', async (route) => {
await route.fulfill({ json: { skills: TAB_SKILLS } });
});
await page.route('**/api/connectors', async (route) => {
await route.fulfill({ json: { connectors: [] } });
});
await page.route('**/api/connectors/status', async (route) => {
await route.fulfill({ json: { statuses: {} } });
});
await page.route('**/api/projects', async (route) => {
if (route.request().method() === 'GET') {
await route.fulfill({ json: { projects: [] } });
return;
}
await route.fallback();
});
await page.route('**/api/design-systems', async (route) => {
await route.fulfill({ json: { designSystems: DESIGN_SYSTEMS } });
});
}
async function openNewProjectFromEmptyProjects(page: Page): Promise<void> {
await page.goto('/projects', { waitUntil: 'domcontentloaded' });
await expect(page.getByText('Loading OpenDesign…')).toHaveCount(0, { timeout: 15_000 });
await expect(page.locator('.designs-empty-state')).toBeVisible();
await page.getByTestId('designs-empty-new-project').click();
await expect(page.getByTestId('new-project-modal')).toBeVisible();
await expect(page.getByTestId('new-project-panel')).toBeVisible();
}
// #5517's rail has no "+ New project" button any more, so the Projects view's
// own CTA is the entry this flow starts from.
async function openNewProjectFromProjectsView(page: Page): Promise<void> {
await page.addInitScript(() => {
window.localStorage.setItem('od.entry.railOpen', 'true');
});
await page.goto('/projects', { waitUntil: 'domcontentloaded' });
await expect(page.locator('.entry')).toHaveClass(/entry--rail-open/);
const projectsView = page.getByTestId('entry-view-projects');
await expect(projectsView).toBeVisible();
const createButton = projectsView
.getByTestId('designs-new-project')
.or(projectsView.getByTestId('designs-empty-new-project'))
.first();
await expect(createButton).toBeVisible();
await createButton.click();
await expect(page.getByTestId('new-project-modal')).toBeVisible();
await expect(page.getByTestId('new-project-panel')).toBeVisible();
}
const TAB_SKILLS = [
skillSummary('prototype-skill', 'Prototype Skill', 'prototype', 'web', ['prototype']),
skillSummary('live-artifact', 'live-artifact', 'prototype', 'web', []),
skillSummary('deck-skill', 'Deck Skill', 'deck', 'web', ['deck']),
skillSummary('image-skill', 'Image Skill', 'image', 'image', ['image']),
];
const COMPOSER_PLUS_PLUGIN = {
id: 'composer-context-plugin',
title: 'Composer Context Plugin',
version: '1.0.0',
trust: 'bundled',
sourceKind: 'bundled',
source: '/tmp/composer-context-plugin',
fsPath: '/tmp/composer-context-plugin',
capabilitiesGranted: ['prompt:inject'],
installedAt: 0,
updatedAt: 0,
manifest: {
name: 'composer-context-plugin',
title: 'Composer Context Plugin',
version: '1.0.0',
description: 'Project composer context picker fixture.',
od: {
kind: 'scenario',
taskKind: 'new-generation',
useCase: {
query: 'Use the composer context plugin.',
},
},
},
};
test.beforeEach(async ({ page }) => {
// The entry home mounts `WhatsNewPopup` (EntryShell.tsx), and its backdrop
// sits at z-index 1500 — above the z-index 120 workspace chrome that owns
// the rail toggle. A live release card therefore swallows every rail/settings
// click in this file. Pin it closed.
await suppressWhatsNew(page);
let appConfig = {
onboardingCompleted: true,
privacyDecisionAt: 1,
telemetry: { metrics: false, content: false, artifactManifest: false },
mode: 'daemon',
agentId: 'codex',
skillId: null,
designSystemId: null,
agentModels: { codex: { model: 'default' } },
agentCliEnv: {},
};
await page.addInitScript((key) => {
window.localStorage.setItem(
key,
JSON.stringify({
mode: 'daemon',
apiKey: '',
baseUrl: 'https://api.anthropic.com',
model: 'default',
agentId: 'codex',
skillId: null,
designSystemId: null,
onboardingCompleted: true,
privacyDecisionAt: 1,
telemetry: { metrics: false, content: false, artifactManifest: false },
agentModels: { codex: { model: 'default' } },
}),
);
}, STORAGE_KEY);
await page.route('**/api/app-config', async (route) => {
if (route.request().method() === 'PUT') {
const next = route.request().postDataJSON() as Record<string, unknown>;
appConfig = {
...appConfig,
...next,
};
await route.fulfill({ json: { config: appConfig } });
return;
}
await route.fulfill({
json: {
config: appConfig,
},
});
});
await routeAgents(page, AGENTS);
});
function artifactPreview(page: Page) {
return page.locator(ACTIVE_ARTIFACT_PREVIEW_SELECTOR).first();
}
function artifactPreviewFrame(page: Page) {
return page.frameLocator(ACTIVE_ARTIFACT_PREVIEW_SELECTOR);
}
test.describe('new project modal from left rail', () => {
// Timeout-only configure: both tests open the modal themselves against
// stubbed data; serial would make the pair atomic within one CI shard.
test.describe.configure({ timeout: 60_000 });
test('[P1] new project tabs switch visible form sections and preserve drafts', async ({ page }) => {
await stubEmptyProjectsNewProjectData(page);
await openNewProjectFromProjectsView(page);
await expect(page.getByTestId('new-project-tab-prototype')).toHaveAttribute('aria-selected', 'true');
await expect(page.locator('.newproj-title')).toContainText('New prototype');
await expect(page.getByTestId('design-system-trigger')).toBeVisible();
await expect(page.getByText('Fidelity', { exact: true })).toBeVisible();
await page.getByTestId('new-project-name').fill('Prototype draft survives');
await page.getByTestId('new-project-tab-live-artifact').click();
await expect(page.getByTestId('new-project-tab-live-artifact')).toHaveAttribute('aria-selected', 'true');
await expect(page.locator('.newproj-title')).toContainText('New live artifact');
await expect(page.locator('.newproj-title')).toContainText('Beta');
await expect(page.getByTestId('design-system-picker')).toHaveCount(0);
await expect(page.getByTestId('new-project-connectors')).toBeVisible();
await expect(page.getByTestId('create-project')).toContainText('Create live artifact');
await page.getByTestId('new-project-tab-deck').click();
await expect(page.getByTestId('new-project-tab-deck')).toHaveAttribute('aria-selected', 'true');
await expect(page.locator('.newproj-title')).toContainText('New slide deck');
await expect(page.getByTestId('design-system-trigger')).toBeVisible();
await expect(page.getByText('Use speaker notes')).toBeVisible();
await expect(page.getByTestId('new-project-connectors')).toHaveCount(0);
await page.getByTestId('new-project-tab-prototype').click();
await expect(page.getByTestId('new-project-tab-prototype')).toHaveAttribute('aria-selected', 'true');
await expect(page.locator('.newproj-title')).toContainText('New prototype');
await expect(page.getByTestId('new-project-name')).toHaveValue('Prototype draft survives');
});
test('[P1] new project media tab switches inner media surfaces', async ({ page }) => {
await stubEmptyProjectsNewProjectData(page);
await openNewProjectFromProjectsView(page);
// Playwright auto-scrolls the tab into view; the consolidated media flow
// keeps image/video/audio as inner segmented surfaces.
await page.getByTestId('new-project-tab-media').click();
await expect(page.getByTestId('new-project-tab-media')).toHaveAttribute('aria-selected', 'true');
await page.getByTestId('new-project-media-surface-image').click();
await expect(page.getByTestId('new-project-media-surface-image')).toHaveAttribute('aria-selected', 'true');
await expect(page.locator('.newproj-title')).toContainText('New image');
await expect(page.getByTestId('design-system-picker')).toHaveCount(0);
await expect(page.getByText('Model', { exact: true })).toBeVisible();
await expect(page.getByText('Aspect', { exact: true })).toBeVisible();
await page.getByTestId('new-project-media-surface-video').click();
await expect(page.getByTestId('new-project-media-surface-video')).toHaveAttribute('aria-selected', 'true');
await expect(page.locator('.newproj-title')).toContainText('New video');
await page.getByTestId('new-project-media-surface-audio').click();
await expect(page.getByTestId('new-project-media-surface-audio')).toHaveAttribute('aria-selected', 'true');
await expect(page.locator('.newproj-title')).toContainText('New audio');
});
});
test('[P0] projects empty state create action opens the new project flow', async ({ page }) => {
await page.route('**/api/skills', async (route) => {
await route.fulfill({ json: { skills: TAB_SKILLS } });
});
await page.route('**/api/connectors', async (route) => {
await route.fulfill({ json: { connectors: [] } });
});
await page.route('**/api/connectors/status', async (route) => {
await route.fulfill({ json: { statuses: {} } });
});
await page.route('**/api/projects', async (route) => {
if (route.request().method() === 'GET') {
await route.fulfill({ json: { projects: [] } });
return;
}
await route.fallback();
});
await stubCatalogsEmpty(page);
await openNewProjectFromEmptyProjects(page);
await expect(page.getByTestId('new-project-modal')).toBeVisible();
await expect(page.getByTestId('new-project-panel')).toBeVisible();
await expect(page.getByTestId('new-project-tab-prototype')).toHaveAttribute('aria-selected', 'true');
await expect(page.locator('.newproj-title')).toContainText('New prototype');
});
test('[P0] UI-created Personal project recovers preview and write authority after reload', async ({ page }) => {
// Create + upload + gated reload needs more than the suite default once the
// post-reload waits use the long boot/route budgets under CI contention.
test.setTimeout(T.xlong);
await mockWritablePersonalProjectScope(page);
await stubCatalogsEmpty(page);
await page.goto('/');
await openNewProjectModal(page);
await page.getByTestId('new-project-tab-prototype').click();
await page.getByTestId('new-project-name').fill('Reloaded Personal authority');
await expect(page.getByTestId('create-project')).toBeEnabled();
await page.getByTestId('create-project').click();
await expectWorkspaceReady(page);
const uploadedName = await uploadTinyHtml(
page,
'reload-personal-authority.html',
'<!doctype html><html><body><h1>Reloaded Personal preview</h1></body></html>',
{ headers: AMR_PERSONAL_WORKSPACE_HEADERS },
);
await openUploadedHtmlArtifactPreview(page, uploadedName);
await expect(artifactPreviewFrame(page).getByRole('heading', {
name: 'Reloaded Personal preview',
})).toBeVisible();
let releaseScope = () => {};
const scopeGate = new Promise<void>((resolve) => {
releaseScope = resolve;
});
let releaseStatus = () => {};
const statusGate = new Promise<void>((resolve) => {
releaseStatus = resolve;
});
await page.route('**/api/projects/*/workspace-scope', async (route) => {
await scopeGate;
const projectId = getProjectIdFromApiPath(route.request().url());
await route.fulfill({
json: {
scope: {
kind: 'personal',
projectId,
workspaceId: AMR_PERSONAL_WORKSPACE_CONTEXT.workspaceId,
visibility: 'personal',
context: AMR_PERSONAL_WORKSPACE_CONTEXT,
},
},
});
});
await page.route('**/api/projects/*/collab/status', async (route) => {
await statusGate;
await route.fulfill({
json: {
publishedVersion: null,
materializedVersion: null,
syncState: 'local_only',
ownerMemberId: null,
},
});
});
const scopeRequested = page.waitForRequest(
(request) => new URL(request.url()).pathname.endsWith('/workspace-scope'),
{ timeout: T.long },
);
const statusRequested = page.waitForRequest(
(request) => new URL(request.url()).pathname.endsWith('/collab/status'),
{ timeout: T.long },
);
try {
// A hard reload drops the module-local same-session creation witness. Keep
// both authority reads unresolved long enough to observe the fail-closed
// state, then release them independently. The persisted Personal binding —
// not that ephemeral witness — must reconnect the already-ready artifact.
//
// Reload only reaches `domcontentloaded` while the dynamic App boot shell
// (`Loading OpenDesign…`) and the project-route workspace-context gate
// (`Loading workspace…`) may still own the page. Wait those out with the
// suite's long budget before asserting the fail-closed workspace chrome —
// the default expect timeout is 10s and is too short under CI contention.
await page.reload({ waitUntil: 'domcontentloaded' });
await page
.getByText('Loading OpenDesign…')
.waitFor({ state: 'hidden', timeout: T.long })
.catch(() => {});
await expect(page.getByText('Loading workspace…')).toHaveCount(0, { timeout: T.long });
await expect(page.getByTestId('file-workspace')).toBeVisible({ timeout: T.long });
await scopeRequested;
// The content-loading skeleton is intentionally transient: a restored
// iframe can become ready before this assertion runs. The authority gate is
// the durable user-visible contract while the scope request is unresolved.
await expect(page.getByTestId('chat-composer-input')).toHaveAttribute('aria-readonly', 'true');
await expect(page.getByRole('button', { name: /^Share$/i })).toBeDisabled();
releaseScope();
// Prevent a second release from the finally if the remaining asserts fail.
releaseScope = () => {};
await statusRequested;
await expect(artifactPreviewFrame(page).getByRole('heading', {
name: 'Reloaded Personal preview',
})).toBeVisible({ timeout: T.long });
// A daemon-confirmed `personal` scope IS the write authority — the gate must
// NOT stay closed waiting on `/collab/status`.
//
// This assertion used to read `toHaveAttribute('aria-readonly', 'true')`,
// written 2026-08-25 when a personal project really did stay read-only until
// status answered. OPEND-2624 (commit `a17a22e32a`, 2026-09-04) deliberately
// changed that: `projectIsDaemonConfirmedPersonal` in
// `apps/web/src/collab/useProjectCollab.ts` now grants write authority from
// the scope alone, because the old behavior showed the creator of a private
// local-only draft 「这是共享项目」 with chat, upload, edit and export all
// disabled while the daemon would have accepted every one of those writes.
// The ruling is pinned by
// `apps/web/tests/collab/opend-2624-personal-project-readonly.test.tsx:211`
// ("does not present a daemon-confirmed personal project as shared read-only
// when /collab/status refuses").
//
// So DO NOT flip this back to expecting read-only: after that ruling the
// read-only window here is only as wide as one React commit, and the old
// assertion passed purely by racing it. `/collab/status` is still unresolved
// on purpose — write authority has to come back from the persisted Personal
// binding by itself. Share shares the same `viewerOnly` gate as the composer
// (`FileViewer.tsx`), so it re-enables on the same tick.
await expect(page.getByTestId('chat-composer-input')).not.toHaveAttribute('aria-readonly', 'true');
await expect(page.getByRole('button', { name: /^Share$/i })).toBeEnabled();
releaseStatus();
releaseStatus = () => {};
// A settled status must not demote the writer the scope just confirmed.
await expect(page.getByTestId('chat-composer-input')).not.toHaveAttribute('aria-readonly', 'true');
await expect(page.getByRole('button', { name: /^Share$/i })).toBeEnabled();
} finally {
releaseScope();
releaseStatus();
}
});
test('[P1] design system multi-select stores primary and inspiration metadata', async ({ page }) => {
await stubEmptyProjectsNewProjectData(page);
await openNewProjectFromProjectsView(page);
await page.getByTestId('new-project-tab-prototype').click();
await page.getByTestId('new-project-name').fill('Design system multi select metadata');
await expect(page.getByTestId('design-system-trigger')).toContainText('Nexu Soft Tech');
await page.getByTestId('design-system-trigger').click();
const multiTab = page.getByRole('tab', { name: /multi/i });
await multiTab.click();
await expect(multiTab).toHaveAttribute('aria-selected', 'true');
await page.getByRole('option', { name: /Editorial Noir/i }).click();
await page.getByRole('option', { name: /Data Mist/i }).click();
await expect(page.getByTestId('design-system-trigger')).toContainText('Nexu Soft Tech');
await expect(page.getByTestId('design-system-trigger')).toContainText('+2');
await page.getByTestId('design-system-trigger').click();
await expect(page.locator('.ds-picker-popover')).toHaveCount(0);
const createProjectRequest = page.waitForRequest(isCreateProjectRequest);
await expect(page.getByTestId('create-project')).toBeEnabled();
await page.getByTestId('create-project').click({ force: true });
const request = await createProjectRequest;
const body = request.postDataJSON() as {
designSystemId?: string | null;
metadata?: {
inspirationDesignSystemIds?: string[];
};
};
expect(body.designSystemId).toBe('nexu-soft-tech');
expect(body.metadata?.inspirationDesignSystemIds).toEqual([
'editorial-noir',
'data-mist',
]);
});
test('[P1] design system picker searches and switches the single selected system', async ({ page }) => {
await stubEmptyProjectsNewProjectData(page);
await openNewProjectFromProjectsView(page);
await page.getByTestId('new-project-tab-prototype').click();
await page.getByTestId('new-project-name').fill('Design system single switch flow');
await expect(page.getByTestId('design-system-trigger')).toBeVisible();
await page.getByTestId('design-system-trigger').click();
await page.getByTestId('design-system-search').fill('mist');
await expect(page.getByRole('option', { name: /Data Mist/i })).toBeVisible();
await expect(page.getByRole('option', { name: /Nexu Soft Tech/i })).toHaveCount(0);
await page.getByRole('option', { name: /Data Mist/i }).click();
await expect(page.getByTestId('design-system-trigger')).toContainText('Data Mist');
await expect(page.getByTestId('design-system-trigger')).toContainText('Analytics');
const createProjectRequest = page.waitForRequest(isCreateProjectRequest);
await expect(page.getByTestId('create-project')).toBeEnabled();
await page.getByTestId('create-project').click({ force: true });
const request = await createProjectRequest;
const body = request.postDataJSON() as {
designSystemId?: string | null;
metadata?: {
inspirationDesignSystemIds?: string[];
};
};
expect(body.designSystemId).toBe('data-mist');
expect(body.metadata?.inspirationDesignSystemIds).toBeUndefined();
});
test('[P1] design system picker can clear the default system before creating a project', async ({ page }) => {
await stubEmptyProjectsNewProjectData(page);
await openNewProjectFromProjectsView(page);
await page.getByTestId('new-project-tab-prototype').click();
await page.getByTestId('new-project-name').fill('Design system clear create flow');
await expect(page.getByTestId('design-system-trigger')).toContainText('Nexu Soft Tech');
await page.getByTestId('design-system-trigger').click();
await page.getByRole('option', { name: /None.*freeform/i }).click();
await expect(page.getByTestId('design-system-trigger')).toContainText('None');
await expect(page.getByTestId('design-system-trigger')).not.toContainText('Nexu Soft Tech');
const createProjectRequest = page.waitForRequest(isCreateProjectRequest);
await expect(page.getByTestId('create-project')).toBeEnabled();
await page.getByTestId('create-project').click({ force: true });
const request = await createProjectRequest;
const body = request.postDataJSON() as {
designSystemId?: string | null;
metadata?: {
inspirationDesignSystemIds?: string[];
};
};
expect(body.designSystemId).toBeNull();
expect(body.metadata?.inspirationDesignSystemIds).toBeUndefined();
});
test('[P1] stale daemon default design system is not posted when creating a project', async ({ page }) => {
await page.route('**/api/app-config', async (route) => {
if (route.request().method() === 'PUT') {
await route.fulfill({
json: {
config: {
onboardingCompleted: true,
privacyDecisionAt: 1,
telemetry: { metrics: false, content: false, artifactManifest: false },
mode: 'daemon',
agentId: 'codex',
skillId: null,
designSystemId: 'stale-design-system',
agentModels: { codex: { model: 'default' } },
agentCliEnv: {},
},
},
});
return;
}
await route.fulfill({
json: {
config: {
onboardingCompleted: true,
privacyDecisionAt: 1,
telemetry: { metrics: false, content: false, artifactManifest: false },
mode: 'daemon',
agentId: 'codex',
skillId: null,
designSystemId: 'stale-design-system',
agentModels: { codex: { model: 'default' } },
agentCliEnv: {},
},
},
});
});
await stubEmptyProjectsNewProjectData(page);
await openNewProjectFromProjectsView(page);
await page.getByTestId('new-project-tab-prototype').click();
await page.getByTestId('new-project-name').fill('Stale design system default flow');
await expect(page.getByTestId('design-system-trigger')).toContainText('None');
await expect(page.getByTestId('design-system-trigger')).not.toContainText('stale-design-system');
const createProjectRequest = page.waitForRequest(isCreateProjectRequest);
await expect(page.getByTestId('create-project')).toBeEnabled();
await page.getByTestId('create-project').click({ force: true });
const request = await createProjectRequest;
const body = request.postDataJSON() as {
designSystemId?: string | null;
metadata?: {
inspirationDesignSystemIds?: string[];
};
};
expect(body.designSystemId).toBeNull();
expect(body.designSystemId).not.toBe('stale-design-system');
expect(body.metadata?.inspirationDesignSystemIds).toBeUndefined();
});
test('[P1] project detail composer design system picker switches the active project design system', async ({ page }) => {
await page.route('**/api/design-systems', async (route) => {
await route.fulfill({ json: { designSystems: DESIGN_SYSTEMS } });
});
await page.goto('/');
await createProject(page, 'Header design system switch');
await expectWorkspaceReady(page);
const trigger = projectDesignSystemTrigger(page);
await expect(trigger).toHaveAccessibleName(/^Design system$/i);
await trigger.click();
const popover = page.getByTestId('project-ds-picker-popover');
await expect(popover).toBeVisible();
await page.getByTestId('project-ds-picker-search').fill('editorial');
const editorialOption = page.getByRole('option', { name: /^Editorial Noir$/ });
await expect(editorialOption).toBeVisible();
const patchRequest = page.waitForRequest((request) => {
const url = new URL(request.url());
return url.pathname === `/api/projects/${getProjectContextFromUrl(page).projectId}` && request.method() === 'PATCH';
});
await editorialOption.click();
await expect(popover).toHaveCount(0);
const request = await patchRequest;
const body = request.postDataJSON() as { designSystemId?: string | null };
expect(body.designSystemId).toBe('editorial-noir');
await expect(trigger).toHaveAccessibleName(/Editorial Noir/i);
});
test('[P1] design system create Back restores the originating project conversation', async ({ page }) => {
await page.route('**/api/design-systems', async (route) => {
await route.fulfill({ json: { designSystems: DESIGN_SYSTEMS } });
});
await page.goto('/');
await createProject(page, 'Design system create return target');
await expectWorkspaceReady(page);
const origin = new URL(page.url());
expect(origin.pathname).toMatch(/^\/projects\/[^/]+\/conversations\/[^/]+$/);
await projectDesignSystemTrigger(page).click();
await page.getByTestId('project-ds-picker-create').click();
await expect(page).toHaveURL(/\/design-systems\/create$/);
await page.getByRole('button', { name: /^(Back|返回)$/ }).first().click();
await expect(page).toHaveURL(origin.toString());
await expectWorkspaceReady(page);
});
test('[P0] @critical project detail composer design system switch carries into the next run request', async ({ page }) => {
const runRequestBodies: Array<Record<string, unknown>> = [];
await routeSuccessfulRuns(page, { bodies: runRequestBodies, runId: 'mock-run' });
await page.route('**/api/design-systems', async (route) => {
await route.fulfill({ json: { designSystems: DESIGN_SYSTEMS } });
});
// This helper creates through APIRequestContext, bypassing the browser-side
// same-session creation witness. Pin the scenario to an exact writable
// Personal owner so a slow catalog/status read cannot turn it viewer-only.
await mockWritablePersonalProjectScope(page);
await page.goto('/');
await createProject(page, 'Header design system run context', {
headers: AMR_PERSONAL_WORKSPACE_HEADERS,
});
await expectWorkspaceReady(page);
const trigger = projectDesignSystemTrigger(page);
await expect(trigger).toHaveAccessibleName(/^Design system$/i);
await trigger.click();
await page.getByTestId('project-ds-picker-search').fill('editorial');
const editorialOption = page.getByRole('option', { name: /^Editorial Noir$/ });
await expect(editorialOption).toBeVisible();
const patchRequest = page.waitForRequest((request) => {
const url = new URL(request.url());
return url.pathname === `/api/projects/${getProjectContextFromUrl(page).projectId}` && request.method() === 'PATCH';
});
await editorialOption.click();
const patchBody = await patchRequest.then((request) => request.postDataJSON() as { designSystemId?: string | null });
expect(patchBody.designSystemId).toBe('editorial-noir');
await expect(trigger).toHaveAccessibleName(/Editorial Noir/i);
const input = page.getByTestId('chat-composer-input');
await input.fill('Use the active design system in this layout.');
const sendButton = page.getByTestId('chat-send');
await expect(sendButton).toBeEnabled();
await Promise.all([
page.waitForRequest((request) => request.url().includes('/api/runs') && request.method() === 'POST'),
sendButton.click(),
]);
expect(runRequestBodies.length).toBeGreaterThan(0);
expect(runRequestBodies[0]?.designSystemId).toBe('editorial-noir');
});
test('[P1] project detail design system picker stays inside the composer controls', async ({ page }) => {
await page.goto('/');
await createProject(page, 'Composer design system position');
await expectWorkspaceReady(page);
const composer = page.getByTestId('chat-composer');
await expect(
composer.getByTestId('home-hero-design-system-trigger'),
).toHaveAccessibleName(/^Design system$/i);
});
test('[P1] project detail composer working directory picker opens without leaving chat', async ({ page }) => {
await page.goto('/');
await createProject(page, 'Composer working directory picker');
await expectWorkspaceReady(page);
const composer = page.getByTestId('chat-composer');
await composer.getByTestId('chat-plus-trigger').click();
await page.getByTestId('composer-plus-working-dir').click();
await expect(page.getByTestId('composer-plus-working-dir-pick')).toBeVisible();
await expect(page).toHaveURL(/\/projects\//);
});
test('[P1] project detail composer plus menu exposes the attachment entry and no resource submenus', async ({ page }) => {
await routeComposerPlusFixtures(page);
await page.goto('/');
await createProject(page, 'Composer plus context menu');
await expectWorkspaceReady(page);
const composer = page.getByTestId('chat-composer');
await composer.getByTestId('chat-plus-trigger').click();
await expect(page.getByTestId('composer-plus-attach')).toBeVisible();
// Plugins, connectors and MCP were removed from this menu; they stay
// reachable from their own surfaces.
await expect(page.getByTestId('composer-plus-plugins')).toHaveCount(0);
await expect(page.getByTestId('composer-plus-connectors')).toHaveCount(0);
await expect(page.getByTestId('composer-plus-mcp')).toHaveCount(0);
});
test('[P1] project detail composer plus menu opens project, local code, Figma help, and design system context actions', async ({ page }) => {
const referenceProject = {
id: 'ref-project-context',
name: 'Reference Project Context',
skillId: null,
designSystemId: null,
createdAt: Date.now() - 1_000,
updatedAt: Date.now(),
metadata: {
kind: 'prototype',
nameSource: 'user',
},
};
await routeComposerPlusFixtures(page);
await page.route('**/api/projects', async (route) => {
if (route.request().method() === 'GET') {
await route.fulfill({ json: { projects: [referenceProject] } });
return;
}
await route.fallback();
});
await page.route('**/api/projects/ref-project-context**', async (route) => {
await route.fulfill({
json: {
project: referenceProject,
resolvedDir: '/tmp/open-design/reference-project-context',
},
});
});
await page.route('**/api/projects/*', async (route) => {
if (route.request().method() !== 'PATCH') {
await route.fallback();
return;
}
const url = new URL(route.request().url());
const id = decodeURIComponent(url.pathname.split('/').pop() ?? 'composer-plus-context-actions');
const body = route.request().postDataJSON() as { metadata?: Record<string, unknown> };
await route.fulfill({
json: {
project: {
id,
name: 'Composer plus context actions',
skillId: null,
designSystemId: null,
createdAt: Date.now() - 1_000,
updatedAt: Date.now(),
metadata: body.metadata ?? { kind: 'prototype' },
},
},
});
});
await page.route('**/api/dialog/open-folder', async (route) => {
await route.fulfill({ json: { path: '/tmp/open-design/local-code-project' } });
});
await page.route('**/api/dir-exists', async (route) => {
await route.fulfill({ json: { exists: true } });
});
await page.goto('/');
await createProject(page, 'Composer plus context actions');
await expectWorkspaceReady(page);
const composer = page.getByTestId('chat-composer');
const input = page.getByTestId('chat-composer-input');
await composer.getByTestId('chat-plus-trigger').click();
// Both context actions sit inside the "+" menu's working-dir group.
await page.getByTestId('composer-plus-working-dir').click();
await page.getByTestId('composer-plus-reference-project').click();
const referenceDialog = page.getByRole('dialog', { name: 'Reference another project' });
await expect(referenceDialog).toBeVisible();
await expect(referenceDialog.getByRole('option', { name: /Reference Project Context/i })).toHaveAttribute('aria-selected', 'true');
await referenceDialog.getByRole('button', { name: 'Reference project' }).click();
await expect(referenceDialog).toHaveCount(0);
await expect(input).toContainText('Reference Project Context');
await composer.getByTestId('chat-plus-trigger').click();
await page.getByTestId('composer-plus-working-dir').click();
await page.getByTestId('composer-plus-local-code').click();
await expect(input).toContainText('local-code-project');
// The "查看方法" (.fig download guide) row was removed from the "+" menu: the
// menu lists things to ATTACH to the message, and a help article is not one.
await composer.getByTestId('chat-plus-trigger').click();
await expect(page.getByTestId('composer-plus-figma-help')).toHaveCount(0);
await page.keyboard.press('Escape');
await composer.getByTestId('home-hero-design-system-trigger').click();
await expect(page.getByTestId('project-ds-picker-popover')).toBeVisible();
});
test('[P1] project detail Figma import uploads a .fig file and stages the suggested prompt', async ({ page }) => {
test.setTimeout(60_000);
const importBodies: string[] = [];
const runRequestBodies: Array<Record<string, unknown>> = [];
const suggestedPrompt = 'Build the current project from figma/DESIGN-context.md.';
await routeComposerPlusFixtures(page);
await routeSuccessfulRuns(page, { bodies: runRequestBodies, runIdPrefix: 'figma-import-build-run' });
await page.route('**/api/projects/*/figma/import', async (route) => {
importBodies.push(route.request().postData() ?? '');
await route.fulfill({
json: {
snapshotDir: 'figma',
files: ['figma/tree.json', 'figma/DESIGN-context.md', 'figma/thumbnail.png'],
inventory: {
decoded: true,
source: 'fig-file',
nodeCount: 10,
pageCount: 1,
frameCount: 2,
componentCount: 2,
colors: ['#0B5FFF'],
fonts: [{ family: 'Inter', styles: ['Regular'] }],
assetCount: 1,
hasThumbnail: true,
warnings: [],
},
thumbnailPath: 'figma/thumbnail.png',
contextPath: 'figma/DESIGN-context.md',
suggestedPrompt,
label: 'project-flow.fig',
},
});
});
await page.goto('/', { waitUntil: 'domcontentloaded' });
await createProject(page, 'Project Figma import success');
await expectWorkspaceReady(page);
const composer = page.getByTestId('chat-composer');
const input = page.getByTestId('chat-composer-input');
await composer.getByTestId('chat-plus-trigger').click();
await page.getByTestId('composer-plus-figma').click();
const figmaImport = page.getByRole('dialog', { name: 'Import from Figma' });
await expect(figmaImport).toBeVisible();
await figmaImport.locator('input[type="file"]').setInputFiles({
name: 'project-flow.fig',
mimeType: 'application/octet-stream',
buffer: Buffer.from('fake project fig payload', 'utf8'),
});
await expect(figmaImport).toContainText('project-flow.fig');
await figmaImport.getByPlaceholder(/Optional: notes/i).fill('Keep the original hierarchy.');
await figmaImport.getByRole('button', { name: 'Import & build' }).click();
await expect.poll(() => importBodies.length, { timeout: 10_000 }).toBe(1);
expect(importBodies[0]).toContain('project-flow.fig');
expect(importBodies[0]).toContain('Keep the original hierarchy.');
await expect(figmaImport).toBeVisible();
await expect(input).toContainText(suggestedPrompt);
await figmaImport.getByRole('button', { name: 'Close' }).click();
await expect(figmaImport).toHaveCount(0);
await Promise.all([
page.waitForRequest((request) => request.url().includes('/api/runs') && request.method() === 'POST'),
page.getByTestId('chat-send').click(),
]);
await expect.poll(() => runRequestBodies.length).toBe(1);
expect(runRequestBodies[0]?.message).toContain(suggestedPrompt);
});
test('[P1] project detail Figma import keeps the dialog open and retryable on decode failure', async ({ page }) => {
const importBodies: string[] = [];
await routeComposerPlusFixtures(page);
await page.route('**/api/projects/*/figma/import', async (route) => {
importBodies.push(route.request().postData() ?? '');
await route.fulfill({
status: 500,
json: { error: { message: 'Could not decode the Figma archive.' } },
});
});
await page.goto('/');
await createProject(page, 'Project Figma import failure');
await expectWorkspaceReady(page);
const composer = page.getByTestId('chat-composer');
const input = page.getByTestId('chat-composer-input');
await composer.getByTestId('chat-plus-trigger').click();
await page.getByTestId('composer-plus-figma').click();
const figmaImport = page.getByRole('dialog', { name: 'Import from Figma' });
await figmaImport.locator('input[type="file"]').setInputFiles({
name: 'broken-project-flow.fig',
mimeType: 'application/octet-stream',
buffer: Buffer.from('broken fig payload', 'utf8'),
});
await figmaImport.getByRole('button', { name: 'Import & build' }).click();
await expect.poll(() => importBodies.length, { timeout: 10_000 }).toBe(1);
await expect(figmaImport).toBeVisible();
await expect(figmaImport).toContainText('Could not decode the Figma archive.');
await expect(figmaImport.getByRole('button', { name: 'Import & build' })).toBeEnabled();
await expect(input).not.toContainText('figma/DESIGN-context.md');
});
test('[P1] project detail composer sends referenced workspace contexts into the run request', async ({ page }) => {
const runRequestBodies: Array<Record<string, unknown>> = [];
const referenceProject = {
id: 'ref-project-payload',
name: 'Reference Project Payload',
skillId: null,
designSystemId: null,
createdAt: Date.now() - 1_000,
updatedAt: Date.now(),
metadata: {
kind: 'prototype',
nameSource: 'user',
},
};
await routeComposerPlusFixtures(page);
await routeSuccessfulRuns(page, { bodies: runRequestBodies, runIdPrefix: 'workspace-context-payload-run' });
await page.route('**/api/projects', async (route) => {
if (route.request().method() === 'GET') {
await route.fulfill({ json: { projects: [referenceProject] } });
return;
}
await route.continue();
});
await page.route('**/api/projects/ref-project-payload**', async (route) => {
await route.fulfill({
json: {
project: referenceProject,
resolvedDir: '/tmp/open-design/reference-project-payload',
},
});
});
await page.route('**/api/projects/*', async (route) => {
if (route.request().method() === 'PATCH') {
const body = route.request().postDataJSON() as Record<string, unknown>;
await route.fulfill({
json: {
project: {
id: route.request().url().split('/api/projects/')[1]?.split(/[/?#]/)[0] ?? 'project',
name: 'Composer workspace context payload',
createdAt: Date.now(),
updatedAt: Date.now(),
metadata: body.metadata ?? { kind: 'prototype' },
},
},
});
return;
}
await route.fallback();
});
await page.route('**/api/dialog/open-folder', async (route) => {
await route.fulfill({ json: { path: '/tmp/open-design/local-code-project-payload' } });
});
await page.route('**/api/dir-exists', async (route) => {
await route.fulfill({ json: { exists: true } });
});
await page.goto('/');
await createProject(page, 'Composer workspace context payload');
await expectWorkspaceReady(page);
const composer = page.getByTestId('chat-composer');
const input = page.getByTestId('chat-composer-input');
await composer.getByTestId('chat-plus-trigger').click();
// Both context actions sit inside the "+" menu's working-dir group.
await page.getByTestId('composer-plus-working-dir').click();
await page.getByTestId('composer-plus-reference-project').click();
const referenceDialog = page.getByRole('dialog', { name: 'Reference another project' });
await expect(referenceDialog.getByRole('option', { name: /Reference Project Payload/i })).toHaveAttribute('aria-selected', 'true');
await referenceDialog.getByRole('button', { name: 'Reference project' }).click();
await expect(input).toContainText('Reference Project Payload');
await composer.getByTestId('chat-plus-trigger').click();
await page.getByTestId('composer-plus-working-dir').click();
await page.getByTestId('composer-plus-local-code').click();
await expect(input).toContainText('local-code-project-payload');
await input.fill('Use the referenced workspace contexts in this run.');
await Promise.all([
page.waitForRequest((request) => request.url().includes('/api/runs') && request.method() === 'POST'),
page.getByTestId('chat-send').click(),
]);
await expect.poll(() => runRequestBodies.length).toBe(1);
const context = runRequestBodies[0]?.context as { workspaceItems?: Array<{ id?: string; label?: string; absolutePath?: string }> } | undefined;
expect(context?.workspaceItems ?? []).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: 'project:ref-project-payload',
label: 'Reference Project Payload',
absolutePath: '/tmp/open-design/reference-project-payload',
}),
expect.objectContaining({
id: 'local-code:/tmp/open-design/local-code-project-payload',
label: 'local-code-project-payload',
absolutePath: '/tmp/open-design/local-code-project-payload',
}),
]),
);
});
test('[P1] project detail composer removing local-code context updates metadata and the next run request', async ({ page }) => {
test.fail(true, 'Deleting an inline workspace mention does not yet remove linkedDirs metadata');
const patchRequests: Array<Record<string, unknown>> = [];
const runRequestBodies: Array<Record<string, unknown>> = [];
await routeComposerPlusFixtures(page);
await routeSuccessfulRuns(page, { bodies: runRequestBodies, runIdPrefix: 'workspace-context-remove-run' });
await page.route('**/api/dialog/open-folder', async (route) => {
await route.fulfill({ json: { path: '/tmp/open-design/local-code-remove' } });
});
await page.route('**/api/dir-exists', async (route) => {
await route.fulfill({ json: { exists: true } });
});
await page.route('**/api/projects/*', async (route) => {
if (route.request().method() === 'PATCH') {
const body = route.request().postDataJSON() as Record<string, unknown>;
patchRequests.push(body);
await route.fulfill({
json: {
project: {
id: route.request().url().split('/api/projects/')[1]?.split(/[/?#]/)[0] ?? 'project',
name: 'Composer remove context',
createdAt: Date.now(),
updatedAt: Date.now(),
metadata: body.metadata ?? { kind: 'prototype' },
},
},
});
return;
}
await route.continue();
});
await page.goto('/');
await createProject(page, 'Composer remove context');
await expectWorkspaceReady(page);
const composer = page.getByTestId('chat-composer');
const input = page.getByTestId('chat-composer-input');
await composer.getByTestId('chat-plus-trigger').click();
await page.getByTestId('composer-plus-working-dir').click();
await page.getByTestId('composer-plus-local-code').click();
await expect(input).toContainText('local-code-remove');
await input.press('ControlOrMeta+A');
await input.press('Backspace');
await expect(input).not.toContainText('local-code-remove');
await expect.poll(() => patchRequests.length).toBeGreaterThanOrEqual(2);
expect((patchRequests.at(-1)?.metadata as { linkedDirs?: string[] } | undefined)?.linkedDirs ?? []).toEqual([]);
await input.fill('Run without the removed local code context.');
await Promise.all([
page.waitForRequest((request) => request.url().includes('/api/runs') && request.method() === 'POST'),
page.getByTestId('chat-send').click(),
]);
await expect.poll(() => runRequestBodies.length).toBe(1);
const context = runRequestBodies[0]?.context as { workspaceItems?: Array<{ label?: string; absolutePath?: string }> } | undefined;
expect(context?.workspaceItems ?? []).toEqual([]);
});
test('[P1] project detail keeps local-code context when linkedDirs PATCH removal fails', async ({ page }) => {
test.fail(true, 'Inline workspace mention deletion does not yet reach the linkedDirs PATCH path');
test.setTimeout(60_000);
const patchRequests: Array<Record<string, unknown>> = [];
const runRequestBodies: Array<Record<string, unknown>> = [];
await routeComposerPlusFixtures(page);
await routeSuccessfulRuns(page, { bodies: runRequestBodies, runIdPrefix: 'workspace-context-remove-failure-run' });
await page.route('**/api/dialog/open-folder', async (route) => {
await route.fulfill({ json: { path: '/tmp/open-design/local-code-persist' } });
});
await page.route('**/api/dir-exists', async (route) => {
await route.fulfill({ json: { exists: true } });
});
await page.route('**/api/projects/*', async (route) => {
if (route.request().method() === 'PATCH') {
const body = route.request().postDataJSON() as Record<string, unknown>;
patchRequests.push(body);
const linkedDirs = (body.metadata as { linkedDirs?: string[] } | undefined)?.linkedDirs ?? [];
if (linkedDirs.length === 0) {
await route.fulfill({
status: 400,
json: { error: { code: 'INVALID_LINKED_DIR', message: 'linked dir removal rejected' } },
});
return;
}
await route.fulfill({
json: {
project: {
id: route.request().url().split('/api/projects/')[1]?.split(/[/?#]/)[0] ?? 'project',
name: 'Composer remove context failure',
createdAt: Date.now(),
updatedAt: Date.now(),
metadata: body.metadata ?? { kind: 'prototype' },
},
},
});
return;
}
await route.continue();
});
await page.goto('/', { waitUntil: 'domcontentloaded' });
await createProject(page, 'Composer remove context failure');
await expectWorkspaceReady(page);
const composer = page.getByTestId('chat-composer');
const input = page.getByTestId('chat-composer-input');
await composer.getByTestId('chat-plus-trigger').click();
// Link-local-code sits inside the + menu's working-dir group.
await page.getByTestId('composer-plus-working-dir').click();
await page.getByRole('menuitem', { name: /Link local code/i }).click();
await expect(input).toContainText('local-code-persist');
await input.press('ControlOrMeta+A');
await input.press('Backspace');
await expect.poll(() => patchRequests.length).toBeGreaterThanOrEqual(2);
await expect(input).toContainText('local-code-persist');
await input.fill('Run with the local code context after removal failed.');
await Promise.all([
page.waitForRequest((request) => request.url().includes('/api/runs') && request.method() === 'POST'),
page.getByTestId('chat-send').click(),
]);
await expect.poll(() => runRequestBodies.length).toBe(1);
const context = runRequestBodies[0]?.context as { workspaceItems?: Array<{ label?: string; absolutePath?: string }> } | undefined;
expect(context?.workspaceItems ?? []).toEqual(
expect.arrayContaining([
expect.objectContaining({
label: 'local-code-persist',
absolutePath: '/tmp/open-design/local-code-persist',
}),
]),
);
});
test('[P1] project detail composer context actions emit analytics event fields', async ({ page }) => {
test.fail(true, 'Inline workspace mention deletion does not yet emit context_remove analytics');
const analyticsBodies: string[] = [];
await page.route('**/api/app-config', async (route) => {
if (route.request().method() !== 'GET') {
await route.continue();
return;
}
await route.fulfill({
json: {
config: {
mode: 'daemon',
apiKey: '',
baseUrl: 'https://api.anthropic.com',
model: 'claude-sonnet-4-5',
agentId: 'codex',
skillId: null,
designSystemId: null,
onboardingCompleted: true,
agentModels: { codex: { model: 'default', reasoning: 'default' } },
privacyDecisionAt: 1,
telemetry: { metrics: true, content: false, artifactManifest: false },
},
},
});
});
await page.route('**/api/analytics/config', async (route) => {
await route.fulfill({
json: {
enabled: true,
env: 'e2e',
key: 'phc_e2e',
host: 'https://analytics.open-design.test',
installationId: 'e2e-installation',
},
});
});
await page.route('https://analytics.open-design.test/**', async (route) => {
analyticsBodies.push(route.request().postData() ?? '');
await route.fulfill({ status: 200, json: { status: 1 } });
});
await routeComposerPlusFixtures(page);
await page.route('**/api/dialog/open-folder', async (route) => {
await route.fulfill({ json: { path: '/tmp/open-design/local-code-analytics' } });
});
await page.route('**/api/dir-exists', async (route) => {
await route.fulfill({ json: { exists: true } });
});
await page.route('**/api/projects/*', async (route) => {
if (route.request().method() === 'PATCH') {
const body = route.request().postDataJSON() as Record<string, unknown>;
await route.fulfill({
json: {
project: {
id: route.request().url().split('/api/projects/')[1]?.split(/[/?#]/)[0] ?? 'project',
name: 'Composer context analytics',
createdAt: Date.now(),
updatedAt: Date.now(),
metadata: body.metadata ?? { kind: 'prototype' },
},
},
});
return;
}
await route.continue();
});
await page.goto('/');
await createProject(page, 'Composer context analytics');
await expectWorkspaceReady(page);
const composer = page.getByTestId('chat-composer');
await composer.getByTestId('chat-plus-trigger').click();
await page.getByTestId('composer-plus-working-dir').click();
await page.getByTestId('composer-plus-local-code').click();
const chip = composer.locator('.staged-context--workspace', { hasText: 'local-code-analytics' });
await expect(chip).toBeVisible();
await chip.getByRole('button', { name: /local-code-analytics/i }).click();
await expect(chip).toHaveCount(0);
await expect.poll(() => analyticsBodies.join('\n')).toContain('plus_pick');
const raw = analyticsBodies.join('\n');
expect(raw).toContain('context_remove');
expect(raw).toContain('workspace');
expect(raw).toContain('local-code');
});
const TEAM_RUN_CONTEXT = {
workspaceId: 'e2e-team-run-workspace',
workspaceName: 'E2E Team Run Workspace',
workspaceType: 'team' as const,
workspaceMemberId: 'e2e-team-run-member',
role: 'owner' as const,
memberStatus: 'active' as const,
lifecycleState: 'active' as const,
billingState: 'active' as const,
planId: 'team_plus',
seatSummary: {
seatLimit: 5,
usedSeats: 2,
availableSeats: 3,
isSeatFull: false,
},
permissions: {
canInviteMembers: true,
canManageBilling: true,
canViewWorkspaceSettings: true,
canManageSharedResources: true,
canShareProjects: true,
canWriteSyncedFiles: true,
},
workspaceSettingsUrl: 'https://console.example.test/workspace/e2e-team-run-workspace',
};
async function wireTeamRunBalanceFixtures(
page: Page,
options: {
personalBalanceUsd: string;
teamBalanceUsd: string;
},
): Promise<{
personalWalletRequests: () => number;
resetBalanceRequests: () => void;
teamBillingQueries: () => Array<Record<string, string | null>>;
fundingQueries: () => Array<Record<string, string | null>>;
}> {
let personalWalletRequestCount = 0;
const fundingQueries: Array<Record<string, string | null>> = [];
const teamBillingQueries: Array<Record<string, string | null>> = [];
await page.route('**/api/app-config', async (route) => {
if (route.request().method() !== 'GET') {
await route.fallback();
return;
}
await route.fulfill({
json: {
config: {
mode: 'daemon',
apiKey: '',
baseUrl: 'https://api.anthropic.com',
model: 'claude-sonnet-4-5',
agentId: 'amr',
skillId: null,
designSystemId: null,
onboardingCompleted: true,
privacyDecisionAt: 1,
telemetry: { metrics: false, content: false, artifactManifest: false },
agentModels: {},
agentCliEnv: {},
},
},
});
});
await routeAgents(page, [
...AGENTS,
{
id: 'amr',
name: 'OpenDesign Cloud',
bin: 'amr',
available: true,
version: 'cloud',
models: [{ id: 'claude-sonnet-4-5', label: 'Claude Sonnet 4.5' }],
},
]);
await page.route('**/api/integrations/vela/status', async (route) => {
await route.fulfill({
json: {
loggedIn: true,
loginInFlight: false,
profile: 'test',
user: {
id: 'e2e-team-run-user',
email: 'team-run@example.com',
name: 'Team Run Owner',
plan: 'team_plus',
},
account: { plan: 'free', balanceUsd: options.personalBalanceUsd },
configPath: '/tmp/.amr/config.json',
},
});
});
await page.route('**/api/integrations/vela/wallet**', async (route) => {
if (new URL(route.request().url()).pathname === '/api/integrations/vela/wallet') {
personalWalletRequestCount += 1;
}
await route.fulfill({
json: {
status: 'available',
profile: 'local',
user: {
id: 'e2e-team-run-user',
email: 'team-run@example.com',
plan: 'free',
},
balanceUsd: options.personalBalanceUsd,
updatedAt: '2026-08-02T00:00:00.000Z',
fetchedAt: '2026-08-02T00:00:00.000Z',
stale: false,
source: 'vela_api',
},
});
});
await page.route('**/api/workspace/**', async (route) => {
const request = route.request();
const url = new URL(request.url());
const { pathname } = url;
if (request.method() !== 'GET') {
await route.fallback();
return;
}
if (pathname === '/api/workspace/context') {
await route.fulfill({ json: { context: TEAM_RUN_CONTEXT } });
return;
}
if (pathname === '/api/workspace/directory') {
await route.fulfill({
json: {
items: [{
workspaceId: TEAM_RUN_CONTEXT.workspaceId,
workspaceName: TEAM_RUN_CONTEXT.workspaceName,
workspaceType: TEAM_RUN_CONTEXT.workspaceType,
workspaceMemberId: TEAM_RUN_CONTEXT.workspaceMemberId,
role: TEAM_RUN_CONTEXT.role,
memberStatus: TEAM_RUN_CONTEXT.memberStatus,
lifecycleState: TEAM_RUN_CONTEXT.lifecycleState,
}],
activeWorkspaceId: TEAM_RUN_CONTEXT.workspaceId,
},
});
return;
}
if (pathname === '/api/workspace/billing/preflight') {
fundingQueries.push({ workspaceId: url.searchParams.get('workspaceId'), modelId: url.searchParams.get('modelId') });
await route.fulfill({ json: { preflight: null } });
return;
}
if (pathname === '/api/workspace/billing') {
const query = {
scope: url.searchParams.get('scope'),
workspaceId: url.searchParams.get('workspaceId'),
freshness: url.searchParams.get('freshness'),
};
teamBillingQueries.push(query);
if (
query.scope !== 'workspace' ||
query.workspaceId !== TEAM_RUN_CONTEXT.workspaceId ||
(query.freshness !== null && query.freshness !== 'authoritative')
) {
await route.fulfill({ status: 400, json: { error: 'unexpected_billing_scope' } });
return;
}
await route.fulfill({
json: {
summary: null,
workspaceBalance: {
billingScopeVersion: 2,
workspaceId: TEAM_RUN_CONTEXT.workspaceId,
workspaceMemberId: TEAM_RUN_CONTEXT.workspaceMemberId,
balanceUsd: options.teamBalanceUsd,
expiresAt: null,
updatedAt: '2026-08-02T00:00:00.000Z',
},
workspaceRuntime: {
workspaceId: TEAM_RUN_CONTEXT.workspaceId,
workspaceMemberId: TEAM_RUN_CONTEXT.workspaceMemberId,
status: 'fresh',
revision: '1',
observedAt: '2026-08-02T00:00:00.000Z',
softExpiresAt: '2099-08-02T00:00:30.000Z',
hardExpiresAt: '2099-08-02T00:02:00.000Z',
retryAt: null,
errorCode: null,
reason:
query.freshness === 'authoritative'
? 'authoritative-action-read'
: 'explicit-billing-read',
sourceGapDetected: false,
},
...(query.freshness === 'authoritative'
? {
authoritativeWorkspaceRead: {
workspaceId: TEAM_RUN_CONTEXT.workspaceId,
workspaceMemberId: TEAM_RUN_CONTEXT.workspaceMemberId,
observedAt: '2026-08-02T00:00:00.000Z',
},
}
: {}),
},
});
return;
}
if (pathname === '/api/workspace/projects/team') {
await route.fulfill({ json: { projects: [] } });
return;
}
await route.fallback();
});
return {
personalWalletRequests: () => personalWalletRequestCount,
resetBalanceRequests: () => {
personalWalletRequestCount = 0;
teamBillingQueries.length = 0;
fundingQueries.length = 0;
},
teamBillingQueries: () => [...teamBillingQueries],
fundingQueries: () => [...fundingQueries],
};
}
async function createBoundTeamProject(
page: Page,
projectName: string,
): Promise<{ projectId: string; conversationId: string }> {
const response = await createProjectViaApi(page, projectName);
const created = (await response.json()) as {
project: Record<string, unknown> & { id: string };
conversationId: string;
};
const bindProject = (project: Record<string, unknown>) => ({
...project,
workspaceId: TEAM_RUN_CONTEXT.workspaceId,
visibility: 'personal',
createdByWorkspaceMemberId: TEAM_RUN_CONTEXT.workspaceMemberId,
updatedByWorkspaceMemberId: TEAM_RUN_CONTEXT.workspaceMemberId,
});
await page.route('**/api/projects', async (route) => {
if (route.request().method() !== 'GET') {
await route.fallback();
return;
}
const responseFromDaemon = await route.fetch();
const body = (await responseFromDaemon.json()) as {
projects?: Array<Record<string, unknown> & { id?: string }>;
};
await route.fulfill({
response: responseFromDaemon,
json: {
...body,
projects: (body.projects ?? []).map((project) =>
project.id === created.project.id ? bindProject(project) : project),
},
});
});
// The deep-link detail and the authoritative Workspace catalog must agree
// about this bound row, independent of which read finishes first.
await page.route(`**/api/workspaces/${TEAM_RUN_CONTEXT.workspaceId}/projects?**`, async (route) => {
if (route.request().method() !== 'GET') {
await route.fallback();
return;
}
await route.fulfill({ json: { projects: [bindProject(created.project)] } });
});
await page.route(`**/api/projects/${created.project.id}`, async (route) => {
if (route.request().method() !== 'GET') {
await route.fallback();
return;
}
await route.fulfill({
json: { project: bindProject(created.project) },
});
});
await page.route(`**/api/projects/${created.project.id}/collab/status`, async (route) => {
await route.fulfill({
json: {
publishedVersion: 1,
materializedVersion: 1,
awaitingFirstMaterialization: false,
syncState: 'synced',
ownerMemberId: TEAM_RUN_CONTEXT.workspaceMemberId,
ownerDisplayName: 'Team Run Owner',
ownerRole: TEAM_RUN_CONTEXT.role,
contentTransferState: null,
},
});
});
return {
projectId: created.project.id,
conversationId: created.conversationId,
};
}
test('[P0] Team project send keeps exact Team run scope through project bootstrap', async ({ page }) => {
test.setTimeout(60_000);
const prompt = 'Run against the exact Team workspace established during project bootstrap.';
const balanceRequests = await wireTeamRunBalanceFixtures(page, {
personalBalanceUsd: '0.00',
teamBalanceUsd: '99.97',
});
const { projectId, conversationId } = await createBoundTeamProject(
page,
'Exact Team scope run witness',
);
let scopeRequests = 0;
let scopedReadHeaders: Record<string, string> | null = null;
await page.route(`**/api/projects/${projectId}/workspace-scope`, async (route) => {
scopeRequests += 1;
const requestHeaders = await route.request().allHeaders();
// Route bootstrap must finish before ProjectView mounts. Capture the first
// exact Team read without blocking it, then prove the same witness reaches
// the run and billing boundaries below.
if (
scopedReadHeaders === null
&& requestHeaders['x-od-workspace-id'] === TEAM_RUN_CONTEXT.workspaceId
&& requestHeaders['x-od-workspace-member-id'] === TEAM_RUN_CONTEXT.workspaceMemberId
) {
scopedReadHeaders = requestHeaders;
}
await route.fulfill({
json: {
scope: {
kind: 'team',
projectId,
workspaceId: TEAM_RUN_CONTEXT.workspaceId,
visibility: 'personal',
context: TEAM_RUN_CONTEXT,
},
},
});
});
const runBodies: Array<Record<string, unknown>> = [];
await routeSuccessfulRuns(page, {
bodies: runBodies,
runIdPrefix: 'pending-team-scope-run',
events: false,
});
const runHeaders: Array<Record<string, string>> = [];
await page.route('**/api/runs', async (route) => {
if (route.request().method() === 'POST') {
runHeaders.push(await route.request().allHeaders());
}
await route.fallback();
});
await page.goto(`/projects/${projectId}/conversations/${conversationId}`);
await expectWorkspaceReady(page);
await expect.poll(() => scopeRequests).toBeGreaterThanOrEqual(2);
expect(scopedReadHeaders?.['x-od-workspace-id']).toBe(TEAM_RUN_CONTEXT.workspaceId);
expect(scopedReadHeaders?.['x-od-workspace-member-id']).toBe(
TEAM_RUN_CONTEXT.workspaceMemberId,
);
await expect(page.getByTestId('chat-composer-input')).toBeEditable();
balanceRequests.resetBalanceRequests();
await page.getByTestId('chat-composer-input').fill(prompt);
await page.getByTestId('chat-send').click();
await expect.poll(() => runHeaders.length).toBe(1);
expect(runHeaders[0]?.['x-od-workspace-id']).toBe(TEAM_RUN_CONTEXT.workspaceId);
expect(runHeaders[0]?.['x-od-workspace-member-id']).toBe(
TEAM_RUN_CONTEXT.workspaceMemberId,
);
// Run scope is an HTTP authority header contract. The daemon intentionally
// does not duplicate this mutable principal into ChatRequest JSON.
expect(runBodies[0]?.currentPrompt).toBe(prompt);
// Send-time feedback has its own endpoint; display billing is no longer
// an admission prerequisite. Both read paths must retain the Team scope.
await expect.poll(() => balanceRequests.fundingQueries().length).toBe(1);
expect(balanceRequests.fundingQueries()).toEqual([{
workspaceId: TEAM_RUN_CONTEXT.workspaceId, modelId: 'claude-sonnet-4-5',
}]);
const teamBillingQueries = balanceRequests.teamBillingQueries();
for (const query of teamBillingQueries) {
expect(query.scope).toBe('workspace');
expect(query.workspaceId).toBe(TEAM_RUN_CONTEXT.workspaceId);
expect([null, 'authoritative']).toContain(query.freshness);
}
expect(teamBillingQueries.some((query) => query.freshness === 'authoritative')).toBe(false);
// The sign-in check reads the account snapshot once for identity
// metadata only; Personal $0 is not the balance oracle and cannot veto the
// Team-funded run proved above.
expect(balanceRequests.personalWalletRequests()).toBe(1);
await expect(page.getByTestId('amr-balance-dialog')).toHaveCount(0);
});
test('[P0] Team project balance gate ignores funded Personal wallet and still sends on empty Team wallet', async ({ page }) => {
test.setTimeout(60_000);
const prompt = 'Do not charge the funded Personal wallet for this Team project.';
const balanceRequests = await wireTeamRunBalanceFixtures(page, {
personalBalanceUsd: '99.97',
teamBalanceUsd: '0.00',
});
const { projectId, conversationId } = await createBoundTeamProject(
page,
'Empty Team wallet run witness',
);
let scopeRequests = 0;
let scopedReadHeaders: Record<string, string> | null = null;
await page.route(`**/api/projects/${projectId}/workspace-scope`, async (route) => {
scopeRequests += 1;
const requestHeaders = await route.request().allHeaders();
if (
scopedReadHeaders === null
&& requestHeaders['x-od-workspace-id'] === TEAM_RUN_CONTEXT.workspaceId
&& requestHeaders['x-od-workspace-member-id'] === TEAM_RUN_CONTEXT.workspaceMemberId
) {
scopedReadHeaders = requestHeaders;
}
await route.fulfill({
json: {
scope: {
kind: 'team',
projectId,
workspaceId: TEAM_RUN_CONTEXT.workspaceId,
visibility: 'personal',
context: TEAM_RUN_CONTEXT,
},
},
});
});
const runBodies: Array<Record<string, unknown>> = [];
await routeSuccessfulRuns(page, {
bodies: runBodies,
runIdPrefix: 'empty-team-wallet-still-sends',
events: false,
});
const runHeaders: Array<Record<string, string>> = [];
await page.route('**/api/runs', async (route) => {
if (route.request().method() === 'POST') {
runHeaders.push(await route.request().allHeaders());
}
await route.fallback();
});
// Settle the authoritative Team catalog before asserting the deep-link UI:
// an empty late-arriving all-view correctly invalidates the route snapshot.
const workspaceProjects = page.waitForResponse((response) => {
const url = new URL(response.url());
return response.request().method() === 'GET'
&& url.pathname === `/api/workspaces/${TEAM_RUN_CONTEXT.workspaceId}/projects`
&& url.searchParams.get('view') === 'all';
});
await page.goto(`/projects/${projectId}/conversations/${conversationId}`);
const workspaceProjectsResponse = await workspaceProjects;
expect(workspaceProjectsResponse.ok()).toBe(true);
expect(await workspaceProjectsResponse.json()).toMatchObject({
projects: expect.arrayContaining([
expect.objectContaining({ id: projectId, workspaceId: TEAM_RUN_CONTEXT.workspaceId }),
]),
});
await expectWorkspaceReady(page);
await expect.poll(() => scopeRequests).toBeGreaterThanOrEqual(2);
expect(scopedReadHeaders?.['x-od-workspace-id']).toBe(TEAM_RUN_CONTEXT.workspaceId);
expect(scopedReadHeaders?.['x-od-workspace-member-id']).toBe(
TEAM_RUN_CONTEXT.workspaceMemberId,
);
await expect(page.getByTestId('chat-composer-input')).toBeEditable();
balanceRequests.resetBalanceRequests();
await page.getByTestId('chat-composer-input').fill(prompt);
await page.getByTestId('chat-send').click();
await expect.poll(() => runHeaders.length).toBe(1);
expect(runHeaders[0]?.['x-od-workspace-id']).toBe(TEAM_RUN_CONTEXT.workspaceId);
expect(runHeaders[0]?.['x-od-workspace-member-id']).toBe(
TEAM_RUN_CONTEXT.workspaceMemberId,
);
expect(runBodies[0]?.currentPrompt).toBe(prompt);
await expect(page.getByTestId('amr-balance-dialog')).toHaveCount(0);
// Send-time feedback has its own endpoint; display billing is no longer
// an admission prerequisite. Both read paths must retain the Team scope.
await expect.poll(() => balanceRequests.fundingQueries().length).toBe(1);
expect(balanceRequests.fundingQueries()).toEqual([{
workspaceId: TEAM_RUN_CONTEXT.workspaceId, modelId: 'claude-sonnet-4-5',
}]);
const teamBillingQueries = balanceRequests.teamBillingQueries();
for (const query of teamBillingQueries) {
expect(query.scope).toBe('workspace');
expect(query.workspaceId).toBe(TEAM_RUN_CONTEXT.workspaceId);
expect([null, 'authoritative']).toContain(query.freshness);
}
expect(teamBillingQueries.some((query) => query.freshness === 'authoritative')).toBe(false);
// Personal $99.97 is identity metadata only; Team $0 is not a client block.
expect(balanceRequests.personalWalletRequests()).toBe(1);
});
test('[P0] @critical project detail composer agent menu lets the user switch the model', async ({ page }) => {
test.setTimeout(60_000);
await page.goto('/');
await createProject(page, 'Composer agent switch');
await expectWorkspaceReady(page);
const { menu } = await openComposerAgentMenu(page);
const list = menu.getByTestId('avatar-model-list');
await expect(list).toBeVisible();
await expect(list.locator('.avatar-model-option.is-active')).toContainText(/default/i);
await list.getByRole('radio', { name: /^GPT 5\.5$/i }).click();
const { menu: reopened } = await openComposerAgentMenu(page);
await expect(
reopened.getByTestId('avatar-model-list').locator('.avatar-model-option.is-active'),
).toContainText(/GPT 5\.5/i);
});
test('[P0] project detail composer model switch carries into the next daemon run request', async ({ page }) => {
test.setTimeout(60_000);
const runRequestBodies: Array<Record<string, unknown>> = [];
await routeSuccessfulRuns(page, { bodies: runRequestBodies, runId: 'agent-model-run' });
await mockWritablePersonalProjectScope(page);
await page.goto('/');
await createProject(page, 'Composer agent switch run context', {
headers: AMR_PERSONAL_WORKSPACE_HEADERS,
});
await expectWorkspaceReady(page);
await pickComposerModel(page, /^GPT 5\.5$/i);
// The composer no longer carries a session-mode picker (#7635): every turn
// runs in the conversation's stored mode, which is design for a new project.
await expect(page.getByTestId('chat-composer').getByTestId('composer-mode-trigger')).toHaveCount(0);
const input = page.getByTestId('chat-composer-input');
await input.fill('Use the selected local agent for this design run.');
await Promise.all([
page.waitForRequest((request) => request.url().includes('/api/runs') && request.method() === 'POST'),
page.getByTestId('chat-send').click(),
]);
expect(runRequestBodies.length).toBeGreaterThan(0);
expect(runRequestBodies[0]?.agentId).toBe('codex');
expect(runRequestBodies[0]?.model).toBe('gpt-5.5');
expect(runRequestBodies[0]?.sessionMode).toBe('design');
});
test('[P1] GPT 5.5 Fast service tier carries into the next Codex daemon run request', async ({ page }) => {
test.setTimeout(60_000);
const runRequestBodies: Array<Record<string, unknown>> = [];
await routeSuccessfulRuns(page, { bodies: runRequestBodies, runIdPrefix: 'codex-fast-tier-run' });
await page.goto('/');
await createProject(page, 'Codex Fast service tier contract');
await expectWorkspaceReady(page);
await pickComposerModel(page, /^GPT 5\.5$/i);
const { menu } = await openComposerAgentMenu(page);
const serviceTierSelect = menu
.locator('label.avatar-select-row', { hasText: /Service tier/i })
.locator('select');
await expect(serviceTierSelect).toBeVisible();
await serviceTierSelect.selectOption('priority');
await expect(serviceTierSelect).toHaveValue('priority');
await page.keyboard.press('Escape');
await expect(page.locator('.avatar-popover[role="dialog"]')).toHaveCount(0);
await page.getByTestId('chat-composer-input').fill('Use GPT 5.5 Fast for this Codex run.');
await Promise.all([
page.waitForRequest((request) => request.url().includes('/api/runs') && request.method() === 'POST'),
page.getByTestId('chat-send').click(),
]);
await expect.poll(() => runRequestBodies.length).toBe(1);
expect(runRequestBodies[0]).toMatchObject({
agentId: 'codex',
model: 'gpt-5.5',
serviceTier: 'priority',
});
});
test('[P1] project detail composer keeps design mode across consecutive turns without a mode picker', async ({ page }) => {
test.setTimeout(60_000);
const runRequestBodies: Array<Record<string, unknown>> = [];
await routeSuccessfulRuns(page, { bodies: runRequestBodies, runIdPrefix: 'fixed-design-mode-run' });
await page.goto('/');
await createProject(page, 'Composer session mode contract');
await expectWorkspaceReady(page);
async function sendTurn(prompt: string) {
const input = page.getByTestId('chat-composer-input');
await expect(input).toBeVisible();
await input.fill(prompt);
await Promise.all([
page.waitForRequest((request) => request.url().includes('/api/runs') && request.method() === 'POST'),
page.getByTestId('chat-send').click(),
]);
await expect(input).toHaveText('');
}
// The 规划/设计/提问 picker left the composer row (#7635): there is nothing
// to alternate from here, and consecutive turns keep the conversation's
// stored mode — design for a new project — on every run request.
await expect(page.getByTestId('chat-composer').getByTestId('composer-mode-trigger')).toHaveCount(0);
await expect(page.getByTestId('composer-mode-menu')).toHaveCount(0);
await sendTurn('Design the first iteration.');
await sendTurn('Design the second iteration without touching any mode control.');
expect(runRequestBodies.map((body) => body.sessionMode)).toEqual(['design', 'design']);
});
test('[P0] @critical project detail composer opens Execution settings where BYOK model choice persists', async ({ page }) => {
test.setTimeout(60_000);
let config = {
mode: 'daemon',
apiKey: 'sk-openai-test',
apiProtocol: 'openai',
apiVersion: '',
baseUrl: 'https://api.openai.com/v1',
model: 'gpt-4o-2024-05-13',
apiProviderBaseUrl: 'https://api.openai.com/v1',
agentId: 'codex',
skillId: null,
designSystemId: null,
onboardingCompleted: true,
privacyDecisionAt: 1,
telemetry: { metrics: false, content: false, artifactManifest: false },
mediaProviders: {},
agentModels: { codex: { model: 'default' } },
agentCliEnv: {},
};
await page.addInitScript(
({ key, value }) => {
window.localStorage.setItem(key, JSON.stringify(value));
},
{ key: STORAGE_KEY, value: config },
);
await page.route('**/api/app-config', async (route) => {
if (route.request().method() === 'PUT') {
const body = route.request().postDataJSON() as Record<string, unknown>;
config = { ...config, ...body };
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ config }),
});
return;
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ config }),
});
});
await page.goto('/');
await createProject(page, 'Composer BYOK model switch');
await expectWorkspaceReady(page);
const { menu } = await openComposerAgentMenu(page);
await menu.getByTestId('avatar-open-execution-settings').click();
const settings = settingsSurface(page);
await expect(settings).toBeVisible();
await expect(settings.getByTestId('settings-nav-execution')).toBeVisible();
await settings.getByRole('tab', { name: 'API providers' }).click();
await settings.getByRole('tab', { name: 'OpenAI', exact: true }).click();
const modelSelect = settings.getByRole('combobox', { name: 'Model', exact: true });
await expect(modelSelect).toContainText('Custom (type below)…');
await expect(settings.getByRole('textbox', { name: 'Custom model id', exact: true }))
.toHaveValue('gpt-4o-2024-05-13');
await modelSelect.click();
const modelPopover = page.getByTestId('settings-byok-model-popover');
await expect(modelPopover.getByRole('option', { name: /^gpt-4o-mini$/i })).toBeVisible();
await expect(modelPopover.getByRole('option', { name: /deepseek/i })).toHaveCount(0);
await expect(modelPopover.getByRole('option', { name: /MiniMax/i })).toHaveCount(0);
await modelPopover.getByRole('option', { name: /^gpt-4o-mini$/i }).click();
await expect(modelSelect).toContainText('gpt-4o-mini');
await expect.poll(async () => page.evaluate((key) => {
const raw = window.localStorage.getItem(key);
return raw ? JSON.parse(raw) : null;
}, STORAGE_KEY)).toMatchObject({
mode: 'api',
model: 'gpt-4o-mini',
});
});
test('[P0] @critical project detail composer keeps Local CLI and BYOK model choices isolated', async ({ page }) => {
test.setTimeout(60_000);
const config = {
mode: 'daemon',
apiKey: 'test-byok-key',
apiProtocol: 'openai',
apiVersion: '',
baseUrl: 'https://api.openai.com/v1',
model: 'gpt-4o-2024-05-13',
apiProviderBaseUrl: 'https://api.openai.com/v1',
agentId: 'codex',
skillId: null,
designSystemId: null,
onboardingCompleted: true,
privacyDecisionAt: 1,
telemetry: { metrics: false, content: false, artifactManifest: false },
mediaProviders: {},
agentModels: { codex: { model: 'default' } },
agentCliEnv: {},
};
await page.addInitScript(
({ key, value }) => {
window.localStorage.setItem(key, JSON.stringify(value));
},
{ key: STORAGE_KEY, value: config },
);
await page.route('**/api/app-config', async (route) => {
if (route.request().method() === 'PUT') {
const body = route.request().postDataJSON() as Record<string, unknown>;
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ config: body }),
});
return;
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ config }),
});
});
await page.goto('/');
await createProject(page, 'Composer model mode isolation');
await expectWorkspaceReady(page);
// Picking a Local CLI model must not touch the stored BYOK model: the two
// live in separate config slots (`agentModels[agentId].model` vs `model`).
await pickComposerModel(page, /^GPT 5\.5$/i);
await expect.poll(async () => page.evaluate((key) => {
const raw = window.localStorage.getItem(key);
return raw ? JSON.parse(raw) : null;
}, STORAGE_KEY)).toMatchObject({
mode: 'daemon',
agentId: 'codex',
model: 'gpt-4o-2024-05-13',
agentModels: {
codex: { model: 'gpt-5.5' },
},
});
const { menu } = await openComposerAgentMenu(page);
await expect(
menu.getByTestId('avatar-model-list').locator('.avatar-model-option.is-active'),
).toContainText(/GPT 5\.5/i);
});
test('[P0] clearing the project design system removes designSystemId from the next run request', async ({ page }) => {
const patchBodies: Array<Record<string, unknown>> = [];
const runRequestBodies: Array<Record<string, unknown>> = [];
await page.route('**/api/design-systems', async (route) => {
await route.fulfill({ json: { designSystems: DESIGN_SYSTEMS } });
});
await page.route('**/api/projects/*', async (route) => {
if (route.request().method() !== 'PATCH') {
await route.continue();
return;
}
const body = route.request().postDataJSON() as Record<string, unknown>;
patchBodies.push(body);
await route.continue();
});
await routeSuccessfulRuns(page, { bodies: runRequestBodies, runId: 'design-system-clear-run' });
await page.goto('/');
await createProject(page, 'Header design system clear run context');
await expectWorkspaceReady(page);
const trigger = projectDesignSystemTrigger(page);
await expect(trigger).toHaveAccessibleName(/^Design system$/i);
await trigger.click();
await page.getByTestId('project-ds-picker-search').fill('editorial');
const editorialOption = page.getByRole('option', { name: /^Editorial Noir$/ });
await expect(editorialOption).toBeVisible();
await editorialOption.click();
await expect(trigger).toHaveAccessibleName(/Editorial Noir/i);
await trigger.click();
await page.locator('.project-ds-picker-option').first().click();
await expect(trigger).toHaveAccessibleName(/^Design system$/i);
expect(patchBodies.some((body) => Object.prototype.hasOwnProperty.call(body, 'designSystemId') && body.designSystemId === null)).toBe(true);
const input = page.getByTestId('chat-composer-input');
await input.fill('Generate this without an active design system.');
await Promise.all([
page.waitForRequest((request) => request.url().includes('/api/runs') && request.method() === 'POST'),
page.getByTestId('chat-send').click(),
]);
expect(runRequestBodies.length).toBeGreaterThan(0);
expect(runRequestBodies[0]?.designSystemId).toBeNull();
});
test('[P1] a disabled project design system is omitted from the next run request', async ({ page }) => {
const runRequestBodies: Array<Record<string, unknown>> = [];
await page.route('**/api/design-systems', async (route) => {
await route.fulfill({ json: { designSystems: DESIGN_SYSTEMS } });
});
await page.route('**/api/app-config', async (route) => {
if (route.request().method() === 'GET') {
await route.fulfill({
json: {
config: {
onboardingCompleted: true,
agentId: 'codex',
designSystemId: null,
disabledDesignSystems: ['editorial-noir'],
agentModels: { codex: { model: 'default' } },
privacyDecisionAt: 1,
telemetry: { metrics: false, content: false, artifactManifest: false },
},
},
});
return;
}
await route.fallback();
});
await routeSuccessfulRuns(page, { bodies: runRequestBodies, runId: 'disabled-design-system-run' });
await createProject(page, 'Disabled project design system');
await expectWorkspaceReady(page);
const projectId = getProjectContextFromUrl(page).projectId;
const projectResponse = await page.request.get(`/api/projects/${projectId}`);
expect(projectResponse.ok(), await projectResponse.text()).toBeTruthy();
const projectPayload = (await projectResponse.json()) as { project: Record<string, unknown> };
const disabledProject = {
...projectPayload.project,
designSystemId: 'editorial-noir',
};
await page.route(`**/api/projects/${projectId}`, async (route) => {
if (route.request().method() === 'GET' || route.request().method() === 'PATCH') {
await route.fulfill({ json: { project: disabledProject } });
return;
}
await route.fallback();
});
await page.reload();
await expectWorkspaceReady(page);
const input = page.getByTestId('chat-composer-input');
await input.fill('Generate without the disabled design system.');
await Promise.all([
page.waitForRequest((request) => request.url().includes('/api/runs') && request.method() === 'POST'),
page.getByTestId('chat-send').click(),
]);
expect(runRequestBodies).toHaveLength(1);
expect(runRequestBodies[0]?.designSystemId).toBeNull();
});
test('[P1] project rename from the switcher persists after reload and ignores blank titles', async ({ page }) => {
await page.goto('/');
await createProject(page, 'Original rename title');
await expectWorkspaceReady(page);
// The chat card carries no inline title any more (OPEND-3128); the project
// is renamed through the switcher's row menu (K2 #8183).
const switcher = page.getByTestId('workspace-tabs-dropdown-trigger');
await renameProjectFromSwitcher(page, 'Original rename title', 'Renamed persistent title');
await expect(switcher).toContainText('Renamed persistent title');
await page.reload();
await expectWorkspaceReady(page);
await expect(switcher).toContainText('Renamed persistent title');
await renameProjectFromSwitcher(page, 'Renamed persistent title', ' ');
await page.reload();
await expectWorkspaceReady(page);
await expect(switcher).toContainText('Renamed persistent title');
const project = await fetchCurrentProject(page);
expect(project.name).toBe('Renamed persistent title');
});
test('[P1] project handoff CLI prompt copies the project path, framework, id, and target agent', async ({ page }) => {
await page.addInitScript(() => {
const store: string[] = [];
Object.defineProperty(window, '__copiedTexts', {
value: store,
configurable: true,
});
Object.defineProperty(navigator, 'clipboard', {
value: {
writeText(text: string) {
store.push(text);
return Promise.resolve();
},
},
configurable: true,
});
});
await routeHandoffEditors(page);
await page.goto('/');
await createProject(page, 'Handoff CLI prompt contract');
await expectWorkspaceReady(page);
await uploadTinyHtml(
page,
'handoff-cli.html',
'<!doctype html><html><body><h1>Handoff CLI</h1></body></html>',
);
const { projectId } = getProjectContextFromUrl(page);
const menu = await openHandoffCliTab(page);
const pathButton = menu.locator('.handoff-path-button');
await expect(pathButton).toBeEnabled();
const projectDir = await pathButton.getAttribute('title');
expect(projectDir).toBeTruthy();
await menu.getByRole('button', { name: /^Next\.js$/ }).click();
await menu.getByTestId('handoff-cli-item-codex').click();
await expect(menu.getByTestId('handoff-cli-item-codex')).toContainText('Copied');
const copied = await page.evaluate(() => {
return (window as typeof window & { __copiedTexts?: string[] }).__copiedTexts ?? [];
});
const prompt = copied.at(-1) ?? '';
expect(prompt).toContain(projectDir as string);
expect(prompt).toContain('cd ');
expect(prompt).toContain('Target: Next.js / React');
expect(prompt).toContain('CLI: Codex CLI (codex)');
expect(prompt).toContain(`Project ID: ${projectId}`);
});
test('[P1] canceling design file deletion keeps the file and open tab', async ({ page }) => {
await page.goto('/');
await createProject(page, 'Design file delete cancel flow');
await expectWorkspaceReady(page);
const uploadedName = await uploadTinyPng(page, 'delete-cancel.png');
const fileTab = tabBySuffix(page, uploadedName);
await expect(fileTab).toHaveAttribute('aria-selected', 'true');
page.once('dialog', async (dialog) => {
expect(dialog.message()).toContain('delete-cancel.png');
await dialog.dismiss();
});
await openAllProjectFiles(page);
await rowByFileName(page, uploadedName).hover();
await menuByFileName(page, uploadedName).click();
await page.getByTestId(`design-file-delete-${uploadedName}`).click();
await expect(rowByFileName(page, uploadedName)).toBeVisible();
await expect(fileTab).toBeVisible();
const { projectId } = getProjectContextFromUrl(page);
const files = await listProjectFiles(page, projectId);
expect(files.map((file) => file.name)).toContain(uploadedName);
});
test('[P1] project detail workspace keeps design file tabs and preview controls visible for uploaded html artifacts', async ({ page }) => {
await page.goto('/');
await createProject(page, 'Workspace preview structure');
await expectWorkspaceReady(page);
const uploadedName = await uploadTinyHtml(page, 'workspace-preview.html', '<!doctype html><html><body><main><h1>Workspace Preview Structure</h1><p>Preview stays visible.</p></main></body></html>');
const fileTab = tabBySuffix(page, uploadedName);
await expect(fileTab).toBeVisible();
await expect(fileTab).toHaveAttribute('aria-selected', 'true');
// #5517 replaced the tab strip's pages dropdown with a plain Design Files
// tab; that tab is the file-navigation entry this assertion guards now.
await expect(page.getByTestId('design-files-tab')).toBeVisible();
await openUploadedHtmlArtifactPreview(page, uploadedName);
await expect(page.getByRole('tablist', { name: 'View mode' })).toBeVisible();
await expect(page.getByRole('tab', { name: 'Preview', exact: true })).toHaveAttribute(
'aria-selected',
'true',
);
await expect(artifactPreview(page)).toBeVisible();
await expect(
artifactPreviewFrame(page).getByRole('heading', { name: 'Workspace Preview Structure' }),
).toBeVisible();
await expect(page.getByRole('button', { name: /Preview viewport/i })).toBeVisible();
await expect(page.locator('pre.viewer-source')).toHaveCount(0);
});
test('[P1] project detail turns carry the stored design session mode into daemon runs and message history', async ({ page }) => {
const runRequestBodies: Array<Record<string, unknown>> = [];
await routeSuccessfulRuns(page, { bodies: runRequestBodies, runIdPrefix: 'session-mode-run' });
await page.goto('/');
await createProject(page, 'Project session mode contract');
await expectWorkspaceReady(page);
// No picker in the composer row any more (#7635); the conversation's stored
// mode is what every run request and message chip carry.
await expect(page.getByTestId('composer-mode-trigger')).toHaveCount(0);
await page.getByTestId('chat-composer-input').fill('Draft the first pass of the screens.');
await Promise.all([
page.waitForRequest((request) => request.url().includes('/api/runs') && request.method() === 'POST'),
page.getByTestId('chat-send').click(),
]);
await expect.poll(() => runRequestBodies.length).toBe(1);
expect(runRequestBodies[0]?.sessionMode).toBe('design');
// Design is the default, so the message history carries no mode chip for it
// (only Ask / Plan turns are chipped).
await expect(page.getByTestId('msg-run-context-row').last()).toBeVisible();
await expect(page.getByTestId('msg-session-mode-chip')).toHaveCount(0);
});
test('[P1] BYOK OpenCode project run sends provider config through the daemon contract', async ({ page }) => {
const byokConfig = {
mode: 'api',
apiKey: 'sk-openai-e2e',
apiProtocol: 'openai',
apiVersion: '',
baseUrl: 'https://api.openai.com/v1',
model: 'gpt-4o-mini',
apiProviderBaseUrl: 'https://api.openai.com/v1',
agentId: null,
skillId: null,
designSystemId: null,
onboardingCompleted: true,
privacyDecisionAt: 1,
telemetry: { metrics: false, content: false, artifactManifest: false },
agentModels: {},
agentCliEnv: {},
};
await page.addInitScript(
({ key, value }) => {
window.localStorage.setItem(key, JSON.stringify(value));
},
{ key: STORAGE_KEY, value: byokConfig },
);
await page.route('**/api/app-config', async (route) => {
if (route.request().method() !== 'GET') {
await route.fallback();
return;
}
await route.fulfill({ json: { config: byokConfig } });
});
await routeAgents(page, [
...AGENTS,
{
id: 'byok-opencode',
name: 'BYOK OpenCode',
bin: 'opencode',
available: true,
version: '0.12.0',
models: [{ id: 'default', label: 'Default' }],
},
]);
await page.route('**/api/memory/extract', async (route) => {
await route.fulfill({ json: { ok: true, extracted: [] } });
});
const runRequestBodies: Array<Record<string, unknown>> = [];
await routeSuccessfulRuns(page, { bodies: runRequestBodies, runIdPrefix: 'byok-opencode-run' });
await page.goto('/');
await createProject(page, 'BYOK OpenCode daemon contract');
await expectWorkspaceReady(page);
const input = page.getByTestId('chat-composer-input');
await input.fill('Create a landing page with the configured BYOK provider.');
await Promise.all([
page.waitForRequest((request) => request.url().includes('/api/runs') && request.method() === 'POST'),
page.getByTestId('chat-send').click(),
]);
await expect.poll(() => runRequestBodies.length).toBe(1);
expect(runRequestBodies[0]).toMatchObject({
agentId: 'byok-opencode',
model: 'gpt-4o-mini',
byokProvider: {
protocol: 'openai',
apiKey: 'sk-openai-e2e',
baseUrl: 'https://api.openai.com/v1',
model: 'gpt-4o-mini',
apiVersion: '',
},
analyticsHints: {
runtimeType: 'byok',
},
});
});
test('[P1] BYOK OpenCode keyless vLLM run keeps auth fields out of the daemon contract', async ({ page }) => {
const byokConfig = {
mode: 'api',
apiKey: '',
apiProtocol: 'openai',
apiVersion: '',
baseUrl: 'http://127.0.0.1:8000/v1',
model: 'model',
apiProviderBaseUrl: 'http://127.0.0.1:8000/v1',
agentId: null,
skillId: null,
designSystemId: null,
onboardingCompleted: true,
privacyDecisionAt: 1,
telemetry: { metrics: false, content: false, artifactManifest: false },
agentModels: {},
agentCliEnv: {},
};
await page.addInitScript(
({ key, value }) => {
window.localStorage.setItem(key, JSON.stringify(value));
},
{ key: STORAGE_KEY, value: byokConfig },
);
await page.route('**/api/app-config', async (route) => {
if (route.request().method() !== 'GET') {
await route.fallback();
return;
}
await route.fulfill({ json: { config: byokConfig } });
});
await routeAgents(page, [
...AGENTS,
{
id: 'byok-opencode',
name: 'BYOK OpenCode',
bin: 'opencode',
available: true,
version: '0.12.0',
models: [{ id: 'default', label: 'Default' }],
},
]);
await page.route('**/api/memory/extract', async (route) => {
await route.fulfill({ json: { ok: true, extracted: [] } });
});
const runRequestBodies: Array<Record<string, unknown>> = [];
await routeSuccessfulRuns(page, { bodies: runRequestBodies, runIdPrefix: 'byok-opencode-keyless-run' });
await page.goto('/');
await createProject(page, 'BYOK OpenCode keyless vLLM contract');
await expectWorkspaceReady(page);
await page.getByTestId('chat-composer-input').fill('Use the local vLLM BYOK endpoint.');
await Promise.all([
page.waitForRequest((request) => request.url().includes('/api/runs') && request.method() === 'POST'),
page.getByTestId('chat-send').click(),
]);
await expect.poll(() => runRequestBodies.length).toBe(1);
expect(runRequestBodies[0]).toMatchObject({
agentId: 'byok-opencode',
model: 'model',
byokProvider: {
protocol: 'openai',
apiKey: '',
baseUrl: 'http://127.0.0.1:8000/v1',
model: 'model',
requiresApiKey: false,
},
analyticsHints: {
runtimeType: 'byok',
},
});
});
test('[P1] BYOK OpenCode unavailable blocks the project run before daemon routing', async ({ page }) => {
const byokConfig = {
mode: 'api',
apiKey: 'sk-openai-e2e',
apiProtocol: 'openai',
apiVersion: '',
baseUrl: 'https://api.openai.com/v1',
model: 'gpt-4o-mini',
apiProviderBaseUrl: 'https://api.openai.com/v1',
agentId: null,
skillId: null,
designSystemId: null,
onboardingCompleted: true,
privacyDecisionAt: 1,
telemetry: { metrics: false, content: false, artifactManifest: false },
agentModels: {},
agentCliEnv: {},
};
await page.addInitScript(
({ key, value }) => {
window.localStorage.setItem(key, JSON.stringify(value));
},
{ key: STORAGE_KEY, value: byokConfig },
);
await page.route('**/api/app-config', async (route) => {
if (route.request().method() !== 'GET') {
await route.fallback();
return;
}
await route.fulfill({ json: { config: byokConfig } });
});
await routeAgents(page, [
...AGENTS,
{
id: 'byok-opencode',
name: 'BYOK OpenCode',
bin: 'opencode',
available: false,
version: null,
models: [],
},
]);
const runRequests = await routeSuccessfulRuns(page, {
runIdPrefix: 'byok-opencode-unavailable-should-not-start',
events: false,
});
await page.goto('/');
await createProject(page, 'BYOK OpenCode unavailable contract');
await expectWorkspaceReady(page);
const input = page.getByTestId('chat-composer-input');
await input.fill('Create a landing page with unavailable OpenCode.');
await page.getByTestId('chat-send').click();
await expect(page.locator('.run-error__description')).toContainText(
/BYOK API runs require OpenCode/i,
);
await runRequests.expectNone({
message: 'unavailable BYOK OpenCode should fail preflight before POST /api/runs',
});
});
test('[P1] project detail active file context is sent with the run but hidden on the user message', async ({ page }) => {
const runRequestBodies: Array<Record<string, unknown>> = [];
await routeSuccessfulRuns(page, { bodies: runRequestBodies, runIdPrefix: 'workspace-context-run' });
await page.goto('/');
await createProject(page, 'Workspace context chip contract');
await expectWorkspaceReady(page);
const uploadedName = await uploadTinyHtml(
page,
'workspace-context.html',
'<!doctype html><html><body><main><h1>Workspace Context</h1></main></body></html>',
);
await expect(tabBySuffix(page, uploadedName)).toHaveAttribute('aria-selected', 'true');
await page.getByTestId('chat-composer-input').fill('Use the currently open file as context.');
await Promise.all([
page.waitForRequest((request) => request.url().includes('/api/runs') && request.method() === 'POST'),
page.getByTestId('chat-send').click(),
]);
await expect.poll(() => runRequestBodies.length).toBe(1);
const context = runRequestBodies[0]?.context as { workspaceItems?: Array<{ label?: string; id?: string }> } | undefined;
expect(context?.workspaceItems?.some((item) => item.label === uploadedName || item.id?.includes(uploadedName))).toBe(true);
await expect(page.getByTestId('msg-run-context-row')).toHaveCount(0);
await expect(page.getByTestId('msg-workspace-context-chip')).toHaveCount(0);
});
test('[P1] project detail active file context survives reload in message history', async ({ page }) => {
const runRequestBodies: Array<Record<string, unknown>> = [];
await routeSuccessfulRuns(page, { bodies: runRequestBodies, runIdPrefix: 'workspace-context-reload-run' });
await page.goto('/', { waitUntil: 'domcontentloaded' });
await createProject(page, 'Workspace context reload contract');
await expectWorkspaceReady(page);
const uploadedName = await uploadTinyHtml(
page,
'workspace-context-reload.html',
'<!doctype html><html><body><main><h1>Workspace Context Reload</h1></main></body></html>',
);
await expect(tabBySuffix(page, uploadedName)).toHaveAttribute('aria-selected', 'true');
await page.getByTestId('chat-composer-input').fill('Persist this file context through reload.');
await Promise.all([
page.waitForRequest((request) => request.url().includes('/api/runs') && request.method() === 'POST'),
page.getByTestId('chat-send').click(),
]);
await expect.poll(() => runRequestBodies.length).toBe(1);
const context = runRequestBodies[0]?.context as { workspaceItems?: Array<{ label?: string; id?: string }> } | undefined;
expect(runRequestBodies[0]?.sessionMode).toBe('design');
expect(context?.workspaceItems?.some((item) => item.label === uploadedName || item.id?.includes(uploadedName))).toBe(true);
// Design turns carry no mode chip (only Ask / Plan are chipped).
await expect(page.getByTestId('msg-session-mode-chip')).toHaveCount(0);
await expect(page.getByTestId('msg-workspace-context-chip').last()).toContainText(uploadedName);
await page.reload();
await expectWorkspaceReady(page);
await expect(page.getByTestId('msg-session-mode-chip')).toHaveCount(0);
await expect(page.getByTestId('msg-workspace-context-chip').last()).toContainText(uploadedName);
});
test('[P1] active project API defaults to the selected project file from the real workspace', async ({ page }) => {
await page.goto('/', { waitUntil: 'domcontentloaded' });
await createProject(page, 'MCP active context contract');
await expectWorkspaceReady(page);
const uploadedName = await uploadTinyHtml(
page,
'mcp-active-context.html',
'<!doctype html><html><body><main><h1>MCP Active Context</h1></main></body></html>',
);
const { projectId } = getProjectContextFromUrl(page);
await expect(tabBySuffix(page, uploadedName)).toHaveAttribute('aria-selected', 'true');
await expect
.poll(async () => {
const response = await page.request.get('/api/active');
const body = (await response.json()) as { projectId?: string; fileName?: string };
return `${body.projectId ?? ''}:${body.fileName ?? ''}`;
})
.toBe(`${projectId}:${uploadedName}`);
});
test('[P1] project detail HTML version manager previews and restores an older snapshot', async ({ page }) => {
const restoredRequests: string[] = [];
await page.goto('/');
await createProject(page, 'HTML version manager contract');
await expectWorkspaceReady(page);
const uploadedName = await uploadTinyHtml(
page,
'version-manager.html',
'<!doctype html><html><body><main><h1>Current Version</h1></main></body></html>',
);
const { projectId } = getProjectContextFromUrl(page);
const now = Date.now();
const currentVersion = {
id: 'v-current',
fileName: uploadedName,
version: 2,
label: 'Current generated HTML',
createdAt: now,
source: 'ai',
prompt: 'Current generated HTML',
promptSource: 'message',
size: 82,
mime: 'text/html',
kind: 'html',
current: true,
};
const oldVersion = {
id: 'v-old',
fileName: uploadedName,
version: 1,
label: 'Initial generated HTML',
createdAt: now - 60_000,
source: 'manual',
prompt: 'Initial generated HTML',
promptSource: 'manual',
size: 78,
mime: 'text/html',
kind: 'html',
current: false,
};
const restoredVersion = {
...oldVersion,
id: 'v-restored',
version: 3,
source: 'restore',
current: true,
restoreFromVersionId: oldVersion.id,
};
const oldVersionContent = '<!doctype html><html><body><main><h1>Initial Version</h1></main></body></html>';
const restoredContent = '<!doctype html><html><body><main><h1>Restored Version</h1></main></body></html>';
await page.route(`**/api/projects/${projectId}/files/${uploadedName}/versions`, async (route) => {
if (route.request().method() === 'GET') {
await route.fulfill({
json: {
file: { name: uploadedName, kind: 'html', mime: 'text/html', size: oldVersionContent.length, mtime: now },
versions: [currentVersion, oldVersion],
},
});
return;
}
await route.continue();
});
await page.route(`**/api/projects/${projectId}/files/${uploadedName}/versions/v-old`, async (route) => {
if (route.request().method() === 'GET') {
await route.fulfill({ json: { version: oldVersion, content: oldVersionContent } });
return;
}
await route.continue();
});
await page.route(`**/api/projects/${projectId}/files/${uploadedName}/versions/v-old/restore`, async (route) => {
if (route.request().method() === 'POST') {
restoredRequests.push(route.request().url());
await route.fulfill({
json: {
file: { name: uploadedName, kind: 'html', mime: 'text/html', size: restoredContent.length, mtime: Date.now() },
version: restoredVersion,
},
});
return;
}
await route.continue();
});
await openUploadedHtmlArtifactPreview(page, uploadedName);
await page.getByRole('button', { name: 'Versions' }).click();
const dialog = page.getByRole('dialog', { name: 'Versions' });
await expect(dialog).toBeVisible();
await expect(dialog.getByRole('option', { name: /Current generated HTML/i })).toHaveAttribute('aria-selected', 'true');
await dialog.getByRole('option', { name: /Initial generated HTML/i }).click();
await expect(dialog.locator('iframe').first().contentFrame().getByRole('heading', { name: 'Initial Version' })).toBeVisible();
await dialog.getByRole('button', { name: 'Switch to this version' }).click();
await dialog.getByRole('dialog', { name: 'Switch to this version?' }).getByRole('button', { name: 'Switch' }).click();
await expect.poll(() => restoredRequests.length).toBe(1);
await expect(dialog).toHaveCount(0);
});
test('[P1] project detail assistant completion actions support copy, fork, and feedback', async ({ page }) => {
await page.addInitScript(() => {
const store: string[] = [];
Object.defineProperty(window, '__copiedTexts', {
value: store,
configurable: true,
});
Object.defineProperty(navigator, 'clipboard', {
value: {
writeText(text: string) {
store.push(text);
return Promise.resolve();
},
},
configurable: true,
});
});
const { projectId, conversationId, assistantMessageId, assistantText } =
await seedProjectWithAssistantCompletion(page);
await page.goto(`/projects/${projectId}/conversations/${conversationId}`);
await expectWorkspaceReady(page);
await expect(page.getByText('Assistant completion actions fixture')).toBeVisible();
const copyButton = page.getByTestId('assistant-copy-markdown');
await expect(copyButton).toBeVisible();
await copyButton.click();
await expect(copyButton).toHaveAttribute('data-copied', 'true');
const copied = await page.evaluate(() => {
return (window as typeof window & { __copiedTexts?: string[] }).__copiedTexts ?? [];
});
expect(copied.at(-1)).toBe(assistantText);
const positive = page.getByTestId('assistant-feedback-positive');
const negative = page.getByTestId('assistant-feedback-negative');
await expect(positive).toBeVisible();
await expect(negative).toBeVisible();
await positive.click();
await expect(positive).toHaveAttribute('aria-pressed', 'true');
await expect(page.locator('.assistant-feedback-reasons')).toBeVisible();
await negative.click();
await expect(negative).toHaveAttribute('aria-pressed', 'true');
await expect(positive).toHaveAttribute('aria-pressed', 'false');
const forkRequestPromise = page.waitForRequest((request) => {
return request.method() === 'POST'
&& request.url().endsWith(`/api/projects/${projectId}/conversations`);
});
await page.getByTestId('assistant-fork-button').click();
const forkRequest = await forkRequestPromise;
const forkBody = forkRequest.postDataJSON() as {
forkAfterMessageId?: string;
seedFromConversationId?: string;
seedMessages?: unknown;
};
expect(forkBody.seedFromConversationId).toBe(conversationId);
expect(forkBody.forkAfterMessageId).toBe(assistantMessageId);
expect(forkBody.seedMessages).toBeUndefined();
await expect
.poll(() => getProjectContextFromUrl(page).conversationId)
.not.toBe(conversationId);
});
for (const origin of ['artifact-card', 'toolbar'] as const) {
test(`[P1] share failure recovery matches available actions from ${origin}`, async ({ page }) => {
await page.clock.install();
await page.context().grantPermissions(['clipboard-read', 'clipboard-write']);
await mockWritablePersonalProjectScope(page);
const { projectId, conversationId } = await seedProjectWithRepeatedArtifactCards(page);
const publicationPath = `/api/projects/${projectId}/files/index.html/publish-public`;
const url = `https://example.test/artifact/${projectId}/recovery-alias`;
let attempts = 0;
let releaseRetry = () => {};
const retryGate = new Promise<void>(resolve => { releaseRetry = resolve; });
await page.route(`**${publicationPath}`, async route => {
if (route.request().method() === 'GET') {
await route.fulfill({ json: { publication: null } });
return;
}
attempts += 1;
if (attempts === 1) {
await route.fulfill({ status: 500, json: { error: 'fixture publish failed' } });
return;
}
await retryGate;
await route.fulfill({ json: { url, slug: 'recovery-alias', fileName: 'index.html' } });
});
await page.goto(`/projects/${projectId}/conversations/${conversationId}`);
await expectWorkspaceReady(page);
if (origin === 'artifact-card') await page.getByTestId('artifact-card-publish-index.html').last().click();
else await page.locator('.chrome-share-menu--unified > button[aria-label="Share"]').click();
const menu = page.locator('.share-menu-popover[role="menu"]');
const hint = menu.getByText('Closing this panel will not interrupt the upload.', { exact: true });
await expect(hint).toHaveCount(0);
const deploy = menu.getByRole('menuitem', { name: 'Deploy to Vercel', exact: true });
if (origin === 'toolbar') await expect(deploy).toBeVisible();
else await expect(deploy).toHaveCount(0);
await test.info().attach(`failure-recovery-${origin}-entry`, { body: await page.screenshot(), contentType: 'image/png' });
await menu.getByRole('menuitem', { name: 'Generate and copy link', exact: true }).click();
await expect(menu.getByRole('status')).toHaveText('Could not create the share link. Please try again later.');
expect(attempts).toBe(1);
const retry = menu.getByRole('menuitem', { name: 'Retry', exact: true });
await expect(retry).toBeEnabled();
await test.info().attach(`failure-recovery-${origin}-failed`, { body: await page.screenshot(), contentType: 'image/png' });
try {
await retry.click();
const progress = menu.getByRole('progressbar');
await expect(progress).toBeVisible();
// Advance the existing waiting simulation, not upload bytes or the held response.
await page.clock.fastForward(3_500);
await expect.poll(async () => Number(await progress.getAttribute('value'))).toBeGreaterThan(0.4);
await expect(progress).toHaveCSS('height', '32px');
await expect(progress).toHaveCSS('border-radius', '6px');
await expect(progress).toHaveCSS('background-color', 'rgb(110, 110, 112)');
const busy = menu.getByRole('menuitem', { name: /Uploading.*\d+%/ });
await expect(busy).toBeDisabled();
const spinner = busy.locator('.icon-spin');
await expect(spinner).toHaveCSS('width', '13px');
await expect(spinner).toHaveCSS('height', '13px');
for (const [name, value] of Object.entries({ viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', 'stroke-width': '2', 'stroke-linecap': 'round', 'aria-hidden': 'true', focusable: 'false' })) {
await expect(spinner).toHaveAttribute(name, value);
}
await expect(spinner.locator('path')).toHaveAttribute('d', 'M12 3a9 9 0 1 0 9 9');
expect(await spinner.evaluate(node => getComputedStyle(node).animationName)).not.toBe('none');
await expect(busy).toHaveCSS('color', 'rgb(255, 255, 255)');
await expect(busy).toHaveCSS('background-color', 'rgba(0, 0, 0, 0)');
expect(await progress.boundingBox()).toEqual(await busy.boundingBox());
const rendered = await progress.evaluate(node => ({
value: Number(node.getAttribute('value')),
text: node.parentElement?.querySelector('button')?.textContent,
}));
expect(rendered.value).toBeGreaterThan(0.4);
expect(rendered.value).toBeLessThanOrEqual(0.9);
expect(rendered.text).toContain(`${Math.round(rendered.value * 100)}%`);
await expect(menu.getByRole('status')).toHaveCount(0);
await expect.poll(() => attempts).toBe(2);
await expect(hint).toBeVisible();
await expect(hint).toHaveCSS('font-size', '10.5px');
await expect(hint).toHaveCSS('line-height', '17px');
await expect(hint).toHaveCSS('color', 'rgb(153, 153, 153)');
await expect(hint).toHaveCSS('margin', '0px');
await test.info().attach(`failure-recovery-${origin}-retry`, { body: await page.screenshot(), contentType: 'image/png' });
const close = menu.getByRole('button', { name: 'Close', exact: true });
const closeIcon = close.locator('svg');
for (const [name, value] of Object.entries({ width: '14', height: '14', viewBox: '0 0 16 16', fill: 'none', stroke: 'currentColor', 'stroke-width': '1.5', 'stroke-linecap': 'round', 'aria-hidden': 'true', focusable: 'false' })) {
await expect(closeIcon).toHaveAttribute(name, value);
}
await expect(closeIcon).toHaveCSS('width', '14px');
await expect(closeIcon).toHaveCSS('height', '14px');
await expect(closeIcon.locator('path')).toHaveAttribute('d', 'M4 4l8 8M12 4l-8 8');
await close.click();
await expect(menu).toBeHidden();
await page.clock.fastForward(500);
if (origin === 'artifact-card') await page.getByTestId('artifact-card-publish-index.html').last().click();
else await page.locator('.chrome-share-menu--unified > button[aria-label="Share"]').click();
await expect(hint).toBeVisible();
await expect(progress).toBeVisible();
expect(Number(await progress.getAttribute('value'))).toBeGreaterThanOrEqual(rendered.value);
expect(attempts).toBe(2); // Dismissing does not abort or restart the pending upload.
await test.info().attach(`failure-recovery-${origin}-reopened`, { body: await page.screenshot(), contentType: 'image/png' });
} finally {
releaseRetry();
}
await expect(menu.getByRole('button', { name: 'Stop sharing', exact: true })).toBeVisible();
await expect(menu).not.toContainText('Could not create the share link.');
await expect(hint).toHaveCount(0);
await expect(menu.getByRole('status')).toHaveCount(0);
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe(url);
await expect(retry).toHaveCount(0);
await expect(menu.getByRole('progressbar')).toHaveCount(0);
expect(attempts).toBe(2);
if (origin === 'toolbar') await expect(deploy).toBeVisible();
else await expect(deploy).toHaveCount(0);
await test.info().attach(`failure-recovery-${origin}-success`, { body: await page.screenshot(), contentType: 'image/png' });
});
}
for (const published of [false, true]) {
test(`[P1] team scope trigger canvas published=${published}`, async ({ page }) => {
await mockWritablePersonalProjectScope(page);
const { projectId, conversationId } = await seedProjectWithRepeatedArtifactCards(page);
const context = { ...AMR_PERSONAL_WORKSPACE_CONTEXT, workspaceType: 'team', teamId: 'scope-visual-team' };
await page.route(`**/api/projects/${projectId}/workspace-scope`, route => route.fulfill({
json: { scope: { kind: 'team', projectId, workspaceId: context.workspaceId, visibility: 'team', context } },
}));
await page.route(`**/api/projects/${projectId}/collab/status`, route => route.fulfill({
json: { publishedVersion: null, materializedVersion: null, syncState: 'local_only', ownerMemberId: context.workspaceMemberId },
}));
await page.route(`**/api/projects/${projectId}/files/index.html/publish-public`, route => route.fulfill({
json: { publication: published ? { url: `https://example.test/artifact/${projectId}/stable-alias`, slug: 'stable-alias', fileName: 'index.html' } : null },
}));
await page.goto(`/projects/${projectId}/conversations/${conversationId}`);
await expectWorkspaceReady(page);
await page.locator('.chrome-share-menu--unified > button[aria-label="Share"]').click();
const menu = page.locator('.share-menu-popover[role="menu"]');
if (published) await expect(menu.getByRole('button', { name: 'Stop sharing', exact: true })).toBeVisible();
else await expect(menu.getByRole('menuitem', { name: 'Generate and copy link', exact: true })).toBeVisible();
const heading = menu.locator('.share-menu-section-label--help');
await expect(heading).toHaveText('Visibility in workspace');
const publicBlock = published ? menu.locator('.chrome-publish-plain') : menu.getByRole('menuitem', { name: 'Generate and copy link', exact: true });
const publicBounds = (await publicBlock.boundingBox())!;
expect((await heading.locator('..').boundingBox())!.y - publicBounds.y - publicBounds.height).toBe(32);
for (const [property, value] of Object.entries({ padding: '0px', color: 'rgb(51, 51, 51)', 'font-size': '13px', 'font-weight': '500', 'line-height': '20px' })) {
await expect(heading).toHaveCSS(property, value);
}
await expect(menu.getByTestId('workspace-access-help')).toHaveCount(0);
const description = menu.getByText('Members of this workspace can access this project.', { exact: true });
await expect(description).toBeVisible();
for (const [property, value] of Object.entries({ margin: '0px', color: 'rgb(136, 136, 136)', 'font-size': '12px', 'line-height': '18px', 'font-weight': '400' })) {
await expect(description).toHaveCSS(property, value);
}
await expect(description.locator('..')).toHaveCSS('gap', '4px');
// S12 moved deployment entries into the header overflow, not a body heading.
await expect(menu.getByRole('button', { name: 'More sharing options' })).toBeVisible();
await expect(menu.locator('.share-menu-section-label:not(.share-menu-section-label--help)')).toHaveCount(0);
const trigger = menu.locator('.chrome-access-trigger');
const headingBounds = (await heading.boundingBox())!;
const triggerBounds = (await trigger.boundingBox())!;
expect(headingBounds.y + headingBounds.height / 2).toBe(triggerBounds.y + triggerBounds.height / 2);
expect(triggerBounds.x).toBeGreaterThan(headingBounds.x + headingBounds.width);
expect((await description.boundingBox())!.y - triggerBounds.y - triggerBounds.height).toBe(4);
await expect(trigger).toBeEnabled();
await expect(trigger.locator(':scope > .share-menu-icon')).toBeHidden();
await expect(trigger.locator(':scope > svg')).toHaveCSS('width', '12px');
await expect(trigger.locator(':scope > svg')).toHaveCSS('height', '12px');
const chevron = trigger.locator(':scope > svg');
for (const [name, value] of Object.entries({ viewBox: '0 0 16 16', fill: 'none', stroke: 'currentColor', 'stroke-width': '1.5', 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'aria-hidden': 'true', focusable: 'false' })) {
await expect(chevron).toHaveAttribute(name, value);
}
await expect(chevron.locator('path')).toHaveAttribute('d', 'm4 6 4 4 4-4');
for (const [property, value] of Object.entries({
height: '28px', 'min-height': '28px', 'min-width': '88px', padding: '0px 8px', gap: '8px',
'border-top-width': '0px', 'border-radius': '5px', 'background-color': 'rgb(246, 246, 246)',
color: 'rgb(85, 85, 85)', 'font-size': '12px', display: 'flex',
})) await expect(trigger).toHaveCSS(property, value);
await trigger.click();
await expect(trigger).toHaveAttribute('aria-expanded', 'true');
const scopeMenu = menu.getByRole('listbox');
for (const [property, value] of Object.entries({
padding: '4px', gap: '2px', display: 'flex', 'flex-direction': 'column',
'border-top-width': '1px', 'border-top-color': 'rgba(0, 0, 0, 0.03)',
'border-radius': '8px', 'background-color': 'rgb(255, 255, 255)',
'box-shadow': 'rgba(0, 0, 0, 0.07) 0px 6px 20px 0px, rgba(0, 0, 0, 0.024) 0px 1px 4px 0px',
})) await expect(scopeMenu).toHaveCSS(property, value);
await expect(scopeMenu).toHaveCSS('min-width', '168px');
await expect(scopeMenu).toHaveCSS('width', '168px');
const scopeBounds = (await scopeMenu.boundingBox())!;
const anchorBounds = (await trigger.boundingBox())!;
expect(scopeBounds.x + scopeBounds.width).toBe(anchorBounds.x + anchorBounds.width);
const options = menu.getByRole('option');
for (const option of await options.all()) {
await expect(option.locator(':scope > .share-menu-icon')).toBeHidden();
const label = option.locator(':scope > span:nth-child(2)');
await expect(label).toHaveCSS('grid-column-start', '2');
expect(await label.evaluate(node => node.scrollWidth <= node.clientWidth)).toBe(true);
}
const check = menu.getByRole('option', { selected: true }).locator(':scope > svg');
await expect(check).toHaveCSS('grid-column-start', '1');
await expect(check).toHaveCSS('width', '13px');
await expect(check).toHaveCSS('height', '13px');
for (const [name, value] of Object.entries({ viewBox: '0 0 16 16', fill: 'none', stroke: 'currentColor', 'stroke-width': '1.8', 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'aria-hidden': 'true', focusable: 'false' })) {
await expect(check).toHaveAttribute(name, value);
}
await expect(check.locator('path')).toHaveAttribute('d', 'm3 8 3 3 7-7');
await expect(menu.getByRole('option', { selected: false }).locator(':scope > svg')).toHaveCount(0);
await expect(options).toHaveCount(2);
for (const option of await options.all()) {
for (const [property, value] of Object.entries({
height: '28px', 'min-height': '28px', padding: '0px 8px', gap: '8px',
'border-top-width': '0px', 'border-radius': '4px', 'font-size': '12px', 'font-weight': '400',
})) await expect(option).toHaveCSS(property, value);
}
const selected = menu.getByRole('option', { name: 'Workspace members', exact: true });
await expect(selected).toHaveAttribute('aria-selected', 'true');
await expect(selected).toHaveCSS('background-color', 'rgb(242, 242, 244)');
await expect(selected).toHaveCSS('color', 'rgb(31, 31, 31)');
const privateOption = menu.getByRole('option', { name: 'Only me', exact: true });
await expect(privateOption).toHaveCSS('color', 'rgb(73, 73, 73)');
await privateOption.hover();
await expect(privateOption).toHaveCSS('background-color', 'rgb(242, 242, 244)');
await expect(privateOption).toHaveCSS('color', 'rgb(31, 31, 31)');
await expect(privateOption).toHaveAttribute('aria-selected', 'false');
await trigger.hover();
await expect(privateOption).toHaveCSS('color', 'rgb(73, 73, 73)');
await expect(selected).toHaveAttribute('aria-selected', 'true');
await expect(selected).toHaveCSS('background-color', 'rgb(242, 242, 244)');
await test.info().attach(`team-scope-${published ? 'published' : 'first'}`, { body: await page.screenshot(), contentType: 'image/png' });
await trigger.click();
await expect(menu.getByRole('listbox')).toBeHidden();
// Keyboard navigation must not change visibility or dismiss the Share panel.
await trigger.press('ArrowDown');
await expect(selected).toBeFocused();
await selected.press('Home');
await expect(privateOption).toBeFocused();
await privateOption.press('End');
await expect(selected).toBeFocused();
await selected.press('ArrowDown');
await expect(privateOption).toBeFocused();
await expect(selected).toHaveAttribute('aria-selected', 'true');
await privateOption.press('Escape');
await expect(menu).toBeVisible();
await expect(menu.getByRole('listbox')).toBeHidden();
await expect(trigger).toBeFocused();
await expect(page.getByRole('alertdialog')).toHaveCount(0);
// A real confirmation/request drives busy state; do not inject DOM classes.
const movePath = `/api/workspaces/${context.workspaceId}/projects/${projectId}/move`;
let releaseMove = () => {};
const moveGate = new Promise<void>(resolve => { releaseMove = resolve; });
await page.route(`**${movePath}`, async route => {
await moveGate;
await route.fulfill({ status: 503, json: { error: { message: 'visual busy-state fixture' } } });
});
try {
await trigger.press('ArrowUp');
await expect(selected).toBeFocused();
await selected.press('Home');
await expect(privateOption).toBeFocused();
await privateOption.press('Enter');
const confirmation = page.getByRole('alertdialog', { name: 'Move out of team space' });
const [request] = await Promise.all([
page.waitForRequest(request => new URL(request.url()).pathname === movePath && request.method() === 'POST'),
confirmation.getByRole('button', { name: 'Confirm move', exact: true }).click(),
]);
expect(request.postDataJSON()).toEqual({ visibility: 'personal' });
// Confirmation is outside the anchored popover and dismisses it.
await page.locator('.chrome-share-menu--unified > button[aria-label="Share"]').click();
await expect(trigger).toBeDisabled();
await expect(trigger.locator('.share-menu-icon .icon-spin')).toBeVisible();
await test.info().attach(`team-scope-busy-${published ? 'published' : 'first'}`, { body: await page.screenshot(), contentType: 'image/png' });
const viewport = page.viewportSize()!;
// Give the preview the viewport through the real layout control.
await page.getByRole('button', { name: 'Collapse the conversation pane', exact: true }).click();
try {
await page.setViewportSize({ width: 320, height: 900 });
// Collapsing chat changes the anchor layout; reopen the workspace entry.
await expect(menu).toBeHidden();
await page.locator('.chrome-share-menu--unified > button[aria-label="Share"]').click();
await expect(menu).toBeVisible();
const menuBox = (await menu.boundingBox())!;
const triggerBox = (await trigger.boundingBox())!;
const headingBox = (await heading.boundingBox())!;
await test.info().attach(`team-scope-narrow-bounds-${published ? 'published' : 'first'}`, {
body: JSON.stringify({ viewport: page.viewportSize(), menuBox, triggerBox, headingBox }), contentType: 'application/json',
});
expect(menuBox.x).toBeGreaterThanOrEqual(0);
expect(menuBox.x + menuBox.width).toBeLessThanOrEqual(320);
expect(headingBox.x + headingBox.width + 12).toBeLessThanOrEqual(triggerBox.x);
expect(triggerBox.x + triggerBox.width).toBeLessThanOrEqual(menuBox.x + menuBox.width - 20);
expect(await heading.evaluate(node => node.scrollWidth <= node.clientWidth)).toBe(true);
await expect(trigger.locator('.share-menu-icon .icon-spin')).toBeVisible();
await test.info().attach(`team-scope-narrow-busy-${published ? 'published' : 'first'}`, { body: await page.screenshot(), contentType: 'image/png' });
} finally {
if (!page.isClosed()) await page.setViewportSize(viewport);
}
await expect(menu).toBeVisible();
} finally {
releaseMove();
}
await expect(trigger).toBeEnabled();
await expect(trigger.locator(':scope > .share-menu-icon')).toBeHidden();
await expect(trigger).toHaveText('Workspace members');
await expect(description).toBeVisible(); // Failed move must not announce a different visibility.
// Reuse this workflow's settled team project for Chinese copy/cascade checks.
for (const [locale, label] of [['zh-CN', '团队成员'], ['zh-TW', '團隊成員']] as const) {
await page.evaluate(locale => {
localStorage.setItem('open-design:locale', locale);
localStorage.setItem('open-design:locale-source', 'manual');
}, locale);
await page.reload();
await expectWorkspaceReady(page);
await page.locator('.chrome-share-menu--unified > button[aria-label="分享"]').click();
await expect(trigger).toHaveText(label);
if (published) {
const copyButton = menu.getByRole('button', { name: locale === 'zh-CN' ? '复制链接' : '複製連結', exact: true });
await expect(copyButton).toBeEnabled();
await expect(copyButton).toHaveCSS('height', '32px');
}
await expect(trigger).toBeEnabled();
await trigger.click();
const teamOption = menu.getByRole('option', { name: label, exact: true });
await expect(teamOption).toHaveAttribute('aria-selected', 'true');
await expect(teamOption).toHaveCSS('height', '28px');
await expect(menu.getByRole('listbox')).toHaveCSS('width', '168px');
const teamLabel = teamOption.locator(':scope > span:nth-child(2)');
expect(await teamLabel.evaluate(node => node.scrollWidth <= node.clientWidth)).toBe(true);
await expect(menu).not.toContainText(locale === 'zh-CN' ? '工作空间成员' : '工作空間成員');
await test.info().attach(`team-scope-${locale}-${published ? 'published' : 'first'}`, { body: await page.screenshot(), contentType: 'image/png' });
}
// Private workspace visibility is independent of the public file link.
await page.route(`**/api/projects/${projectId}/collab/status`, route => route.fulfill({
json: { publishedVersion: null, materializedVersion: null, syncState: 'local_only' },
}));
await page.route('**/api/workspace/projects/team', route => route.fulfill({ json: { projects: [] } }));
for (const [locale, label, descriptionText] of [
['zh-CN', '仅自己', '只有你可以在工作区内访问此项目。'],
['zh-TW', '只有自己', '只有你可以在工作區內存取此專案。'],
] as const) {
await page.evaluate(locale => {
localStorage.setItem('open-design:locale', locale);
localStorage.setItem('open-design:locale-source', 'manual');
}, locale);
await page.reload();
await expectWorkspaceReady(page);
await page.locator('.chrome-share-menu--unified > button[aria-label="分享"]').click();
await expect(trigger).toHaveText(label);
if (published) {
const copyButton = menu.getByRole('button', { name: locale === 'zh-CN' ? '复制链接' : '複製連結', exact: true });
await expect(copyButton).toBeEnabled();
await expect(copyButton).toHaveCSS('height', '32px');
}
const privateDescription = menu.getByText(descriptionText, { exact: true });
await expect(privateDescription).toBeVisible();
await expect(privateDescription).toHaveCSS('font-size', '12px');
await expect(privateDescription).toHaveCSS('line-height', '18px');
if (published) await expect(menu.getByText(`https://example.test/artifact/${projectId}/stable-alias`, { exact: true })).toBeVisible();
else await expect(menu.getByRole('menuitem', { name: locale === 'zh-CN' ? '生成并复制链接' : '產生並複製連結', exact: true })).toBeVisible();
await trigger.click();
await expect(menu.getByRole('option', { name: label, exact: true })).toHaveAttribute('aria-selected', 'true');
await test.info().attach(`team-scope-private-${locale}-${published ? 'published' : 'first'}`, { body: await page.screenshot(), contentType: 'image/png' });
}
await page.evaluate(() => {
localStorage.setItem('open-design:locale', 'de');
localStorage.setItem('open-design:locale-source', 'manual');
});
await page.reload();
await expectWorkspaceReady(page);
await page.locator('.chrome-share-menu--unified > button[aria-label="Teilen"]').click();
await expect(trigger).toHaveText('Nur ich');
await trigger.click();
await expect(menu.getByRole('option', { name: 'Nur ich', exact: true })).toHaveAttribute('aria-selected', 'true');
const germanMembers = menu.getByRole('option', { name: 'Teammitglieder', exact: true });
await expect(germanMembers).toHaveAttribute('aria-selected', 'false');
await expect(germanMembers).toHaveCSS('height', '28px');
const germanLabel = germanMembers.locator(':scope > span:nth-child(2)');
expect(await germanLabel.evaluate(node => node.scrollWidth <= node.clientWidth)).toBe(true);
await test.info().attach(`team-scope-private-de-${published ? 'published' : 'first'}`, { body: await page.screenshot(), contentType: 'image/png' });
});
}
test('[P1] S12 more sharing opens the existing deployment dialog from the share header', async ({ page }) => {
await mockWritablePersonalProjectScope(page);
const { projectId, conversationId } = await seedProjectWithRepeatedArtifactCards(page);
await page.goto(`/projects/${projectId}/conversations/${conversationId}`);
await expectWorkspaceReady(page);
const secondShare = page.getByTestId('artifact-card-publish-index.html').nth(1);
await secondShare.click();
const menu = page.locator('.share-menu-popover[role="menu"]');
const heading = menu.getByRole('heading', { name: 'Share', level: 2 });
await expect(heading).toBeVisible();
const moreSharing = menu.getByRole('button', { name: 'More sharing options', exact: true });
await expect(moreSharing).toBeVisible();
await expect(moreSharing).toHaveCSS('width', '20px');
await expect(moreSharing).toHaveCSS('height', '20px');
await moreSharing.click();
const deploymentMenu = menu.getByRole('menu', { name: 'More sharing options', exact: true });
await expect(deploymentMenu.getByRole('menuitem')).toHaveText(['Deploy to Vercel', 'Deploy to Cloudflare Pages']);
for (const [property, value] of Object.entries({ width: '214px', height: '68px', padding: '4px', gap: '2px', 'border-radius': '8px', 'background-color': 'rgb(255, 255, 255)' })) {
await expect(deploymentMenu).toHaveCSS(property, value);
}
const vercelAction = deploymentMenu.getByRole('menuitem', { name: 'Deploy to Vercel', exact: true });
for (const [property, value] of Object.entries({ height: '28px', padding: '0px 8px', gap: '8px', 'font-size': '12px', 'font-weight': '400' })) {
await expect(vercelAction).toHaveCSS(property, value);
}
await expect(vercelAction).toBeFocused();
await test.info().attach('s12-more-sharing', { body: await page.screenshot(), contentType: 'image/png' });
await page.keyboard.press('ArrowDown');
await expect(deploymentMenu.getByRole('menuitem', { name: 'Deploy to Cloudflare Pages' })).toBeFocused();
await page.keyboard.press('Escape');
await expect(deploymentMenu).toBeHidden();
await expect(moreSharing).toBeFocused();
await expect(heading).toBeVisible();
await moreSharing.click();
await vercelAction.click();
const deployDialog = page.getByRole('dialog');
await expect(deployDialog).toBeVisible();
await expect(deployDialog.getByRole('combobox', { name: /Provider/i })).toBeVisible();
await page.keyboard.press('Escape');
await expect(deployDialog).toBeHidden();
await secondShare.click();
await expect(heading).toBeVisible();
// The toolbar consumes the same shell, not a second deployment implementation.
await menu.getByRole('button', { name: 'Close', exact: true }).click();
await page.locator('.chrome-share-menu--unified > button[aria-label="Share"]').click();
await moreSharing.click();
await expect(deploymentMenu.getByRole('menuitem')).toHaveCount(2);
await test.info().attach('s12-toolbar-sharing', { body: await page.screenshot(), contentType: 'image/png' });
});
test('[P1] S10 failed stop preserves the share link and retries without republishing', async ({ page }) => {
await mockWritablePersonalProjectScope(page);
const { projectId, conversationId } = await seedProjectWithRepeatedArtifactCards(page);
const publicationPath = `/api/projects/${projectId}/files/index.html/publish-public`;
const slug = 's10-stable-alias';
const url = `https://example.test/artifact/${projectId}/${slug}`;
let stopAttempts = 0;
let publishAttempts = 0;
await page.route(`**${publicationPath}`, async route => {
const method = route.request().method();
if (method === 'GET') {
await route.fulfill({ json: { publication: { url, slug, fileName: 'index.html', publishedAt: 1 } } });
} else if (method === 'DELETE') {
stopAttempts += 1;
await route.fulfill(stopAttempts === 1
? { status: 500, json: { error: 's10_fixture_stop_failure' } }
: { json: { ok: true, slug, fileName: 'index.html' } });
} else {
publishAttempts += 1;
await route.fulfill({ status: 500, json: { error: 'unexpected publish' } });
}
});
await page.goto(`/projects/${projectId}/conversations/${conversationId}`);
await expectWorkspaceReady(page);
await page.getByTestId('artifact-card-publish-index.html').last().click();
const menu = page.locator('.share-menu-popover[role="menu"]');
const link = menu.locator('.chrome-publish-url');
const stop = menu.getByRole('button', { name: 'Stop sharing', exact: true });
const copy = menu.getByRole('button', { name: 'Copy share link', exact: true });
await expect(link).toHaveText(url);
const response = () => page.waitForResponse(r => r.request().method() === 'DELETE'
&& new URL(r.url()).pathname === publicationPath);
const failure = response();
await stop.click();
expect((await failure).status()).toBe(500);
await expect(menu.getByRole('status')).toHaveText('Could not turn off the link. Please try again.');
await expect(link).toHaveText(url);
await expect(stop).toBeEnabled();
await expect(copy).toBeEnabled();
await expect(menu).not.toContainText('Please manually copy');
expect(stopAttempts).toBe(1);
expect(publishAttempts).toBe(0);
await test.info().attach('s10-stop-failed', { body: await page.screenshot(), contentType: 'image/png' });
const retry = response();
await stop.click();
expect((await retry).ok()).toBe(true);
await expect(menu.getByRole('menuitem', { name: 'Generate and copy link', exact: true })).toBeEnabled();
await expect(link).toHaveCount(0);
await expect(menu.getByRole('status')).toHaveCount(0);
expect(stopAttempts).toBe(2);
expect(publishAttempts).toBe(0);
});
test('[P1] repeated artifact cards anchor Share to the clicked turn and keep the card menu focused', async ({ page }) => {
await page.clock.install();
await mockWritablePersonalProjectScope(page);
const { projectId, conversationId } = await seedProjectWithRepeatedArtifactCards(page);
await page.goto(`/projects/${projectId}/conversations/${conversationId}`);
await expectWorkspaceReady(page);
const shareButtons = page.getByTestId('artifact-card-publish-index.html');
await expect(shareButtons).toHaveCount(2);
const firstShare = shareButtons.nth(0);
const secondShare = shareButtons.nth(1);
// E0/G1 share-only seams must survive the production global CSS cascade.
const toolbarShare = page.locator('.chrome-share-menu--unified > button[aria-label="Share"]');
await expect(toolbarShare).toBeVisible();
for (const [property, value] of Object.entries({
height: '28px', 'border-radius': '6px', gap: '5px',
'padding-left': '12px', 'padding-right': '12px',
'background-color': 'rgb(40, 40, 40)', color: 'rgb(255, 255, 255)',
'font-size': '12px', 'font-weight': '500', 'border-top-width': '0px',
})) {
await expect(toolbarShare).toHaveCSS(property, value);
}
for (const [property, value] of Object.entries({
height: '30px', 'border-radius': '6px', gap: '5px',
'background-color': 'rgb(237, 237, 240)', color: 'rgb(51, 51, 51)',
'font-size': '12px', 'font-weight': '500', 'border-top-width': '0px',
'backdrop-filter': 'none', 'box-shadow': 'none',
})) {
await expect(secondShare).toHaveCSS(property, value);
}
const firstAnchor = await firstShare.getAttribute('data-artifact-anchor');
const secondAnchor = await secondShare.getAttribute('data-artifact-anchor');
expect(firstAnchor).toBeTruthy();
expect(secondAnchor).toBeTruthy();
expect(secondAnchor).not.toBe(firstAnchor);
await secondShare.click();
const anchoredMenu = page.locator('[data-anchored-menu]');
await expect(anchoredMenu).toHaveCount(1);
await expect(anchoredMenu).toHaveAttribute('data-anchored-menu', secondAnchor!);
const menu = anchoredMenu.locator('.share-menu-popover[role="menu"]');
await expect(menu).toBeVisible();
// Shared S1/S2/S3/S4/S4-C/S7 shell, scoped away from the Export tab.
for (const [property, value] of Object.entries({
width: '360px', padding: '16px 20px', gap: '12px',
'border-radius': '12px', 'background-color': 'rgb(255, 255, 255)',
'border-top-color': 'rgba(0, 0, 0, 0.03)', 'border-top-width': '1px',
'box-shadow': 'rgba(0, 0, 0, 0.063) 0px 8px 28px 0px, rgba(0, 0, 0, 0.024) 0px 2px 6px 0px',
})) {
await expect(menu).toHaveCSS(property, value);
}
await expect(menu.locator('.chrome-unified-panel--share')).toHaveCSS('padding', '0px');
await expect(menu.locator('.chrome-unified-panel--share')).toHaveCSS('gap', '12px');
const heading = menu.getByRole('heading', { name: 'Share', level: 2 });
await expect(heading).toBeVisible();
for (const [property, value] of Object.entries({ 'font-size': '15px', 'line-height': '22px', 'font-weight': '600', margin: '0px' })) {
await expect(heading).toHaveCSS(property, value);
}
const header = heading.locator('..');
for (const [property, value] of Object.entries({ 'min-height': '24px', gap: '12px', 'align-items': 'center', 'justify-content': 'space-between' })) {
await expect(header).toHaveCSS(property, value);
}
const closeShare = menu.getByRole('button', { name: 'Close', exact: true });
for (const [property, value] of Object.entries({
width: '20px', height: '20px', padding: '3px', 'border-top-width': '0px', 'border-radius': '5px',
color: 'rgb(133, 133, 133)', 'background-color': 'rgba(0, 0, 0, 0)',
})) {
await expect(closeShare).toHaveCSS(property, value);
}
await test.info().attach('share-header-first', { body: await page.screenshot(), contentType: 'image/png' });
await closeShare.focus();
await page.keyboard.press('Enter');
await expect(menu).toBeHidden();
await secondShare.click();
await expect(heading).toBeVisible();
const shareLink = menu.getByRole('menuitem', { name: 'Generate and copy link', exact: true });
await expect(shareLink).toBeVisible();
await expect(shareLink).toBeEnabled();
// S1's local button seam must survive the real shared/global CSS cascade.
for (const [property, value] of Object.entries({
height: '32px', 'border-radius': '6px', 'padding-top': '0px', 'padding-right': '8px',
'padding-bottom': '0px', 'padding-left': '8px', gap: '5px',
'background-color': 'rgb(41, 41, 43)', color: 'rgb(255, 255, 255)',
'font-size': '12px', 'font-weight': '500', 'line-height': '18px', 'border-top-width': '0px',
})) {
await expect(shareLink).toHaveCSS(property, value);
}
await expect(shareLink.locator('svg')).toHaveAttribute('width', '13');
await expect(shareLink.locator('svg')).toHaveAttribute('height', '13');
await expect(shareLink.locator('svg path')).toHaveAttribute('d', 'M12 15V4m-4 4 4-4 4 4M5 20h14');
await expect(menu).not.toContainText(/Quick Share/i);
await expect(menu).not.toContainText('Share project in workspace');
await expect(menu).not.toContainText('Visibility in workspace');
await expect(menu.getByRole('menuitem', { name: 'Deploy to Vercel', exact: true })).toBeHidden();
await expect(menu).not.toContainText('Save as template');
// S7 HTTP failure fixture, not a real cloud failure. Only the production API
// response is mocked; the product module and its CSS remain unmodified.
let publishAttempts = 0;
let releaseFirstPublish!: () => void;
const firstPublishGate = new Promise<void>(resolve => { releaseFirstPublish = resolve; });
await page.route(`**/api/projects/${projectId}/files/index.html/publish-public`, async (route) => {
if (route.request().method() !== 'POST') { await route.continue(); return; }
publishAttempts += 1;
if (publishAttempts === 1) await firstPublishGate;
await route.fulfill({ status: 500, json: { error: 's7_fixture_internal_failure' } });
});
await shareLink.click();
try {
await expect(menu.getByRole('progressbar')).toBeVisible();
await expect(closeShare).toBeEnabled();
await test.info().attach('share-header-publishing', { body: await page.screenshot(), contentType: 'image/png' });
await closeShare.click();
await expect(menu).toBeHidden();
await secondShare.click();
await expect(heading).toBeVisible();
await expect(menu.getByRole('progressbar')).toBeVisible();
expect(publishAttempts).toBe(1); // Closing a shell must not cancel or restart upload.
} finally {
releaseFirstPublish();
}
const failure = menu.getByRole('status');
await expect(failure).toHaveText('Could not create the share link. Please try again later.');
await expect(failure).not.toContainText('s7_fixture_internal_failure');
for (const [property, value] of Object.entries({
color: 'rgb(201, 78, 78)', 'font-size': '12px', 'line-height': '18px',
display: 'flex', 'align-items': 'flex-start', gap: '6px', margin: '0px', padding: '0px',
})) {
await expect(failure).toHaveCSS(property, value);
}
const warningIcon = failure.locator('svg');
await expect(warningIcon).toHaveCSS('width', '14px');
await expect(warningIcon).toHaveCSS('height', '14px');
await expect(warningIcon).toHaveCSS('flex-shrink', '0');
await expect(warningIcon).toHaveCSS('margin-top', '2px');
await expect(warningIcon).toHaveAttribute('aria-hidden', 'true');
await expect(warningIcon.locator('path')).toHaveAttribute('d', 'M8 4.8v3.6M8 11h.01');
const retry = menu.getByRole('menuitem', { name: 'Retry', exact: true });
await expect(retry).toBeEnabled();
expect(publishAttempts).toBe(1);
const retried = page.waitForResponse(response => response.request().method() === 'POST'
&& new URL(response.url()).pathname === `/api/projects/${projectId}/files/index.html/publish-public`);
await retry.click();
expect((await retried).status()).toBe(500);
await expect(failure).toBeVisible();
expect(publishAttempts).toBe(2);
// S11: publication succeeds, but both browser clipboard mechanisms fail.
// Mock external boundaries only; selection and CSS exercise the real DOM.
await page.evaluate(() => {
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText: () => Promise.reject(new Error('clipboard denied by fixture')) },
});
document.execCommand = () => false;
});
const publicUrl = `https://example.test/artifact/${projectId}/stable-alias-for-manual-copy`;
await page.route(`**/api/projects/${projectId}/files/index.html/publish-public`, async route => {
if (route.request().method() !== 'POST') { await route.continue(); return; }
publishAttempts += 1;
await route.fulfill({ json: { url: publicUrl, slug: 'stable-alias-for-manual-copy', fileName: 'index.html' } });
});
await retry.click();
const failedCopy = menu.getByRole('button', { name: 'Copy share link', exact: true });
await expect(failedCopy).toBeVisible();
const manualCopyHint = menu.getByRole('status');
await expect(manualCopyHint).toHaveText('Could not copy automatically. Please manually copy the link above.');
for (const [property, value] of Object.entries({
margin: '0px', color: 'rgb(136, 136, 136)', 'font-size': '12px', 'line-height': '18px',
})) {
await expect(manualCopyHint).toHaveCSS(property, value);
}
const fallback = menu.locator('.chrome-publish-url');
await expect(fallback).toHaveText(publicUrl);
await expect(fallback).toHaveAttribute('title', publicUrl);
for (const [property, value] of Object.entries({
height: '32px', padding: '0px 9px', 'border-radius': '6px',
'border-top-width': '1px', 'border-top-color': 'rgb(229, 229, 229)',
'background-color': 'rgb(255, 255, 255)', color: 'rgb(102, 102, 102)',
'font-size': '11px', 'line-height': '30px', 'user-select': 'text',
'white-space': 'nowrap', 'text-overflow': 'ellipsis',
})) {
await expect(fallback).toHaveCSS(property, value);
}
await fallback.click({ clickCount: 3 });
await expect.poll(() => page.evaluate(() => window.getSelection()?.toString().trim())).toBe(publicUrl);
await test.info().attach('s11-manual-copy', { body: await page.screenshot(), contentType: 'image/png' });
await failedCopy.click();
await expect(failedCopy).toBeEnabled();
expect(publishAttempts).toBe(3); // Retrying copy must not publish again.
// S4-C: hold only the external clipboard boundary; the real host settles feedback.
// Pause before the copy creates its feedback timer; assert the actual boundary.
// Anchor to the browser clock: Playwright actions may have advanced it beyond Node's time.
const pauseTime = await page.evaluate(() => Date.now() + 1_000);
await page.clock.pauseAt(pauseTime);
await page.evaluate(() => {
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText: () => new Promise<void>((resolve) => {
window.addEventListener('test:release-copy', () => resolve(), { once: true });
}) },
});
});
await failedCopy.click();
const copying = menu.getByRole('button', { name: 'Copying…', exact: true });
await expect(copying).toBeVisible();
await expect(copying).toBeDisabled();
await expect(copying).toHaveAttribute('aria-busy', 'true');
await expect(copying).toHaveCSS('background-color', 'rgb(90, 90, 92)');
await expect(copying).toHaveCSS('color', 'rgb(255, 255, 255)');
await expect(copying).toHaveCSS('opacity', '1');
const copySpinner = copying.locator('svg');
await expect(copySpinner).toHaveAttribute('stroke-width', '2');
await expect(copySpinner.locator('path')).toHaveAttribute('d', 'M12 3a9 9 0 1 0 9 9');
await expect(copySpinner).toHaveCSS('width', '13px');
expect(await copySpinner.evaluate((node) => getComputedStyle(node).animationName)).not.toBe('none');
await expect(fallback).toHaveText(publicUrl);
await test.info().attach('s4-c-copy-pending', { body: await page.screenshot(), contentType: 'image/png' });
await page.evaluate(() => window.dispatchEvent(new Event('test:release-copy')));
const copied = menu.getByRole('button', { name: 'Copied!', exact: true });
await expect(copied).toBeVisible();
await expect(copied).toBeEnabled();
await expect(copied).not.toHaveAttribute('aria-busy');
await page.clock.runFor(1799);
await expect(copied).toBeVisible();
await page.clock.runFor(1);
await expect(failedCopy).toBeVisible(); // Existing host feedback timer restores the action.
await page.clock.resume();
expect(publishAttempts).toBe(3);
// The same real cards become disabled through the workspace permission boundary.
const readonlyContext = {
...AMR_PERSONAL_WORKSPACE_CONTEXT,
workspaceType: 'team', teamId: 'disabled-share-team',
permissions: { ...AMR_PERSONAL_WORKSPACE_CONTEXT.permissions, canWriteSyncedFiles: false },
};
await page.route(`**/api/projects/${projectId}/workspace-scope`, route => route.fulfill({
json: { scope: { kind: 'team', projectId, workspaceId: readonlyContext.workspaceId, visibility: 'team', context: readonlyContext } },
}));
await page.route(`**/api/projects/${projectId}/collab/status`, route => route.fulfill({
json: { publishedVersion: 1, materializedVersion: 1, syncState: 'synced', ownerMemberId: 'another-owner' },
}));
await page.reload();
await page.getByText('Loading OpenDesign…').waitFor({ state: 'hidden', timeout: T.long });
await expect(page.getByTestId('chat-composer-input')).toHaveAttribute('aria-readonly', 'true');
// Read-only navigation may focus the preview; reveal chat through its UI controls.
const showChat = page.getByTestId('workspace-focus-toggle');
if (await showChat.isVisible()) await showChat.click();
const expandConversation = page.getByRole('button', { name: 'Expand the conversation pane' });
if (await expandConversation.isVisible()) await expandConversation.click();
await expect(secondShare).toBeVisible();
await expect(shareButtons).toHaveCount(2);
for (const share of await shareButtons.all()) {
await expect(share).toBeDisabled();
await expect(share).toHaveAttribute('title', /.+/);
for (const [property, value] of Object.entries({ 'background-color': 'rgb(243, 243, 241)', color: 'rgb(189, 189, 184)', opacity: '1', height: '30px' })) {
await expect(share).toHaveCSS(property, value);
}
}
await secondShare.hover();
await expect(secondShare).toHaveCSS('background-color', 'rgb(243, 243, 241)');
await expect(secondShare).toHaveCSS('color', 'rgb(189, 189, 184)');
await expect(page.locator('.share-menu-popover[role="menu"]')).toHaveCount(0);
await test.info().attach('card-share-disabled', { body: await page.screenshot(), contentType: 'image/png' });
});
test('[P1] project detail fork emits correlated click and result analytics', async ({ page }) => {
const analyticsBodies: string[] = [];
await page.unroute('**/api/app-config').catch(() => {});
await page.addInitScript((key) => {
window.localStorage.setItem(
key,
JSON.stringify({
mode: 'daemon',
apiKey: '',
baseUrl: 'https://api.anthropic.com',
model: 'default',
agentId: 'codex',
skillId: null,
designSystemId: null,
onboardingCompleted: true,
privacyDecisionAt: 1,
telemetry: { metrics: true, content: false, artifactManifest: false },
agentModels: { codex: { model: 'default' } },
}),
);
}, STORAGE_KEY);
await page.route('**/api/app-config', async (route) => {
await route.fulfill({
json: {
config: {
onboardingCompleted: true,
privacyDecisionAt: 1,
telemetry: { metrics: true, content: false, artifactManifest: false },
mode: 'daemon',
agentId: 'codex',
skillId: null,
designSystemId: null,
agentModels: { codex: { model: 'default' } },
agentCliEnv: {},
},
},
});
});
await page.route('**/api/analytics/config', async (route) => {
await route.fulfill({
json: {
enabled: true,
env: 'e2e',
key: 'phc_e2e',
host: 'https://analytics.open-design.test',
installationId: 'e2e-installation',
},
});
});
await page.route('https://analytics.open-design.test/**', async (route) => {
analyticsBodies.push(route.request().postData() ?? '');
await route.fulfill({ status: 200, json: { status: 1 } });
});
const { projectId, conversationId } = await seedProjectWithAssistantCompletion(page);
await page.goto(`/projects/${projectId}/conversations/${conversationId}`);
await expectWorkspaceReady(page);
const forkResponsePromise = page.waitForResponse((response) => {
return response.request().method() === 'POST'
&& response.url().endsWith(`/api/projects/${projectId}/conversations`);
});
await page.getByTestId('assistant-fork-button').click();
expect((await forkResponsePromise).ok()).toBe(true);
await expect
.poll(() => analyticsBodies.join('\n'), { timeout: T.medium })
.toContain('conversation_fork_result');
const raw = analyticsBodies.join('\n');
expect(raw).toContain('assistant_fork_button');
expect(raw).toContain('fork_conversation');
expect(raw).toContain('"result":"success"');
expect(raw).toContain('"fork_point":"latest"');
expect(raw).toContain(projectId);
expect(raw).toContain(conversationId);
const requestIdCounts = new Map<string, number>();
for (const match of raw.matchAll(/"request_id":"([^"]+)"/g)) {
const requestId = match[1];
if (!requestId) continue;
requestIdCounts.set(requestId, (requestIdCounts.get(requestId) ?? 0) + 1);
}
expect([...requestIdCounts.values()].some((count) => count >= 2)).toBe(true);
});
test('[P1] project detail forks histories larger than the daemon JSON body limit', async ({ page }) => {
test.setTimeout(T.xlong);
const { projectId, conversationId, expectedContents } =
await seedProjectWithLargeAssistantHistory(page);
await page.goto(`/projects/${projectId}/conversations/${conversationId}`);
await expectWorkspaceReady(page);
await expect(page.getByTestId('assistant-fork-button')).toHaveCount(3, {
timeout: T.long,
});
const forkResponsePromise = page.waitForResponse((response) => {
return response.request().method() === 'POST'
&& response.url().endsWith(`/api/projects/${projectId}/conversations`);
});
await page.getByTestId('assistant-fork-button').last().click();
const forkResponse = await forkResponsePromise;
expect(
forkResponse.ok(),
`fork large conversation: ${await forkResponse.text()}`,
).toBe(true);
const forkRequestBody = forkResponse.request().postDataJSON() as {
seedMessages?: unknown;
};
expect(forkRequestBody.seedMessages).toBeUndefined();
await expect
.poll(() => getProjectContextFromUrl(page).conversationId)
.not.toBe(conversationId);
const forkConversationId = getProjectContextFromUrl(page).conversationId;
expect(forkConversationId).toBeTruthy();
const forkRequestHeaders = forkResponse.request().headers();
const workspaceHeaders = Object.fromEntries(
['x-od-workspace-id', 'x-od-workspace-member-id']
.map((name) => [name, forkRequestHeaders[name]] as const)
.filter((entry): entry is [string, string] => typeof entry[1] === 'string'),
);
const forkMessagesResponse = await page.request.get(
`/api/projects/${projectId}/conversations/${forkConversationId}/messages`,
{ headers: workspaceHeaders },
);
expect(
forkMessagesResponse.ok(),
`load forked messages: ${await forkMessagesResponse.text()}`,
).toBe(true);
const forkMessagesBody = (await forkMessagesResponse.json()) as {
messages: Array<{ content: string }>;
};
expect(forkMessagesBody.messages.map((message) => message.content)).toEqual(expectedContents);
});
test('[P1] read-only project viewers do not see conversation fork actions', async ({ page }) => {
const { projectId, conversationId } = await seedProjectWithAssistantCompletion(page);
const readonlyTeamContext = {
...AMR_PERSONAL_WORKSPACE_CONTEXT,
workspaceId: 'workspace-readonly-fork',
workspaceType: 'team',
workspaceMemberId: 'member-readonly-fork',
role: 'member',
teamId: 'team-readonly-fork',
permissions: {
...AMR_PERSONAL_WORKSPACE_CONTEXT.permissions,
canWriteSyncedFiles: false,
},
};
await page.route(`**/api/projects/${projectId}/workspace-scope`, async (route) => {
await route.fulfill({
json: {
scope: {
kind: 'team',
projectId,
workspaceId: readonlyTeamContext.workspaceId,
visibility: 'team',
context: readonlyTeamContext,
},
},
});
});
await page.route(`**/api/projects/${projectId}/collab/status`, async (route) => {
await route.fulfill({
json: {
publishedVersion: 1,
materializedVersion: 1,
syncState: 'synced',
ownerMemberId: 'member-project-owner',
},
});
});
await page.goto(`/projects/${projectId}/conversations/${conversationId}`);
await page
.getByText('Loading OpenDesign…')
.waitFor({ state: 'hidden', timeout: T.long })
.catch(() => {});
const showChat = page.getByTestId('workspace-focus-toggle');
if (await showChat.isVisible()) {
await showChat.click();
}
const expandConversation = page.getByRole('button', { name: 'Expand the conversation pane' });
if (await expandConversation.isVisible()) {
await expandConversation.click();
}
await expect(page.getByTestId('chat-composer-input')).toBeVisible({ timeout: T.long });
await expect(page.getByTestId('chat-composer-input')).toHaveAttribute('aria-readonly', 'true');
await expect(page.getByTestId('assistant-fork-button')).toHaveCount(0);
});
test('[P1] project detail conversations menu supports new chat, search, and recency metadata', async ({ page }) => {
const { projectId, conversations } = await seedProjectConversationHistory(page);
await routeConversationHistoryFixtures(page, projectId, conversations);
await page.goto(`/projects/${projectId}/conversations/${conversations[0]!.id}`);
await expectWorkspaceReady(page);
await page.getByTestId('conversation-history-trigger').click();
const menu = page.getByTestId('conversation-history-menu');
await expect(menu).toBeVisible();
// The dropdown no longer shows a heading, a count, per-row message counts
// or run durations (OPEND-3087, Demo #8113): rows carry only the title and
// the time since the last update. The fixtures are 30s / 2m / 7m old at
// seed time, so allow one minute of drift while the page loads.
const rows = page.getByTestId('conversation-list').locator('.chat-conv-item');
await expect(rows).toHaveCount(3);
await expect(page.getByTestId(`conversation-select-${conversations[0]!.id}`)).toContainText('Runway final polish');
await expect(page.getByTestId(`conversation-meta-${conversations[0]!.id}`)).toHaveText(/^(now|1m)$/);
await expect(page.getByTestId(`conversation-meta-${conversations[1]!.id}`)).toHaveText(/^[23]m$/);
await expect(page.getByTestId(`conversation-meta-${conversations[2]!.id}`)).toHaveText(/^[78]m$/);
await expect(page.getByTestId('conversation-history-count')).toHaveCount(0);
await expect(menu.getByTestId(/^conversation-delete-/)).toHaveCount(0);
await page.getByTestId('conversation-history-search').fill('font audit');
await expect(rows).toHaveCount(1);
await expect(page.getByTestId(`conversation-item-${conversations[1]!.id}`)).toBeVisible();
await expect(page.getByTestId(`conversation-item-${conversations[0]!.id}`)).toHaveCount(0);
await page.getByTestId('conversation-history-search').fill('');
const newConversationRequestPromise = page.waitForRequest((request) => {
return request.method() === 'POST'
&& request.url().endsWith(`/api/projects/${projectId}/conversations`);
});
// The single "new conversation" control sits inside the dropdown beside the
// search field. Clicking it still dismisses the menu, which is what the
// count assertion below pins.
await menu.getByTestId('chat-new-conversation').click();
await newConversationRequestPromise;
await expect(page.getByTestId('conversation-history-menu')).toHaveCount(0);
await page.getByTestId('conversation-history-trigger').click();
await expect(rows).toHaveCount(4);
await expect(page.getByTestId('conversation-select-conv-new-history')).toContainText('Untitled');
await expect(page.getByTestId('conversation-meta-conv-new-history')).toHaveText(/^(now|1m)$/);
});
test('[P0] project detail share menu copies the current share link for uploaded html artifacts', async ({ page }) => {
// Upload opens the file tab immediately, so the first deployment read may
// precede the upload helper's return. Seed the deterministic file name so
// that read receives the deployment instead of a stale empty fixture.
let uploadedName = 'share-link-copy.html';
await page.addInitScript(() => {
const store: string[] = [];
Object.defineProperty(window, '__copiedTexts', {
value: store,
configurable: true,
});
Object.defineProperty(navigator, 'clipboard', {
value: {
writeText(text: string) {
store.push(text);
return Promise.resolve();
},
},
configurable: true,
});
});
await page.route('**/api/projects/*/deployments', async (route) => {
await route.fulfill({
json: {
deployments: uploadedName
? [{
id: 'ready-share-link',
projectId: getProjectIdFromApiPath(route.request().url()),
fileName: uploadedName,
providerId: 'vercel-self',
url: 'https://share-preview.example',
deploymentCount: 1,
target: 'preview',
status: 'ready',
createdAt: 1,
updatedAt: 2,
}]
: [],
},
});
});
await mockWritablePersonalProjectScope(page);
await page.goto('/');
await createProject(page, 'Share link copy flow', {
headers: AMR_PERSONAL_WORKSPACE_HEADERS,
});
await expectWorkspaceReady(page);
uploadedName = await uploadTinyHtml(page, 'share-link-copy.html', '<!doctype html><html><body><h1>Share link copy</h1></body></html>', {
headers: AMR_PERSONAL_WORKSPACE_HEADERS,
});
await openUploadedHtmlArtifactPreview(page, uploadedName);
await openShareMenu(page);
await page.getByRole('menuitem', { name: /^Copy share link$/i }).click();
await expect(page.getByRole('menuitem', { name: /^Copied!$/i })).toBeVisible();
const copied = await page.evaluate(() => (window as typeof window & { __copiedTexts?: string[] }).__copiedTexts ?? []);
expect(copied.at(-1)).toBe('https://share-preview.example');
});
test('[P0] project detail share menu opens the current share page for uploaded html artifacts', async ({ page }) => {
// See the copy-link case above: FileViewer can ask for deployments before
// the upload helper returns and assigns the persisted file name.
let uploadedName = 'share-page-open.html';
await page.addInitScript(() => {
const opened: string[] = [];
Object.defineProperty(window, '__openedUrls', {
value: opened,
configurable: true,
});
const originalOpen = window.open.bind(window);
window.open = ((...args: Parameters<typeof window.open>) => {
if (typeof args[0] === 'string') opened.push(args[0]);
return originalOpen(...args);
}) as typeof window.open;
});
await page.route('**/api/projects/*/deployments', async (route) => {
await route.fulfill({
json: {
deployments: uploadedName
? [{
id: 'protected-share-link',
projectId: getProjectIdFromApiPath(route.request().url()),
fileName: uploadedName,
providerId: 'vercel-self',
url: 'https://protected-share.example',
deploymentCount: 1,
target: 'preview',
status: 'protected',
createdAt: 1,
updatedAt: 2,
}]
: [],
},
});
});
// This scenario creates through Playwright's APIRequestContext rather than
// the browser UI, so the Web cannot inherit its normal same-session creation
// witness. Give the page an exact writable Personal/owner identity and bind
// the project-scope bootstrap to that same identity. Do not make the share
// control writable by weakening the shared-project authority gate.
await mockWritablePersonalProjectScope(page);
await page.goto('/');
await createProject(page, 'Open share page flow', {
headers: AMR_PERSONAL_WORKSPACE_HEADERS,
});
await expectWorkspaceReady(page);
uploadedName = await uploadTinyHtml(page, 'share-page-open.html', '<!doctype html><html><body><h1>Open share page</h1></body></html>', {
headers: AMR_PERSONAL_WORKSPACE_HEADERS,
});
await openUploadedHtmlArtifactPreview(page, uploadedName);
await openShareMenu(page);
await page.getByRole('menuitem', { name: /Open share page/i }).click();
await expect
.poll(() =>
page.evaluate(() => (window as typeof window & { __openedUrls?: string[] }).__openedUrls ?? []),
)
.toContain('https://protected-share.example/');
});
test('[P0] @critical project detail share menu lists Vercel under more sharing options', async ({ page }) => {
await page.route('**/api/projects/*/deployments', async (route) => {
await route.fulfill({ json: { deployments: [] } });
});
// Match the other writable share scenarios: APIRequestContext creation does
// not register the browser's same-session owner witness, so provide the
// exact Personal authority this test intends to exercise.
await mockWritablePersonalProjectScope(page);
await page.goto('/');
await createProject(page, 'Deploy action flow', {
headers: AMR_PERSONAL_WORKSPACE_HEADERS,
});
await expectWorkspaceReady(page);
const uploadedName = await uploadTinyHtml(page, 'deploy-action.html', '<!doctype html><html><body><h1>Deploy action</h1></body></html>', {
headers: AMR_PERSONAL_WORKSPACE_HEADERS,
});
await openUploadedHtmlArtifactPreview(page, uploadedName);
await openShareMenu(page);
await page.getByRole('button', { name: 'More sharing options' }).click();
await expect(page.getByRole('menuitem', { name: /^Deploy to Vercel$/i })).toBeVisible();
});
test('[P1] home design card deletion supports cancel and confirm flows', async ({ page }) => {
const projectName = `Home delete design flow ${Date.now()}`;
await page.goto('/');
await createProject(page, projectName);
await expectWorkspaceReady(page);
const { projectId } = getProjectContextFromUrl(page);
await page.goto('/projects');
await expectDesignsView(page);
const designCard = homeDesignCard(page, projectName);
await expect(designCard).toBeVisible();
// Cancel flow: open the overflow menu, choose Delete, then dismiss the confirm modal.
await designCard.hover();
await designCard.getByRole('button', { name: /more actions/i }).click();
await page.getByRole('menuitem', { name: /^delete$/i }).click();
const confirmDialog = page.locator('.modal-confirm');
await expect(confirmDialog).toBeVisible();
await expect(confirmDialog).toContainText(projectName);
await confirmDialog.getByRole('button', { name: /^cancel$/i }).click();
await expect(confirmDialog).toHaveCount(0);
await expect(designCard).toBeVisible();
// Confirm flow: same trigger, this time accept the confirm modal.
await designCard.hover();
await designCard.getByRole('button', { name: /more actions/i }).click();
await page.getByRole('menuitem', { name: /^delete$/i }).click();
const confirmDialog2 = page.locator('.modal-confirm');
await expect(confirmDialog2).toBeVisible();
await expect(confirmDialog2).toContainText(projectName);
await confirmDialog2.getByRole('button', { name: /^delete$/i }).click();
await expect(homeDesignCard(page, projectName)).toHaveCount(0);
const response = await page.request.get(`/api/projects/${projectId}`);
expect(response.status()).toBe(404);
});
test('[P2] home designs view toggle switches between grid and kanban and persists', async ({ page }) => {
const projectName = `Home view toggle flow ${Date.now()}`;
await page.goto('/');
await createProject(page, projectName);
await expectWorkspaceReady(page);
const { projectId } = getProjectContextFromUrl(page);
await page.goto('/projects');
await expectDesignsView(page);
await expect(homeDesignCard(page, projectName)).toBeVisible();
await expect(page.locator('.design-grid')).toBeVisible();
await expect(page.locator('.design-kanban-board')).toHaveCount(0);
await expect(page.getByTestId('designs-view-grid')).toHaveAttribute('aria-pressed', 'true');
await page.getByTestId('designs-view-kanban').click();
await expect(page.locator('.design-kanban-board')).toBeVisible();
await expect(page.locator('.design-grid')).toHaveCount(0);
await expect(page.getByTestId('designs-view-kanban')).toHaveAttribute('aria-pressed', 'true');
await expect(page.locator('.design-kanban-card', { hasText: projectName })).toBeVisible();
await page.reload();
await expectDesignsView(page);
await expect(page.locator('.design-kanban-board')).toBeVisible();
await expect(page.getByTestId('designs-view-kanban')).toHaveAttribute('aria-pressed', 'true');
const projectsAfterReload = await listProjectsFromApi(page);
expect(projectsAfterReload.some((project) => project.id === projectId && project.name === projectName)).toBe(true);
await page.getByTestId('designs-view-grid').click();
await expect(page.locator('.design-grid')).toBeVisible();
await expect(homeDesignCard(page, projectName)).toBeVisible();
await expect(page.getByTestId('designs-view-grid')).toHaveAttribute('aria-pressed', 'true');
});
test('[P1] home designs search filters projects and recovers from no results', async ({ page }) => {
test.setTimeout(60_000);
const stamp = Date.now();
const alphaName = `Home search alpha ${stamp}`;
const betaName = `Home search beta ${stamp}`;
await page.goto('/');
await createProject(page, alphaName);
await expectWorkspaceReady(page);
const alphaProjectId = getProjectContextFromUrl(page).projectId;
await page.goto('/projects');
await expectDesignsView(page);
await createProject(page, betaName);
await expectWorkspaceReady(page);
const betaProjectId = getProjectContextFromUrl(page).projectId;
await page.goto('/projects');
await expectDesignsView(page);
await expect(homeDesignCard(page, alphaName)).toBeVisible();
await expect(homeDesignCard(page, betaName)).toBeVisible();
const search = page.locator('.tab-panel-toolbar .toolbar-search input');
await search.fill('alpha');
await expect(homeDesignCard(page, alphaName)).toBeVisible();
await expect(homeDesignCard(page, betaName)).toHaveCount(0);
await search.fill(`missing-${stamp}`);
await expect(homeDesignCard(page, alphaName)).toHaveCount(0);
await expect(homeDesignCard(page, betaName)).toHaveCount(0);
await expect(page.locator('.tab-empty')).toBeVisible();
await search.fill('');
await expect(homeDesignCard(page, alphaName)).toBeVisible();
await expect(homeDesignCard(page, betaName)).toBeVisible();
const projects = await listProjectsFromApi(page);
expect(projects).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: alphaProjectId, name: alphaName }),
expect.objectContaining({ id: betaProjectId, name: betaName }),
]),
);
});
test('[P2] projects sub tabs switch between Recent and Your designs ordering', async ({ page }) => {
const now = Date.now();
const projects = [
makeProjectsTabProject({
id: 'proj-alpha',
name: 'Sort Alpha',
createdAt: now - 3 * 60_000,
updatedAt: now - 1 * 60_000,
}),
makeProjectsTabProject({
id: 'proj-beta',
name: 'Sort Beta',
createdAt: now - 1 * 60_000,
updatedAt: now - 3 * 60_000,
}),
makeProjectsTabProject({
id: 'proj-gamma',
name: 'Sort Gamma',
createdAt: now - 2 * 60_000,
updatedAt: now - 2 * 60_000,
}),
];
await page.route('**/api/projects', async (route) => {
if (route.request().method() === 'GET') {
await route.fulfill({ json: { projects } });
return;
}
await route.continue();
});
await page.route('**/api/live-artifacts?projectId=*', async (route) => {
await route.fulfill({ json: { liveArtifacts: [] } });
});
await stubCatalogsEmpty(page);
await page.goto('/projects');
await expectDesignsView(page);
await expect(page.locator('.design-grid .design-card .design-card-name').nth(0)).toContainText(
'Sort Alpha',
);
await expect(page.locator('.design-grid .design-card .design-card-name').nth(1)).toContainText(
'Sort Gamma',
);
await expect(page.locator('.design-grid .design-card .design-card-name').nth(2)).toContainText(
'Sort Beta',
);
await page.getByRole('button', { name: 'Your designs' }).click();
await expect(page.locator('.design-grid .design-card .design-card-name').nth(0)).toContainText(
'Sort Beta',
);
await expect(page.locator('.design-grid .design-card .design-card-name').nth(1)).toContainText(
'Sort Gamma',
);
await expect(page.locator('.design-grid .design-card .design-card-name').nth(2)).toContainText(
'Sort Alpha',
);
});
test('[P1] projects grid card rename updates the card title and persists after reload', async ({ page }) => {
const originalName = `Projects rename flow ${Date.now()}`;
const renamedName = `${originalName} renamed`;
await page.goto('/');
await createProject(page, originalName);
await expectWorkspaceReady(page);
const { projectId } = getProjectContextFromUrl(page);
await page.goto('/projects');
await expectDesignsView(page);
const card = homeDesignCard(page, originalName);
await card.hover();
await card.getByRole('button', { name: /more actions/i }).click();
await page.getByRole('menuitem', { name: /^rename$/i }).click();
const renameModal = page.locator('.modal-rename');
await expect(renameModal).toBeVisible();
const renameInput = renameModal.getByRole('textbox');
await expect(renameInput).toHaveValue(originalName);
await renameInput.fill(renamedName);
await renameModal.locator('button.primary').click();
await expect(homeDesignCard(page, renamedName)).toBeVisible();
await expect(homeDesignCard(page, originalName)).toHaveCount(0);
await page.reload();
await expectDesignsView(page);
await expect(homeDesignCard(page, renamedName)).toBeVisible();
const project = await fetchProjectById(page, projectId);
expect(project.name).toBe(renamedName);
});
test('[P1] projects select mode supports multi-select delete with cancel and confirm', async ({ page }) => {
const firstName = `Batch delete A ${Date.now()}`;
const secondName = `Batch delete B ${Date.now()}`;
await page.goto('/');
await createProject(page, firstName);
await expectWorkspaceReady(page);
const firstProjectId = getProjectContextFromUrl(page).projectId;
await page.goto('/projects');
await expectDesignsView(page);
await createProject(page, secondName);
await expectWorkspaceReady(page);
const secondProjectId = getProjectContextFromUrl(page).projectId;
await page.goto('/projects');
await expectDesignsView(page);
await page.locator('.designs-select-toggle').click();
await homeDesignCard(page, firstName).click();
await homeDesignCard(page, secondName).click();
await expect(page.locator('.designs-select-bar')).toBeVisible();
await expect(page.locator('.design-card.is-selected')).toHaveCount(2);
await page.getByRole('button', { name: /Delete selected/i }).click();
const confirmDialog = page.locator('.modal-confirm');
await expect(confirmDialog).toBeVisible();
await confirmDialog.getByRole('button', { name: /^cancel$/i }).click();
await expect(confirmDialog).toHaveCount(0);
await expect(homeDesignCard(page, firstName)).toBeVisible();
await expect(homeDesignCard(page, secondName)).toBeVisible();
await page.getByRole('button', { name: /Delete selected/i }).click();
const confirmDialog2 = page.locator('.modal-confirm');
await expect(confirmDialog2).toBeVisible();
await confirmDialog2.getByRole('button', { name: /^delete/i }).click();
await expect(homeDesignCard(page, firstName)).toHaveCount(0);
await expect(homeDesignCard(page, secondName)).toHaveCount(0);
await expect(page.locator('.designs-select-bar')).toHaveCount(0);
const firstResponse = await page.request.get(`/api/projects/${firstProjectId}`);
const secondResponse = await page.request.get(`/api/projects/${secondProjectId}`);
expect(firstResponse.status()).toBe(404);
expect(secondResponse.status()).toBe(404);
});
test('[P1] projects kanban cards open projects and support delete cancel and confirm', async ({ page }) => {
const projectName = `Kanban flow ${Date.now()}`;
await page.goto('/');
await createProject(page, projectName);
await expectWorkspaceReady(page);
const { projectId } = getProjectContextFromUrl(page);
await page.goto('/projects');
await expectDesignsView(page);
await page.getByTestId('designs-view-kanban').click();
await expect(page.locator('.design-kanban-board')).toBeVisible();
const kanbanCard = page.locator('.design-kanban-card', { hasText: projectName });
await expect(kanbanCard).toBeVisible();
await kanbanCard.click();
await expect(page).toHaveURL(new RegExp(`/projects/${projectId}(/conversations/[^/]+)?$`));
await expect(page.getByTestId('workspace-tabs-dropdown-trigger')).toContainText(projectName);
const openedProject = await fetchCurrentProject(page);
expect(openedProject.name).toBe(projectName);
await page.goto('/projects');
await expectDesignsView(page);
await expect(page.locator('.design-kanban-board')).toBeVisible();
const kanbanCardAgain = page.locator('.design-kanban-card', { hasText: projectName });
await kanbanCardAgain.locator('.design-card-close').click();
const confirmDialog = page.locator('.modal-confirm');
await expect(confirmDialog).toBeVisible();
await confirmDialog.getByRole('button', { name: /^cancel$/i }).click();
await expect(kanbanCardAgain).toBeVisible();
await kanbanCardAgain.locator('.design-card-close').click();
const confirmDialog2 = page.locator('.modal-confirm');
await expect(confirmDialog2).toBeVisible();
await confirmDialog2.getByRole('button', { name: /^delete/i }).click();
await expect(page.locator('.design-kanban-card', { hasText: projectName })).toHaveCount(0);
const response = await page.request.get(`/api/projects/${projectId}`);
expect(response.status()).toBe(404);
});
test('[P2] projects page shows the empty state when there are no projects', async ({ page }) => {
await page.route('**/api/projects', async (route) => {
if (route.request().method() === 'GET') {
await route.fulfill({ json: { projects: [] } });
return;
}
await route.continue();
});
await stubCatalogsEmpty(page);
await page.goto('/projects');
await expect(page).toHaveURL(/\/projects$/);
await expect(page.locator('.tab-empty')).toBeVisible();
await expect(page.locator('.tab-empty')).toContainText('No projects yet');
await expect(page.locator('.design-grid')).toHaveCount(0);
await expect(page.locator('.design-kanban-board')).toHaveCount(0);
});
test('[P2] projects page shows the no-results state and recovers when search is cleared', async ({ page }) => {
const projects = [
makeProjectsTabProject({
id: 'proj-search-1',
name: 'Searchable Prototype',
createdAt: Date.now() - 10_000,
updatedAt: Date.now() - 5_000,
}),
];
await page.route('**/api/projects', async (route) => {
if (route.request().method() === 'GET') {
await route.fulfill({ json: { projects } });
return;
}
await route.continue();
});
await page.route('**/api/live-artifacts?projectId=*', async (route) => {
await route.fulfill({ json: { liveArtifacts: [] } });
});
await stubCatalogsEmpty(page);
await page.goto('/projects');
await expectDesignsView(page);
await expect(homeDesignCard(page, 'Searchable Prototype')).toBeVisible();
const search = page.locator('.tab-panel-toolbar .toolbar-search input');
await search.fill('does-not-exist');
await expect(page.locator('.tab-empty')).toBeVisible();
await expect(page.locator('.tab-empty')).toContainText('No projects match your search');
await expect(homeDesignCard(page, 'Searchable Prototype')).toHaveCount(0);
await search.fill('');
await expect(homeDesignCard(page, 'Searchable Prototype')).toBeVisible();
});
test('[P2] projects grid overflow menu closes on outside click and Escape', async ({ page }) => {
const projects = [
makeProjectsTabProject({
id: 'proj-menu-1',
name: 'Menu Close Project',
createdAt: Date.now() - 10_000,
updatedAt: Date.now() - 5_000,
}),
];
await page.route('**/api/projects', async (route) => {
if (route.request().method() === 'GET') {
await route.fulfill({ json: { projects } });
return;
}
await route.continue();
});
await page.route('**/api/live-artifacts?projectId=*', async (route) => {
await route.fulfill({ json: { liveArtifacts: [] } });
});
await stubCatalogsEmpty(page);
await page.goto('/projects');
await expectDesignsView(page);
const card = homeDesignCard(page, 'Menu Close Project');
await card.hover();
await card.getByRole('button', { name: /more actions/i }).click();
const menu = page.locator('.design-card-menu');
await expect(menu).toBeVisible();
await page.locator('.tab-panel-toolbar').click({ position: { x: 8, y: 8 } });
await expect(menu).toHaveCount(0);
await card.hover();
await card.getByRole('button', { name: /more actions/i }).click();
await expect(menu).toBeVisible();
await page.keyboard.press('Escape');
await expect(menu).toHaveCount(0);
});
test('[P2] projects kanban view groups cards into status columns', async ({ page }) => {
const now = Date.now();
const projects = [
makeProjectsTabProject({
id: 'proj-not-started',
name: 'Not Started Card',
createdAt: now - 50_000,
updatedAt: now - 45_000,
status: { value: 'not_started' },
}),
makeProjectsTabProject({
id: 'proj-running',
name: 'Running Card',
createdAt: now - 40_000,
updatedAt: now - 35_000,
status: { value: 'running' },
}),
makeProjectsTabProject({
id: 'proj-awaiting',
name: 'Awaiting Input Card',
createdAt: now - 30_000,
updatedAt: now - 25_000,
status: { value: 'awaiting_input' },
}),
makeProjectsTabProject({
id: 'proj-succeeded',
name: 'Succeeded Card',
createdAt: now - 20_000,
updatedAt: now - 15_000,
status: { value: 'succeeded' },
}),
makeProjectsTabProject({
id: 'proj-failed',
name: 'Failed Card',
createdAt: now - 10_000,
updatedAt: now - 5_000,
status: { value: 'failed' },
}),
];
await page.route('**/api/projects', async (route) => {
if (route.request().method() === 'GET') {
await route.fulfill({ json: { projects } });
return;
}
await route.continue();
});
await page.route('**/api/live-artifacts?projectId=*', async (route) => {
await route.fulfill({ json: { liveArtifacts: [] } });
});
await stubCatalogsEmpty(page);
await page.goto('/projects');
await expectDesignsView(page);
await page.getByTestId('designs-view-kanban').click();
await expect(page.locator('.design-kanban-board')).toBeVisible();
await expect(page.locator('.design-kanban-card.status-not_started')).toHaveCount(1);
await expect(page.locator('.design-kanban-card.status-running')).toHaveCount(1);
await expect(page.locator('.design-kanban-card.status-awaiting_input')).toHaveCount(1);
await expect(page.locator('.design-kanban-card.status-succeeded')).toHaveCount(1);
await expect(page.locator('.design-kanban-card.status-failed')).toHaveCount(1);
const kanbanColumns = page.locator('.design-kanban-col');
await expect(kanbanColumns).toHaveCount(7);
await expect(
kanbanColumns.filter({ hasText: 'Incomplete' }).locator('.design-kanban-empty'),
).toHaveCount(1);
await expect(
kanbanColumns.filter({ hasText: 'Canceled' }).locator('.design-kanban-empty'),
).toHaveCount(1);
await expect(page.locator('.design-kanban-empty')).toHaveCount(2);
await expect(page.locator('.design-kanban-card.status-running')).toContainText('Running Card');
await expect(page.locator('.design-kanban-card.status-awaiting_input')).toContainText(
'Awaiting Input Card',
);
await expect(page.locator('.design-kanban-card.status-succeeded')).toContainText(
'Succeeded Card',
);
});
test('[P1] projects page shows live artifact cards, supports search, and opens the live artifact project', async ({ page }) => {
const liveProject = makeProjectsTabProject({
id: 'proj-live',
name: 'Orbit Daily Digest',
createdAt: Date.now() - 60_000,
updatedAt: Date.now() - 30_000,
skillId: 'live-artifact',
metadata: { kind: 'orbit', intent: 'live-artifact' },
status: { value: 'succeeded' },
});
const regularProject = makeProjectsTabProject({
id: 'proj-regular',
name: 'Regular Prototype',
createdAt: Date.now() - 120_000,
updatedAt: Date.now() - 90_000,
});
const liveArtifact = {
id: 'artifact-1',
projectId: 'proj-live',
title: 'Orbit Daily Digest — 2026-05-15',
slug: 'orbit-daily-digest',
status: 'ready',
refreshStatus: 'succeeded',
pinned: false,
hasDocument: true,
updatedAt: new Date(Date.now() - 20_000).toISOString(),
createdAt: new Date(Date.now() - 50_000).toISOString(),
preview: {
kind: 'rendered',
url: '',
},
};
await page.route('**/api/projects', async (route) => {
if (route.request().method() === 'GET') {
await route.fulfill({ json: { projects: [liveProject, regularProject] } });
return;
}
await route.continue();
});
await page.route('**/api/projects/proj-live', async (route) => {
await route.fulfill({ json: { project: liveProject } });
});
await page.route('**/api/projects/proj-live/files', async (route) => {
await route.fulfill({ json: { files: [] } });
});
await page.route('**/api/live-artifacts?projectId=*', async (route) => {
const url = new URL(route.request().url());
const projectId = url.searchParams.get('projectId');
await route.fulfill({
json: {
liveArtifacts: projectId === 'proj-live' ? [liveArtifact] : [],
},
});
});
await page.route('**/api/live-artifacts/artifact-1', async (route) => {
await route.fulfill({ json: { liveArtifact } });
});
await page.route('**/api/live-artifacts/artifact-1/refreshes?projectId=*', async (route) => {
await route.fulfill({ json: { refreshes: [] } });
});
await page.route('**/api/live-artifacts/artifact-1/preview?projectId=*', async (route) => {
await route.fulfill({
status: 200,
headers: { 'content-type': 'text/html' },
body: '<!doctype html><html><body><h1>Orbit Daily Digest</h1></body></html>',
});
});
await stubCatalogsEmpty(page);
await page.goto('/projects');
await expectDesignsView(page);
const liveCard = page.locator('.live-artifact-card', {
has: page.locator('.design-card-name', { hasText: 'Orbit Daily Digest' }),
});
await expect(liveCard).toBeVisible();
await expect(liveCard).toContainText(/Live Artifact/i);
await expect(liveCard).toContainText(/LIVE|Refreshed/i);
const search = page.locator('.tab-panel-toolbar .toolbar-search input');
await search.fill('digest');
await expect(liveCard).toBeVisible();
await expect(homeDesignCard(page, 'Regular Prototype')).toHaveCount(0);
await liveCard.click();
await expect(page).toHaveURL(/\/projects\/proj-live\/files\/live%3Aartifact-1$/);
await expect(page.getByTestId('workspace-tabs-dropdown-trigger')).toContainText('Orbit Daily Digest');
});
test('[P2] General settings updates the custom companion draft', async ({ page }) => {
await seedAdoptedPet(page);
await page.route('**/api/codex-pets', async (route) => {
await route.fulfill({ json: { pets: [], rootDir: '' } });
});
await page.goto('/');
const dialog = await openSettingsDialog(page);
await dialog.getByRole('button', { name: /^General$/i }).click();
await expect(dialog.getByRole('heading', { level: 3, name: 'Pets' })).toBeVisible();
await dialog.getByRole('tab', { name: 'Custom' }).click();
const customPanel = dialog.locator('.pet-custom');
await expect(customPanel).toBeVisible();
await customPanel.getByLabel('Name').fill('QA Turtle');
await customPanel.getByLabel('Glyph').fill('🐢');
await customPanel.getByLabel('Greeting').fill('Shell yeah, tests are green.');
await expect(customPanel.getByText('QA Turtle')).toBeVisible();
await expect(customPanel.getByText('Shell yeah, tests are green.')).toBeVisible();
await dialog.getByRole('button', { name: 'Back to home', exact: true }).click();
await expect(dialog).toHaveCount(0);
});
async function createProject(
page: Page,
projectName: string,
options: { headers?: Readonly<Record<string, string>> } = {},
) {
const response = await createProjectViaApi(page, projectName, options);
const body = (await response.json()) as {
project: { id: string };
conversationId: string;
};
await page.goto(`/projects/${body.project.id}/conversations/${body.conversationId}`);
}
async function createProjectViaApi(
page: Page,
projectName: string,
options: { headers?: Readonly<Record<string, string>> } = {},
) {
// The Playwright suite fixture waits on daemon `/api/health` before handing
// out a worker. Project create is therefore a single-shot completion signal
// (the HTTP response), not a call-site retry loop over an unknown race.
const response = await page.request.post('/api/projects', {
timeout: 15_000,
...(options.headers ? { headers: { ...options.headers } } : {}),
data: {
id: `project-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
name: projectName,
skillId: null,
designSystemId: null,
metadata: {
kind: 'prototype',
nameSource: 'user',
},
},
});
expect(
response.ok(),
`create project "${projectName}": ${await response.text()}`,
).toBeTruthy();
return response;
}
async function seedProjectWithAssistantCompletion(
page: Page,
): Promise<{
projectId: string;
conversationId: string;
assistantMessageId: string;
assistantText: string;
}> {
const projectId = `assistant-actions-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const projectResponse = await page.request.post('/api/projects', {
data: {
id: projectId,
name: 'Assistant Completion Actions',
skillId: null,
designSystemId: null,
metadata: {
kind: 'prototype',
nameSource: 'user',
},
},
});
expect(projectResponse.ok(), `create project: ${await projectResponse.text()}`).toBeTruthy();
const { conversationId } = (await projectResponse.json()) as { conversationId: string };
const fileResponse = await page.request.post(`/api/projects/${projectId}/files`, {
data: {
name: 'index.html',
content: '<!doctype html><html><body><main><h1>Assistant actions preview</h1></main></body></html>',
artifactManifest: {
version: 1,
kind: 'html',
title: 'index.html',
entry: 'index.html',
renderer: 'html',
exports: ['html'],
},
},
});
expect(fileResponse.ok(), `seed index.html: ${await fileResponse.text()}`).toBeTruthy();
const createdAt = Date.now() - 2_000;
const userMessageId = `u-${projectId}`;
const userResponse = await page.request.put(
`/api/projects/${projectId}/conversations/${conversationId}/messages/${userMessageId}`,
{
data: {
id: userMessageId,
role: 'user',
content: 'Create a tiny prototype.',
createdAt,
},
},
);
expect(userResponse.ok(), `seed user message: ${await userResponse.text()}`).toBeTruthy();
const assistantMessageId = `a-${projectId}`;
const assistantText = 'Assistant completion actions fixture.\n\nGenerated `index.html` for this turn.';
const assistantResponse = await page.request.put(
`/api/projects/${projectId}/conversations/${conversationId}/messages/${assistantMessageId}`,
{
data: {
id: assistantMessageId,
role: 'assistant',
content: assistantText,
runStatus: 'succeeded',
startedAt: createdAt + 500,
endedAt: createdAt + 1_500,
events: [
{ kind: 'text', text: assistantText },
],
createdAt: createdAt + 1_000,
},
},
);
expect(assistantResponse.ok(), `seed assistant message: ${await assistantResponse.text()}`).toBeTruthy();
return { projectId, conversationId, assistantMessageId, assistantText };
}
async function seedProjectWithRepeatedArtifactCards(
page: Page,
): Promise<{ projectId: string; conversationId: string }> {
const projectId = `repeated-artifact-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const projectResponse = await page.request.post('/api/projects', {
data: {
id: projectId,
name: 'Repeated Artifact Card Anchors',
skillId: null,
designSystemId: null,
metadata: { kind: 'prototype', nameSource: 'user' },
},
});
expect(projectResponse.ok(), `create project: ${await projectResponse.text()}`).toBeTruthy();
const { conversationId } = (await projectResponse.json()) as { conversationId: string };
const fileName = 'index.html';
const fileContent = '<!doctype html><html><body><main><h1>Repeated artifact anchors</h1></main></body></html>';
const fileResponse = await page.request.post(`/api/projects/${projectId}/files`, {
data: {
name: fileName,
content: fileContent,
artifactManifest: {
version: 1,
kind: 'html',
title: fileName,
entry: fileName,
renderer: 'html',
exports: ['html'],
},
},
});
expect(fileResponse.ok(), `seed ${fileName}: ${await fileResponse.text()}`).toBeTruthy();
const startedAt = Date.now() - 4_000;
const producedFile = {
name: fileName,
path: fileName,
localPath: `/project/${projectId}/${fileName}`,
type: 'file',
size: fileContent.length,
mtime: startedAt + 1_000,
kind: 'html',
mime: 'text/html',
};
for (let turn = 0; turn < 2; turn += 1) {
const userMessageId = `repeated-artifact-user-${turn}-${projectId}`;
const assistantMessageId = `repeated-artifact-assistant-${turn}-${projectId}`;
const turnStartedAt = startedAt + turn * 2_000;
const userResponse = await page.request.put(
`/api/projects/${projectId}/conversations/${conversationId}/messages/${userMessageId}`,
{
data: {
id: userMessageId,
role: 'user',
content: `Generate ${fileName}, turn ${turn + 1}.`,
createdAt: turnStartedAt,
},
},
);
expect(userResponse.ok(), `seed user turn ${turn + 1}: ${await userResponse.text()}`).toBeTruthy();
const toolUseId = `write-index-${turn}`;
const assistantResponse = await page.request.put(
`/api/projects/${projectId}/conversations/${conversationId}/messages/${assistantMessageId}`,
{
data: {
id: assistantMessageId,
role: 'assistant',
content: `Generated ${fileName}, turn ${turn + 1}.`,
runStatus: 'succeeded',
startedAt: turnStartedAt + 200,
endedAt: turnStartedAt + 1_200,
events: [
{
kind: 'tool_use',
id: toolUseId,
name: 'Write',
input: { file_path: producedFile.localPath, content: fileContent },
},
{ kind: 'tool_result', toolUseId, content: 'ok', isError: false },
{ kind: 'text', text: `Generated ${fileName}, turn ${turn + 1}.` },
{ kind: 'artifact_focus', show: [fileName] },
],
producedFiles: [producedFile],
createdAt: turnStartedAt + 1_000,
},
},
);
expect(assistantResponse.ok(), `seed assistant turn ${turn + 1}: ${await assistantResponse.text()}`).toBeTruthy();
}
return { projectId, conversationId };
}
async function seedProjectWithLargeAssistantHistory(
page: Page,
): Promise<{
projectId: string;
conversationId: string;
expectedContents: string[];
}> {
const projectId = `assistant-large-fork-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const projectResponse = await page.request.post('/api/projects', {
data: {
id: projectId,
name: 'Large Conversation Fork',
skillId: null,
designSystemId: null,
metadata: {
kind: 'prototype',
nameSource: 'user',
},
},
});
expect(projectResponse.ok(), `create project: ${await projectResponse.text()}`).toBeTruthy();
const { conversationId } = (await projectResponse.json()) as { conversationId: string };
const expectedContents: string[] = [];
for (let index = 1; index <= 3; index += 1) {
const userMessageId = `large-user-${index}`;
const userContent = `Large fork request ${index}`;
const userResponse = await page.request.put(
`/api/projects/${projectId}/conversations/${conversationId}/messages/${userMessageId}`,
{
data: {
id: userMessageId,
role: 'user',
content: userContent,
createdAt: Date.now() + index * 2,
},
},
);
expect(userResponse.ok(), `seed user ${index}: ${await userResponse.text()}`).toBeTruthy();
expectedContents.push(userContent);
const assistantMessageId = `large-assistant-${index}`;
const assistantContent = `Large fork point ${index}`;
const assistantResponse = await page.request.put(
`/api/projects/${projectId}/conversations/${conversationId}/messages/${assistantMessageId}`,
{
data: {
id: assistantMessageId,
role: 'assistant',
content: assistantContent,
runStatus: 'succeeded',
events: [{ kind: 'raw', line: 'x'.repeat(1_500_000) }],
createdAt: Date.now() + index * 2 + 1,
},
},
);
expect(
assistantResponse.ok(),
`seed assistant ${index}: ${await assistantResponse.text()}`,
).toBeTruthy();
expectedContents.push(assistantContent);
}
return { projectId, conversationId, expectedContents };
}
type ConversationHistoryFixture = {
id: string;
projectId: string;
title: string | null;
sessionMode: 'design' | 'ask' | 'plan';
messageCount: number;
createdAt: number;
updatedAt: number;
totalDurationMs?: number;
latestRun?: {
status: 'succeeded' | 'failed' | 'canceled';
durationMs?: number;
};
};
async function seedProjectConversationHistory(
page: Page,
): Promise<{
projectId: string;
conversations: ConversationHistoryFixture[];
}> {
const projectId = `conversation-history-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const projectResponse = await page.request.post('/api/projects', {
data: {
id: projectId,
name: 'Conversation History Coverage',
skillId: null,
designSystemId: null,
metadata: {
kind: 'prototype',
nameSource: 'user',
},
},
});
expect(projectResponse.ok(), `create project: ${await projectResponse.text()}`).toBeTruthy();
const { conversationId } = (await projectResponse.json()) as { conversationId: string };
const now = Date.now();
return {
projectId,
conversations: [
{
id: conversationId,
projectId,
title: 'Runway final polish',
sessionMode: 'design',
messageCount: 8,
createdAt: now - 90 * 60_000,
updatedAt: now - 30_000,
totalDurationMs: 342_000,
latestRun: {
status: 'succeeded',
durationMs: 330_000,
},
},
{
id: 'conv-font-audit',
projectId,
title: 'Font audit and brand pass',
sessionMode: 'design',
messageCount: 6,
createdAt: now - 80 * 60_000,
updatedAt: now - 2 * 60_000,
latestRun: {
status: 'succeeded',
durationMs: 1_140_000,
},
},
{
id: 'conv-slide-review',
projectId,
title: 'Slide review baseline',
sessionMode: 'ask',
messageCount: 6,
createdAt: now - 70 * 60_000,
updatedAt: now - 7 * 60_000,
},
],
};
}
async function routeConversationHistoryFixtures(
page: Page,
projectId: string,
initialConversations: ConversationHistoryFixture[],
) {
const conversations = [...initialConversations];
await page.route(`**/api/projects/${projectId}/conversations`, async (route) => {
if (route.request().method() === 'GET') {
await route.fulfill({ json: { conversations } });
return;
}
if (route.request().method() === 'POST') {
const now = Date.now();
const fresh: ConversationHistoryFixture = {
id: 'conv-new-history',
projectId,
title: null,
sessionMode: 'design',
messageCount: 0,
createdAt: now,
updatedAt: now,
};
conversations.unshift(fresh);
await route.fulfill({ json: { conversation: fresh } });
return;
}
await route.continue();
});
await page.route(`**/api/projects/${projectId}/conversations/*/messages`, async (route) => {
if (route.request().method() === 'GET') {
const conversationId = conversationIdFromMessagesApiPath(route.request().url());
const conversation = conversations.find((item) => item.id === conversationId);
const count = conversation?.messageCount ?? 0;
await route.fulfill({
json: {
messages: Array.from({ length: count }, (_, index) => ({
id: `${conversationId}-m-${index}`,
role: index % 2 === 0 ? 'user' : 'assistant',
content: `Conversation ${conversationId} message ${index + 1}`,
createdAt: (conversation?.createdAt ?? Date.now()) + index,
})),
},
});
return;
}
await route.continue();
});
}
function conversationIdFromMessagesApiPath(url: string): string {
const pathname = new URL(url).pathname;
const match = pathname.match(/\/conversations\/([^/]+)\/messages$/);
return match ? decodeURIComponent(match[1]!) : '';
}
async function expectDesignsView(page: Page) {
if (!/\/projects$/.test(new URL(page.url()).pathname)) {
// The rail's Projects destination went away in #5517; /projects is still a
// real route (Home's recent-projects "view all" is the in-product entry).
await page.goto('/projects', { waitUntil: 'domcontentloaded' });
}
await expect(page).toHaveURL(/\/projects$/);
await expect(page.locator('.design-grid, .design-kanban-board')).toBeVisible();
}
/**
* Opens the composer's agent/model popover.
*
* The popover is a one-decision surface: pick the model for the agent that is
* already active. Which CLI agent runs, the execution mode, PATH rescan and
* reasoning effort are configuration and live in Settings → Execution, so
* tests that need a different agent/mode seed it into the stored config rather
* than clicking through this popover.
*/
async function openComposerAgentMenu(page: Page): Promise<{ menu: Locator }> {
const composer = page.getByTestId('chat-composer');
await expect(composer).toBeVisible();
const trigger = composer.locator('.avatar-menu .avatar-agent-trigger');
await expect(trigger).toBeVisible();
await trigger.click();
const menu = page.locator('.avatar-popover[role="dialog"]');
await expect(menu).toBeVisible();
return { menu };
}
/** Picks a model from the popover's always-expanded radio list. */
async function pickComposerModel(page: Page, name: RegExp): Promise<void> {
const { menu } = await openComposerAgentMenu(page);
const list = menu.getByTestId('avatar-model-list');
await expect(list).toBeVisible({ timeout: 20_000 });
await list.getByRole('radio', { name }).click();
// Selecting a model dismisses the popover.
await expect(page.locator('.avatar-popover[role="dialog"]')).toHaveCount(0);
}
async function routeComposerPlusFixtures(page: Page) {
await page.route('**/api/connectors', async (route) => {
await route.fulfill({
json: {
connectors: [
{
id: 'figma',
name: 'Figma Connector',
provider: 'Composio',
category: 'Design',
status: 'connected',
tools: [],
},
],
},
});
});
await page.route('**/api/connectors/status', async (route) => {
await route.fulfill({
json: {
statuses: {
figma: { status: 'connected', accountLabel: 'Design Team' },
},
},
});
});
await page.route('**/api/connectors/discovery**', async (route) => {
await route.fulfill({ json: { connectors: [] } });
});
await page.route('**/api/plugins', async (route) => {
await route.fulfill({ json: { plugins: [COMPOSER_PLUS_PLUGIN] } });
});
await page.route('**/api/mcp/servers', async (route) => {
await route.fulfill({
json: {
servers: [
{
id: 'design-docs',
label: 'Design Docs MCP',
transport: 'stdio',
enabled: true,
command: 'npx',
},
],
templates: [],
},
});
});
}
async function expectWorkspaceReady(page: Page) {
await expect(page).toHaveURL(/\/projects\//);
await page.getByText('Loading OpenDesign…').waitFor({ state: 'hidden', timeout: T.long }).catch(() => {});
await dismissPrivacyDialog(page);
await expect(page.getByTestId('workspace-tabs-dropdown-trigger')).toBeVisible();
await expect(page.getByTestId('chat-composer')).toBeVisible();
await expect(page.getByTestId('chat-composer-input')).toBeVisible();
await expect(page.locator('.chat-loading-state')).toHaveCount(0, { timeout: T.medium });
await expect(page.getByTestId('file-workspace')).toBeVisible();
}
async function routeHandoffEditors(page: Page): Promise<void> {
await page.route('**/api/editors', async (route) => {
await route.fulfill({
json: {
platform: 'darwin',
editors: [
{
id: 'cursor',
label: 'Cursor',
icon: 'cursor',
available: true,
resolvedPath: '/Applications/Cursor.app',
platforms: ['darwin', 'win32', 'linux'],
},
{
id: 'finder',
label: 'Finder',
icon: 'finder',
available: true,
resolvedPath: '/System/Library/CoreServices/Finder.app',
platforms: ['darwin'],
},
],
},
});
});
}
async function openHandoffCliTab(page: Page): Promise<Locator> {
await page.getByRole('button', { name: 'Share', exact: true }).click();
const unifiedPopover = page.locator('.chrome-unified-popover:visible');
await unifiedPopover.getByRole('tab', { name: 'Send to...' }).click();
const menu = unifiedPopover.getByTestId('handoff-menu');
await expect(menu).toBeVisible();
await menu.getByRole('tab', { name: /^Copy for CLI$/ }).click();
return menu;
}
async function dismissPrivacyDialog(page: Page) {
const privacyRegion = page.getByRole('region', { name: /Help us improve OpenDesign/i });
if (await privacyRegion.isVisible().catch(() => false)) {
await privacyRegion.getByRole('button', { name: /I get it|not now|got it/i }).click();
await expect(privacyRegion).toBeHidden();
}
}
/**
* Renames a project through the switcher's row menu (K2 #8183): open the
* dropdown, ⋮ on the project's row, 重命名, type, Enter. The chat card carries
* no inline title to edit any more (OPEND-3128).
*/
async function renameProjectFromSwitcher(
page: Page,
currentName: string,
nextName: string,
) {
await page.getByTestId('workspace-tabs-dropdown-trigger').click();
const row = page
.locator('.workspace-tabs-dropdown__row')
.filter({ hasText: currentName })
.first();
await row.hover();
await row.getByTestId('workspace-tabs-dropdown-row-more').click();
await page
.getByTestId('workspace-tabs-dropdown-row-menu')
.getByRole('menuitem', { name: 'Rename' })
.click();
const input = page.getByRole('textbox', { name: 'Rename' });
await input.fill(nextName);
await input.press('Enter');
await page.locator('.workspace-tabs-dropdown__backdrop').click({ position: { x: 4, y: 4 } });
}
async function uploadTinyHtml(
page: Page,
name: string,
content: string,
options: { headers?: Readonly<Record<string, string>> } = {},
): Promise<string> {
await page.getByTestId('design-files-upload-input').setInputFiles({
name,
mimeType: 'text/html',
buffer: Buffer.from(content),
});
const { projectId } = getProjectContextFromUrl(page);
let uploadedName = '';
await expect
.poll(async () => {
const files = await listProjectFiles(page, projectId, options);
uploadedName = files.find((file) => file.name.endsWith(name))?.name ?? '';
return uploadedName;
})
.not.toBe('');
await expect(tabBySuffix(page, uploadedName)).toBeVisible();
return uploadedName;
}
async function uploadTinyPng(
page: Page,
name: string,
): Promise<string> {
const pngBytes = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO5W6McAAAAASUVORK5CYII=',
'base64',
);
await page.getByTestId('design-files-upload-input').setInputFiles({
name,
mimeType: 'image/png',
buffer: pngBytes,
});
await expect(tabBySuffix(page, name)).toBeVisible();
const { projectId } = getProjectContextFromUrl(page);
const files = await listProjectFiles(page, projectId);
const uploaded = files.find((file) => file.name.endsWith(name));
expect(uploaded?.name).toBeTruthy();
return uploaded!.name;
}
async function openUploadedHtmlArtifactPreview(page: Page, uploadedName: string) {
await openAllProjectFiles(page);
const fileRow = rowByFileName(page, uploadedName);
await expect(fileRow).toBeVisible();
// #5517 deleted the preview card and its Open button: the row's primary
// target opens the artifact in a workspace tab on a single click.
await fileRow.getByRole('button').first().click();
await expect(tabBySuffix(page, uploadedName)).toHaveAttribute('aria-selected', 'true');
}
function tabBySuffix(page: Page, name: string): Locator {
return page.getByRole('tab', { name: new RegExp(`${escapeRegExp(name)}(?:\\s+Close tab)?$`, 'i') });
}
function rowByFileName(page: Page, name: string): Locator {
return page.getByTestId(`design-file-row-${name}`);
}
function menuByFileName(page: Page, name: string): Locator {
return page.getByTestId(`design-file-menu-${name}`);
}
function homeDesignCard(page: Page, name: string): Locator {
return page.locator('.design-card', {
has: page.locator('.design-card-name', {
hasText: new RegExp(`^${escapeRegExp(name)}$`),
}),
});
}
async function seedAdoptedPet(page: Page) {
await page.addInitScript((key) => {
window.localStorage.setItem(
key,
JSON.stringify({
mode: 'daemon',
apiKey: '',
baseUrl: 'https://api.anthropic.com',
model: 'default',
agentId: 'codex',
skillId: null,
designSystemId: null,
onboardingCompleted: true,
agentModels: { codex: { model: 'default' } },
pet: {
adopted: true,
enabled: true,
petId: 'custom',
custom: {
name: 'Original Buddy',
glyph: '🦄',
accent: '#c96442',
greeting: 'Ready to pair.',
},
},
}),
);
}, STORAGE_KEY);
}
async function fetchCurrentProject(page: Page) {
const { projectId } = getProjectContextFromUrl(page);
return fetchProjectById(page, projectId);
}
async function fetchProjectById(page: Page, projectId: string) {
const response = await page.request.get(`/api/projects/${projectId}`);
expect(response.ok()).toBeTruthy();
const body = (await response.json()) as {
project: {
id?: string;
name: string;
designSystemId: string | null;
metadata?: {
inspirationDesignSystemIds?: string[];
};
};
};
return body.project;
}
async function listProjectsFromApi(page: Page) {
const response = await page.request.get('/api/projects');
expect(response.ok()).toBeTruthy();
const body = (await response.json()) as {
projects: Array<{ id: string; name: string }>;
};
return body.projects;
}
async function listProjectFiles(
page: Page,
projectId: string,
options: { headers?: Readonly<Record<string, string>> } = {},
) {
const response = await page.request.get(
`/api/projects/${projectId}/files`,
options.headers ? { headers: { ...options.headers } } : undefined,
);
expect(response.ok()).toBeTruthy();
const body = (await response.json()) as { files: Array<{ name: string }> };
return body.files;
}
async function mockWritablePersonalProjectScope(page: Page) {
await mockAmrPersonalWorkspace(page);
await page.route('**/api/projects/*/workspace-scope', async (route) => {
const projectId = getProjectIdFromApiPath(route.request().url());
await route.fulfill({
json: {
scope: {
kind: 'personal',
projectId,
workspaceId: AMR_PERSONAL_WORKSPACE_CONTEXT.workspaceId,
visibility: 'personal',
context: AMR_PERSONAL_WORKSPACE_CONTEXT,
},
},
});
});
}
function isCreateProjectRequest(request: Request): boolean {
const url = new URL(request.url());
return url.pathname === '/api/projects' && request.method() === 'POST';
}
function getProjectContextFromUrl(page: Page) {
const url = new URL(page.url());
const [, projectId, conversationId] = url.pathname.match(
/\/projects\/([^/]+)(?:\/conversations\/([^/]+))?/,
) ?? [];
if (!projectId) throw new Error(`unexpected project route: ${url.pathname}`);
return { projectId, conversationId };
}
function getProjectIdFromApiPath(rawUrl: string) {
const url = new URL(rawUrl);
const [, projectId] = url.pathname.match(/\/api\/projects\/([^/]+)/) ?? [];
if (!projectId) throw new Error(`unexpected project api path: ${url.pathname}`);
return projectId;
}
// Share opens straight onto the link/asset-shaped rows — share link, share
// page, deploy targets, save-as-template. These used to live under the old
// popover's "Export" tab; the split moved them to Share and left Export as a
// pure file-format menu, so the callers below take the Share door now. The
// popover shell is still shared between the two, so the locator is unchanged.
async function openShareMenu(page: Page) {
await page.getByRole('button', { name: /^Share$/i }).click();
const menu = page.locator('.share-menu-popover[role="menu"]');
await expect(menu).toBeVisible();
return menu;
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function makeProjectsTabProject({
id,
name,
createdAt,
updatedAt,
skillId = null,
metadata = { kind: 'prototype' as const },
status = { value: 'succeeded' as const },
}: {
id: string;
name: string;
createdAt: number;
updatedAt: number;
skillId?: string | null;
metadata?: Record<string, unknown>;
status?: { value: string };
}) {
return {
id,
name,
createdAt,
updatedAt,
skillId,
designSystemId: null,
pendingPrompt: '',
customInstructions: null,
metadata,
status,
};
}
function skillSummary(
id: string,
name: string,
mode: 'prototype' | 'deck' | 'image',
surface: 'web' | 'image',
defaultFor: string[],
) {
return {
id,
name,
description: `${name} for tab switching coverage.`,
triggers: [],
mode,
surface,
platform: 'desktop',
scenario: 'qa',
previewType: 'html',
designSystemRequired: mode !== 'image',
defaultFor,
upstream: null,
featured: null,
fidelity: null,
speakerNotes: null,
animations: null,
hasBody: true,
examplePrompt: '',
};
}