mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
* 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>
1733 lines
67 KiB
TypeScript
1733 lines
67 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||
import { installMockOpenDesignHost } from '@open-design/host/testing';
|
||
import {
|
||
archiveFilenameFrom,
|
||
archiveRootFromFilePath,
|
||
buildDesignHandoffContent,
|
||
buildDesignManifestContent,
|
||
downloadImageDataUrl,
|
||
buildSandboxedPreviewDocument,
|
||
downloadDesignSystemArchive,
|
||
downloadProjectArchive,
|
||
exportAsImage,
|
||
exportAsMd,
|
||
exportAsPdf,
|
||
exportProjectImageDataUrl,
|
||
isUsablePrintSize,
|
||
reportPrintSizeWhenStable,
|
||
exportProjectAsHtml,
|
||
exportProjectAsPdf,
|
||
exportProjectAsPptx,
|
||
exportProjectAsZip,
|
||
openSandboxedPreviewInNewTab,
|
||
prepareImageExportTarget,
|
||
planDeckImageCapture,
|
||
requestPreviewSnapshot,
|
||
sourceLooksLikeExportableDeck,
|
||
sourceLooksLikeNavigableDeck,
|
||
} from '../../src/runtime/exports';
|
||
import { workspaceContextFixture } from '../helpers/workspace-context';
|
||
|
||
describe('planDeckImageCapture (#4604 current-slide capture for runtime decks)', () => {
|
||
it('whole-deck capture renders off-screen with no index (stitch all)', () => {
|
||
expect(planDeckImageCapture({ deck: true, wholeDeck: true, trackedActive: 3 })).toEqual({
|
||
useOffscreen: true,
|
||
index: undefined,
|
||
});
|
||
});
|
||
|
||
it('Export-as-image of an ordinary page renders the whole page off-screen', () => {
|
||
expect(planDeckImageCapture({ deck: false, wholeDeck: true, trackedActive: null })).toEqual({
|
||
useOffscreen: true,
|
||
index: undefined,
|
||
});
|
||
});
|
||
|
||
it('current-view capture of an ordinary page stays viewport-based (host snapshot)', () => {
|
||
// Copy screenshot / captureViewport annotation must reflect what the user is
|
||
// looking at, NOT an off-screen full-page render of the whole document.
|
||
expect(planDeckImageCapture({ deck: false, wholeDeck: false, trackedActive: null })).toEqual({
|
||
useOffscreen: false,
|
||
index: undefined,
|
||
});
|
||
});
|
||
|
||
it('tracked deck current-slide renders off-screen at the active index', () => {
|
||
expect(planDeckImageCapture({ deck: true, wholeDeck: false, trackedActive: 4 })).toEqual({
|
||
useOffscreen: true,
|
||
index: 4,
|
||
});
|
||
});
|
||
|
||
it('runtime-managed deck (no tracked active) skips off-screen → host snapshot, NOT index 0', () => {
|
||
// The viewer doesn't track a deck-stage / data-screen-label deck's active
|
||
// slide, so a current-slide capture must use the visible host snapshot rather
|
||
// than off-screen-rendering slide 0.
|
||
expect(planDeckImageCapture({ deck: true, wholeDeck: false, trackedActive: null })).toEqual({
|
||
useOffscreen: false,
|
||
index: undefined,
|
||
});
|
||
});
|
||
});
|
||
|
||
function mockResponse(headers: Record<string, string>): Response {
|
||
return { headers: new Headers(headers) } as Response;
|
||
}
|
||
|
||
describe('sourceLooksLikeExportableDeck (#4604 horizontal deck export)', () => {
|
||
// Runtime-managed decks (`<deck-stage>` web component with slotted
|
||
// `<section data-screen-label>` children) carry NO literal class="slide", so
|
||
// the viewer's `.slide`-only nav heuristic misses them and export would force a
|
||
// single page-mode capture of slide 1. This broader signal recognizes them so
|
||
// image/PDF capture every slide.
|
||
it('detects a <deck-stage> runtime deck (no class="slide")', () => {
|
||
const src =
|
||
'<deck-stage width="1920" height="1080">' +
|
||
'<section class="s-cover" data-screen-label="01 Cover">A</section>' +
|
||
'<section class="s-grid" data-screen-label="02 Grid">B</section>' +
|
||
'</deck-stage>';
|
||
expect(sourceLooksLikeExportableDeck(src)).toBe(true);
|
||
});
|
||
|
||
it('detects data-screen-label slide surfaces on their own', () => {
|
||
expect(
|
||
sourceLooksLikeExportableDeck('<section data-screen-label="01 Intro">x</section>'),
|
||
).toBe(true);
|
||
});
|
||
|
||
it('detects explicit deck slide classes, but not plain .slide', () => {
|
||
expect(sourceLooksLikeExportableDeck('<div class="slide">x</div>')).toBe(false);
|
||
expect(sourceLooksLikeExportableDeck('<section class="slide">x</section>')).toBe(false);
|
||
expect(sourceLooksLikeExportableDeck('<div class="s-cover deck-slide">x</div>')).toBe(true);
|
||
expect(sourceLooksLikeExportableDeck('<div class="ppt-slide">x</div>')).toBe(true);
|
||
});
|
||
|
||
it('detects legacy html-ppt slide structure without treating every .slide as a deck', () => {
|
||
expect(
|
||
sourceLooksLikeExportableDeck('<section class="slide" data-title="Cover">x</section>'),
|
||
).toBe(true);
|
||
expect(
|
||
sourceLooksLikeExportableDeck('<section data-title="Cover" class="slide is-active">x</section>'),
|
||
).toBe(true);
|
||
expect(
|
||
sourceLooksLikeExportableDeck('<div class="deck"><section class="slide">x</section></div>'),
|
||
).toBe(true);
|
||
});
|
||
|
||
it('does NOT treat an ordinary page as a deck', () => {
|
||
expect(
|
||
sourceLooksLikeExportableDeck('<main><h1>Landing</h1><p>Hello</p></main>'),
|
||
).toBe(false);
|
||
// `slideshow` is not a `slide` class token.
|
||
expect(sourceLooksLikeExportableDeck('<div class="slideshow">x</div>')).toBe(false);
|
||
});
|
||
|
||
it('returns false for empty / nullish source', () => {
|
||
expect(sourceLooksLikeExportableDeck('')).toBe(false);
|
||
expect(sourceLooksLikeExportableDeck(null)).toBe(false);
|
||
expect(sourceLooksLikeExportableDeck(undefined)).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe('sourceLooksLikeNavigableDeck', () => {
|
||
it('does not turn ordinary prototype annotations into deck chrome', () => {
|
||
expect(
|
||
sourceLooksLikeNavigableDeck('<h1 data-screen-label="Hero title">Prototype</h1>'),
|
||
).toBe(false);
|
||
expect(sourceLooksLikeNavigableDeck(
|
||
'<main><h1 data-screen-label="Hero">Prototype</h1>' +
|
||
'<button data-screen-label="CTA">Buy</button></main>',
|
||
)).toBe(false);
|
||
expect(sourceLooksLikeNavigableDeck(
|
||
'<main><section data-screen-label="01 Hero">One</section>' +
|
||
'<div><section data-screen-label="02 CTA">Two</section></div></main>',
|
||
)).toBe(false);
|
||
});
|
||
|
||
it('ignores deck-shaped text inside report comments and scripts', () => {
|
||
expect(sourceLooksLikeNavigableDeck(
|
||
'<main class="doc"><h1>Annual report</h1></main>' +
|
||
'<!-- print support waits for a <deck-stage> when one exists -->' +
|
||
'<script>const example = "<deck-stage></deck-stage>";</script>',
|
||
)).toBe(false);
|
||
});
|
||
|
||
it('keeps explicit and multi-screen persisted decks navigable', () => {
|
||
expect(sourceLooksLikeNavigableDeck(
|
||
'<deck-stage><section data-screen-label="Cover">A</section></deck-stage>',
|
||
)).toBe(true);
|
||
expect(sourceLooksLikeNavigableDeck(
|
||
'<main><section data-screen-label="01 Cover">A</section>' +
|
||
'<section data-screen-label="02 Agenda">B</section></main>',
|
||
)).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe('isUsablePrintSize (#4458)', () => {
|
||
// The print-ready handshake reports the artifact's own content size so the
|
||
// desktop bridge can size the PDF page to it. When that size is zero or
|
||
// invalid, the desktop path falls back to measuring the wrapper viewport,
|
||
// which (per inferPageSize's own docs) blanks artifacts whose visible
|
||
// content sits below the fold. Gating on a usable size prevents that.
|
||
it('treats positive finite dimensions as usable', () => {
|
||
expect(isUsablePrintSize(1440, 2000)).toBe(true);
|
||
expect(isUsablePrintSize(1, 1)).toBe(true);
|
||
});
|
||
|
||
it('rejects zero, negative, non-finite, or non-number dimensions', () => {
|
||
expect(isUsablePrintSize(0, 2000)).toBe(false);
|
||
expect(isUsablePrintSize(1440, 0)).toBe(false);
|
||
expect(isUsablePrintSize(-5, 100)).toBe(false);
|
||
expect(isUsablePrintSize(Number.NaN, 100)).toBe(false);
|
||
expect(isUsablePrintSize(Number.POSITIVE_INFINITY, 100)).toBe(false);
|
||
expect(isUsablePrintSize('1440' as unknown as number, 2000)).toBe(false);
|
||
expect(isUsablePrintSize(undefined as unknown as number, undefined as unknown as number)).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe('reportPrintSizeWhenStable (#4458)', () => {
|
||
it('reports only once the content has a usable size, polling animation frames', () => {
|
||
// Simulate content that has not finished layout: size is 0 for the first
|
||
// frames, then becomes positive once laid out. The handshake must not
|
||
// report the early zero size (which would blank the PDF).
|
||
const sizes = [
|
||
{ width: 0, height: 0 },
|
||
{ width: 0, height: 0 },
|
||
{ width: 1440, height: 2000 },
|
||
];
|
||
let call = 0;
|
||
const measure = (): { width: number; height: number } => sizes[Math.min(call++, sizes.length - 1)]!;
|
||
const reported: Array<{ width: number; height: number }> = [];
|
||
const raf = (cb: () => void): void => cb(); // run synchronously
|
||
reportPrintSizeWhenStable(measure, (s) => reported.push(s), 30, raf);
|
||
expect(reported).toEqual([{ width: 1440, height: 2000 }]);
|
||
});
|
||
|
||
it('reports the last measured size when frames are exhausted (genuinely empty content)', () => {
|
||
// A truly empty artifact never gains a usable size; rather than hang
|
||
// forever, report best-effort after the frame budget so the desktop
|
||
// path is not left waiting on the readiness handshake indefinitely.
|
||
const measure = (): { width: number; height: number } => ({ width: 0, height: 0 });
|
||
const reported: Array<{ width: number; height: number }> = [];
|
||
const raf = (cb: () => void): void => cb();
|
||
reportPrintSizeWhenStable(measure, (s) => reported.push(s), 3, raf);
|
||
expect(reported).toEqual([{ width: 0, height: 0 }]);
|
||
});
|
||
});
|
||
|
||
describe('injected print-ready parent cache script — runtime behavior (#4458)', () => {
|
||
// Issue #4458 calls out that the existing coverage only proves script
|
||
// *strings* are injected, never that the injected script *behaves*. These
|
||
// specs extract the real parent-cache <script> from a live export, run it,
|
||
// and drive it with postMessage to assert the runtime size gate: a usable
|
||
// size is cached for the desktop inferPageSize(); a zero or non-finite size
|
||
// is rejected so it cannot blank the page (viewport fallback) or poison it.
|
||
async function extractCacheScript(): Promise<{ body: string; nonce: string }> {
|
||
const printPdfMock = vi.fn().mockResolvedValue({ ok: true });
|
||
const restoreHost = installMockOpenDesignHost({ host: { pdf: { print: printPdfMock } } });
|
||
try {
|
||
await exportAsPdf('<div style="height:4000px">tall artifact</div>', 'Cache Eval');
|
||
} finally {
|
||
restoreHost();
|
||
}
|
||
const htmlArg = printPdfMock.mock.calls[0]![0] as string;
|
||
const match = /<script>(window\.__odPrintReady=false;[\s\S]*?)<\/script>/.exec(htmlArg);
|
||
if (!match) throw new Error('parent cache script not found in exported HTML');
|
||
const body = match[1]!;
|
||
const nonceMatch = /nonce===['"]([^'"]+)['"]/.exec(body);
|
||
if (!nonceMatch) throw new Error('nonce not found in parent cache script');
|
||
return { body, nonce: nonceMatch[1]! };
|
||
}
|
||
|
||
// This suite runs in the node environment (no DOM), so we drive the real
|
||
// injected script against a minimal fake `window`: a plain object with a
|
||
// message-listener registry. The script wires its handler through
|
||
// `window.addEventListener('message', …)`, so dispatching a message means
|
||
// invoking the registered handler with a `{ data, source }` event — exactly
|
||
// what a real `postMessage` delivers, including the source-identity check.
|
||
type FakeWindow = Record<string, unknown> & {
|
||
__odPrintReady?: unknown;
|
||
__odPrintSize?: unknown;
|
||
};
|
||
|
||
function loadCache(body: string): { win: FakeWindow; fire: (event: unknown) => void } {
|
||
const handlers: Array<(event: unknown) => void> = [];
|
||
const win: FakeWindow = {
|
||
addEventListener: (type: string, fn: (event: unknown) => void) => {
|
||
if (type === 'message') handlers.push(fn);
|
||
},
|
||
removeEventListener: () => undefined,
|
||
};
|
||
win.frames = [win];
|
||
// eslint-disable-next-line @typescript-eslint/no-implied-eval, no-new-func
|
||
new Function('window', body)(win);
|
||
return { win, fire: (event) => handlers.slice().forEach((h) => h(event)) };
|
||
}
|
||
|
||
function readyEvent(
|
||
nonce: string,
|
||
width: unknown,
|
||
height: unknown,
|
||
source: unknown,
|
||
): { data: unknown; source: unknown } {
|
||
return { data: { type: 'OD_PRINT_READY', nonce, width, height }, source };
|
||
}
|
||
|
||
it('caches a usable content size so the desktop bridge sizes the page to the artifact', async () => {
|
||
const { body, nonce } = await extractCacheScript();
|
||
const { win, fire } = loadCache(body);
|
||
fire(readyEvent(nonce, 1440, 2000, win));
|
||
expect(win.__odPrintReady).toBe(true);
|
||
expect(win.__odPrintSize).toEqual({ width: 1440, height: 2000 });
|
||
});
|
||
|
||
it('rejects a zero size so the page does not fall back to the wrapper viewport and blank', async () => {
|
||
const { body, nonce } = await extractCacheScript();
|
||
const { win, fire } = loadCache(body);
|
||
fire(readyEvent(nonce, 0, 0, win));
|
||
// Readiness still resolves (so the desktop bridge never hangs), but the
|
||
// size is withheld so inferPageSize cannot adopt a blank wrapper viewport.
|
||
expect(win.__odPrintReady).toBe(true);
|
||
expect(win.__odPrintSize).toBeNull();
|
||
});
|
||
|
||
it('rejects a non-finite size so Infinity cannot poison the page size', async () => {
|
||
const { body, nonce } = await extractCacheScript();
|
||
const { win, fire } = loadCache(body);
|
||
fire(readyEvent(nonce, Number.POSITIVE_INFINITY, 100, win));
|
||
expect(win.__odPrintSize).toBeNull();
|
||
});
|
||
|
||
it('ignores a print-ready message carrying the wrong nonce (anti-spoof)', async () => {
|
||
const { body } = await extractCacheScript();
|
||
const { win, fire } = loadCache(body);
|
||
fire(readyEvent('not-the-real-nonce', 1440, 2000, win));
|
||
expect(win.__odPrintReady).toBe(false);
|
||
expect(win.__odPrintSize).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe('archiveRootFromFilePath', () => {
|
||
it('returns the top-level directory name when present', () => {
|
||
expect(archiveRootFromFilePath('ui-design/index.html')).toBe('ui-design');
|
||
expect(archiveRootFromFilePath('ui-design/src/app.css')).toBe('ui-design');
|
||
});
|
||
|
||
it('returns empty for files at the project root', () => {
|
||
expect(archiveRootFromFilePath('index.html')).toBe('');
|
||
expect(archiveRootFromFilePath('README.md')).toBe('');
|
||
});
|
||
|
||
it('strips a leading slash before scanning', () => {
|
||
expect(archiveRootFromFilePath('/ui-design/index.html')).toBe('ui-design');
|
||
expect(archiveRootFromFilePath('//ui-design/index.html')).toBe('ui-design');
|
||
});
|
||
|
||
it('returns empty for empty/garbage input', () => {
|
||
expect(archiveRootFromFilePath('')).toBe('');
|
||
expect(archiveRootFromFilePath('/')).toBe('');
|
||
});
|
||
});
|
||
|
||
describe('archiveFilenameFrom', () => {
|
||
it('decodes the RFC 5987 UTF-8 filename* form (preserves multi-byte chars)', () => {
|
||
// 'café-design.zip' encoded — the é is a 2-byte UTF-8 sequence (%C3%A9),
|
||
// which is enough to fail under naive ASCII-only handling.
|
||
const resp = mockResponse({
|
||
'content-disposition':
|
||
"attachment; filename=\"project.zip\"; filename*=UTF-8''caf%C3%A9-design.zip",
|
||
});
|
||
expect(archiveFilenameFrom(resp, 'fallback', 'ui-design')).toBe('café-design.zip');
|
||
});
|
||
|
||
it('falls back to the legacy quoted filename= when filename* is absent', () => {
|
||
const resp = mockResponse({
|
||
'content-disposition': 'attachment; filename="ui-design.zip"',
|
||
});
|
||
expect(archiveFilenameFrom(resp, 'fallback', 'ui-design')).toBe('ui-design.zip');
|
||
});
|
||
|
||
it('falls back to the active root slug when the header is missing', () => {
|
||
const resp = mockResponse({});
|
||
expect(archiveFilenameFrom(resp, 'fallback-title', 'ui-design')).toBe('ui-design.zip');
|
||
});
|
||
|
||
it('falls back to the title slug when both header and root are absent', () => {
|
||
const resp = mockResponse({});
|
||
expect(archiveFilenameFrom(resp, 'My Artifact', '')).toBe('My-Artifact.zip');
|
||
});
|
||
|
||
it('falls through to the slug when filename* is malformed', () => {
|
||
// Truncated percent-escape — decodeURIComponent throws; we should not
|
||
// surface the exception, just fall back to the next strategy.
|
||
const resp = mockResponse({
|
||
'content-disposition': "attachment; filename*=UTF-8''%E9%9D",
|
||
});
|
||
expect(archiveFilenameFrom(resp, 'fallback', 'ui-design')).toBe('ui-design.zip');
|
||
});
|
||
});
|
||
|
||
describe('buildDesignHandoffContent', () => {
|
||
it('documents coding handoff and responsive verification expectations', () => {
|
||
const content = buildDesignHandoffContent({
|
||
title: 'Checkout Design',
|
||
entryFile: 'index.html',
|
||
files: ['index.html', 'src/app.css', 'src/app.js'],
|
||
});
|
||
|
||
expect(content).toContain('Checkout Design implementation handoff');
|
||
expect(content).toContain('Mobile compact: 360×800');
|
||
expect(content).toContain('Tablet portrait: 820×1180');
|
||
expect(content).toContain('Wide desktop: 1920×1080');
|
||
expect(content).toContain('`src/app.css`');
|
||
expect(content).toContain('visual system');
|
||
expect(content).toContain('Design fidelity contract');
|
||
expect(content).toContain('CJX-ready UX contract');
|
||
expect(content).toContain('DESIGN-MANIFEST.json');
|
||
expect(content).toContain('in-app modules/components');
|
||
expect(content).toContain('OS widgets are home-screen/lock-screen/quick-access surfaces');
|
||
expect(content).toContain('Color and brand contract');
|
||
expect(content).toContain('Do not introduce warm beige / cream / peach / pink / orange-brown background washes');
|
||
expect(content).toContain('Build product screens and domain-specific in-app modules');
|
||
});
|
||
|
||
it('builds a machine-readable design manifest for coding tools', () => {
|
||
const manifest = JSON.parse(buildDesignManifestContent({
|
||
title: 'Checkout Design',
|
||
entryFile: 'index.html',
|
||
files: ['index.html', 'src/app.css', 'src/app.js'],
|
||
}));
|
||
|
||
expect(manifest.schema).toBe('open-design.design-manifest.v1');
|
||
expect(manifest.entryFile).toBe('index.html');
|
||
expect(manifest.sourceFiles.css).toEqual(['src/app.css']);
|
||
expect(manifest.sourceFiles.scriptsAndComponents).toEqual(['src/app.js']);
|
||
expect(manifest.appModules.join(' ')).toContain('domain-specific in-app modules');
|
||
expect(manifest.osWidgets.join(' ')).toContain('home-screen');
|
||
expect(manifest.responsiveViewports).toContainEqual({
|
||
name: 'tablet-portrait',
|
||
width: 820,
|
||
height: 1180,
|
||
category: 'tablet',
|
||
mustAvoidHorizontalScroll: true,
|
||
});
|
||
expect(manifest.implementationChecklist.join(' ')).toContain('landing pages, in-app modules, and OS widgets');
|
||
});
|
||
|
||
it('does not classify plain home.html as a landing page in the manifest', () => {
|
||
const manifest = JSON.parse(buildDesignManifestContent({
|
||
title: 'Product App',
|
||
entryFile: 'home.html',
|
||
files: ['home.html', 'dashboard.html', 'marketing.html'],
|
||
}));
|
||
|
||
const screens = new Map(manifest.screens.map((screen: { file: string; role: string }) => [screen.file, screen.role]));
|
||
expect(screens.get('home.html')).not.toBe('landing-page');
|
||
expect(screens.get('marketing.html')).toBe('landing-page');
|
||
expect(screens.get('dashboard.html')).toBe('product-screen');
|
||
});
|
||
|
||
it('keeps frame wrapper HTML out of client export manifest screens', () => {
|
||
const manifest = JSON.parse(buildDesignManifestContent({
|
||
title: 'Framed App',
|
||
entryFile: 'index.html',
|
||
files: ['index.html', 'frames/iphone-15-pro.html', 'browser-chrome.html', 'src/app.css'],
|
||
}));
|
||
|
||
expect(manifest.sourceFiles.html).toEqual(['browser-chrome.html', 'frames/iphone-15-pro.html', 'index.html']);
|
||
expect(manifest.screens.map((screen: { file: string }) => screen.file)).toEqual(['index.html']);
|
||
});
|
||
|
||
it('normalizes a frame-wrapper active file to the implementable screen entry in manifest and handoff', () => {
|
||
const manifest = JSON.parse(buildDesignManifestContent({
|
||
title: 'Framed App',
|
||
entryFile: 'frames/iphone-15-pro.html',
|
||
files: ['index.html', 'landing.html', 'frames/iphone-15-pro.html', 'src/app.css'],
|
||
}));
|
||
const handoff = buildDesignHandoffContent({
|
||
title: 'Framed App',
|
||
entryFile: 'frames/iphone-15-pro.html',
|
||
files: ['index.html', 'landing.html', 'frames/iphone-15-pro.html', 'src/app.css'],
|
||
});
|
||
|
||
expect(manifest.entryFile).toBe('index.html');
|
||
expect(manifest.screens.map((screen: { file: string }) => screen.file)).toEqual(['index.html', 'landing.html']);
|
||
expect(handoff).toContain('Primary entry: `index.html`');
|
||
expect(handoff).toContain('Open `index.html` and `DESIGN-MANIFEST.json`');
|
||
expect(handoff).not.toContain('Primary entry: `frames/iphone-15-pro.html`');
|
||
});
|
||
|
||
it('keeps phone.html and iphone-upgrade.html as real screens when outside frames/ directory', () => {
|
||
// phone.html as a carrier storefront screen, iphone-upgrade.html as a
|
||
// product surface — neither should be silently dropped from screens just
|
||
// because the filename resembles a device name.
|
||
const manifest = JSON.parse(buildDesignManifestContent({
|
||
title: 'Carrier Storefront',
|
||
entryFile: 'phone.html',
|
||
files: ['phone.html', 'iphone-upgrade.html', 'frames/browser-shell.html', 'src/app.css'],
|
||
}));
|
||
|
||
const screenFiles = manifest.screens.map((screen: { file: string }) => screen.file);
|
||
expect(screenFiles).toContain('phone.html');
|
||
expect(screenFiles).toContain('iphone-upgrade.html');
|
||
// frame wrapper inside frames/ is still excluded
|
||
expect(screenFiles).not.toContain('frames/browser-shell.html');
|
||
// both real screens appear in sourceFiles.html
|
||
expect(manifest.sourceFiles.html).toContain('phone.html');
|
||
expect(manifest.sourceFiles.html).toContain('iphone-upgrade.html');
|
||
expect(manifest.sourceFiles.html).toContain('frames/browser-shell.html');
|
||
});
|
||
});
|
||
|
||
describe('exportProjectAsPdf', () => {
|
||
afterEach(() => {
|
||
vi.unstubAllGlobals();
|
||
vi.restoreAllMocks();
|
||
});
|
||
|
||
it.each([{}, { ok: 'true' }, null])('does not confirm saving from an invalid PDF acknowledgement %j', async body => {
|
||
const fallback = vi.fn();
|
||
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||
vi.stubGlobal('fetch', vi.fn(async () => Response.json(body)));
|
||
const result = await exportProjectAsPdf({ deck: false, fallbackPdf: fallback, filePath: 'index.html', projectId: 'proj-1', title: 'Page' });
|
||
expect(result).toBe('fallback');
|
||
expect(fallback).toHaveBeenCalledOnce();
|
||
});
|
||
it('uses the daemon desktop PDF export API before falling back to browser print', async () => {
|
||
const fallback = vi.fn();
|
||
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ ok: true }), { status: 200 })));
|
||
|
||
const result = await exportProjectAsPdf({
|
||
deck: true,
|
||
fallbackPdf: fallback,
|
||
filePath: 'deck/index.html',
|
||
projectId: 'proj-1',
|
||
title: 'Seed Deck',
|
||
});
|
||
|
||
expect(result).toBe('desktop');
|
||
expect(fallback).not.toHaveBeenCalled();
|
||
expect(fetch).toHaveBeenCalledWith('/api/projects/proj-1/export/pdf', {
|
||
body: JSON.stringify({ deck: true, fileName: 'deck/index.html', title: 'Seed Deck' }),
|
||
headers: { 'content-type': 'application/json' },
|
||
method: 'POST',
|
||
});
|
||
});
|
||
|
||
it('passes versionId to the daemon desktop PDF export API', async () => {
|
||
const fallback = vi.fn();
|
||
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ ok: true }), { status: 200 })));
|
||
|
||
const result = await exportProjectAsPdf({
|
||
deck: false,
|
||
fallbackPdf: fallback,
|
||
filePath: 'index.html',
|
||
projectId: 'proj-1',
|
||
title: 'Landing v1',
|
||
versionId: 'v1',
|
||
});
|
||
|
||
expect(result).toBe('desktop');
|
||
expect(fallback).not.toHaveBeenCalled();
|
||
expect(fetch).toHaveBeenCalledWith('/api/projects/proj-1/export/pdf', {
|
||
body: JSON.stringify({ deck: false, fileName: 'index.html', title: 'Landing v1', versionId: 'v1' }),
|
||
headers: { 'content-type': 'application/json' },
|
||
method: 'POST',
|
||
});
|
||
});
|
||
|
||
it('treats a canceled desktop PDF save dialog as a silent no-op', async () => {
|
||
const fallback = vi.fn();
|
||
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ ok: true, canceled: true }), { status: 200 })));
|
||
|
||
const result = await exportProjectAsPdf({
|
||
deck: true,
|
||
fallbackPdf: fallback,
|
||
filePath: 'deck/index.html',
|
||
projectId: 'proj-1',
|
||
title: 'Seed Deck',
|
||
});
|
||
|
||
expect(result).toBe('cancelled');
|
||
expect(fallback).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('falls back to browser print when the desktop PDF export API is unavailable', async () => {
|
||
const fallback = vi.fn();
|
||
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ error: { message: 'unavailable' } }), { status: 501 })));
|
||
|
||
const result = await exportProjectAsPdf({
|
||
deck: false,
|
||
fallbackPdf: fallback,
|
||
filePath: 'index.html',
|
||
projectId: 'proj-1',
|
||
title: 'Landing',
|
||
});
|
||
|
||
expect(result).toBe('fallback');
|
||
expect(fallback).toHaveBeenCalledTimes(1);
|
||
});
|
||
});
|
||
|
||
describe('exportProjectAsHtml', () => {
|
||
let capturedBlob: Blob | undefined;
|
||
let capturedFilename: string | undefined;
|
||
|
||
beforeEach(() => {
|
||
capturedBlob = undefined;
|
||
capturedFilename = undefined;
|
||
vi.stubGlobal('URL', {
|
||
createObjectURL: (blob: Blob) => {
|
||
capturedBlob = blob;
|
||
return 'blob:test';
|
||
},
|
||
revokeObjectURL: () => {},
|
||
});
|
||
vi.stubGlobal('document', {
|
||
createElement: () => {
|
||
const anchor = { href: '', click: () => {} } as { href: string; download?: string; click: () => void };
|
||
Object.defineProperty(anchor, 'download', {
|
||
set(value: string) {
|
||
capturedFilename = value;
|
||
},
|
||
get() {
|
||
return capturedFilename ?? '';
|
||
},
|
||
});
|
||
return anchor;
|
||
},
|
||
body: { appendChild: () => {}, removeChild: () => {} },
|
||
});
|
||
});
|
||
|
||
afterEach(() => {
|
||
vi.unstubAllGlobals();
|
||
vi.restoreAllMocks();
|
||
});
|
||
|
||
it('downloads daemon-inlined project HTML instead of the raw source body', async () => {
|
||
vi.stubGlobal('fetch', vi.fn(async () => new Response('<!doctype html><p>inlined</p>', {
|
||
headers: { 'content-type': 'text/html' },
|
||
status: 200,
|
||
})));
|
||
|
||
await exportProjectAsHtml({
|
||
projectId: 'proj 1',
|
||
filePath: 'screens/main page.html',
|
||
fallbackTitle: 'Main Page',
|
||
});
|
||
|
||
expect(fetch).toHaveBeenCalledWith('/api/projects/proj%201/export/html', {
|
||
method: 'POST',
|
||
headers: { 'content-type': 'application/json' },
|
||
body: JSON.stringify({ fileName: 'screens/main page.html', title: 'Main Page' }),
|
||
});
|
||
expect(capturedFilename).toBe('Main-Page.html');
|
||
expect(await capturedBlob!.text()).toBe('<!doctype html><p>inlined</p>');
|
||
});
|
||
|
||
it('passes versionId so the daemon can explicitly reject mixed-version dependencies', async () => {
|
||
vi.stubGlobal('fetch', vi.fn(async () => new Response('<!doctype html><p>version</p>', {
|
||
headers: { 'content-type': 'text/html' },
|
||
status: 200,
|
||
})));
|
||
|
||
await exportProjectAsHtml({
|
||
projectId: 'proj 1',
|
||
filePath: 'screens/main page.html',
|
||
fallbackTitle: 'Main Page v1',
|
||
versionId: 'v1',
|
||
});
|
||
|
||
expect(fetch).toHaveBeenCalledWith('/api/projects/proj%201/export/html', {
|
||
method: 'POST',
|
||
headers: { 'content-type': 'application/json' },
|
||
body: JSON.stringify({
|
||
fileName: 'screens/main page.html',
|
||
title: 'Main Page v1',
|
||
versionId: 'v1',
|
||
}),
|
||
});
|
||
expect(capturedFilename).toBe('Main-Page-v1.html');
|
||
expect(await capturedBlob!.text()).toBe('<!doctype html><p>version</p>');
|
||
});
|
||
|
||
it('surfaces structured daemon failures instead of downloading broken source HTML', async () => {
|
||
vi.stubGlobal('fetch', vi.fn(async () => Response.json({
|
||
error: { message: 'missing local dependency: assets/hero.png' },
|
||
}, { status: 422 })));
|
||
|
||
await expect(exportProjectAsHtml({
|
||
projectId: 'proj-1',
|
||
filePath: 'index.html',
|
||
fallbackTitle: 'Fallback',
|
||
})).rejects.toThrow('missing local dependency: assets/hero.png');
|
||
|
||
expect(capturedFilename).toBeUndefined();
|
||
expect(capturedBlob).toBeUndefined();
|
||
});
|
||
});
|
||
|
||
describe('exportProjectImageDataUrl', () => {
|
||
afterEach(() => {
|
||
vi.unstubAllGlobals();
|
||
vi.restoreAllMocks();
|
||
});
|
||
|
||
it('passes versionId to the daemon image export endpoint', async () => {
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn(async () => new Response(JSON.stringify({ error: { message: 'desktop only' } }), { status: 501 })),
|
||
);
|
||
|
||
const result = await exportProjectImageDataUrl({
|
||
projectId: 'proj 1',
|
||
fileName: 'screens/main page.html',
|
||
index: 2,
|
||
deck: false,
|
||
width: 390,
|
||
height: 844,
|
||
versionId: 'v1',
|
||
});
|
||
|
||
expect(result).toEqual({ ok: false, unavailable: true, reason: 'no-renderer' });
|
||
expect(fetch).toHaveBeenCalledWith('/api/projects/proj%201/export/image', {
|
||
method: 'POST',
|
||
headers: { 'content-type': 'application/json' },
|
||
body: JSON.stringify({
|
||
fileName: 'screens/main page.html',
|
||
index: 2,
|
||
deck: false,
|
||
width: 390,
|
||
height: 844,
|
||
versionId: 'v1',
|
||
}),
|
||
});
|
||
});
|
||
|
||
it('omits viewport dimensions when the caller uses renderer defaults', async () => {
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn(async () => new Response(JSON.stringify({ error: { message: 'desktop only' } }), { status: 501 })),
|
||
);
|
||
|
||
await exportProjectImageDataUrl({
|
||
projectId: 'proj-1',
|
||
fileName: 'index.html',
|
||
deck: false,
|
||
});
|
||
|
||
expect(fetch).toHaveBeenCalledWith('/api/projects/proj-1/export/image', {
|
||
method: 'POST',
|
||
headers: { 'content-type': 'application/json' },
|
||
body: JSON.stringify({ fileName: 'index.html', deck: false }),
|
||
});
|
||
});
|
||
});
|
||
|
||
describe('binary project/design-system downloads', () => {
|
||
let capturedBlob: Blob | undefined;
|
||
let capturedFilename: string | undefined;
|
||
|
||
beforeEach(() => {
|
||
capturedBlob = undefined;
|
||
capturedFilename = undefined;
|
||
vi.stubGlobal('URL', {
|
||
createObjectURL: (blob: Blob) => {
|
||
capturedBlob = blob;
|
||
return 'blob:test';
|
||
},
|
||
revokeObjectURL: () => {},
|
||
});
|
||
vi.stubGlobal('document', {
|
||
createElement: () => {
|
||
const anchor = { href: '', click: () => {} } as { href: string; download?: string; click: () => void };
|
||
Object.defineProperty(anchor, 'download', {
|
||
set(value: string) {
|
||
capturedFilename = value;
|
||
},
|
||
get() {
|
||
return capturedFilename ?? '';
|
||
},
|
||
});
|
||
return anchor;
|
||
},
|
||
body: { appendChild: () => {}, removeChild: () => {} },
|
||
});
|
||
});
|
||
|
||
afterEach(() => {
|
||
vi.unstubAllGlobals();
|
||
vi.restoreAllMocks();
|
||
});
|
||
|
||
it('POSTs to the pptx export route and downloads the returned bytes', async () => {
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn(async () => new Response('PK-fake-pptx', { status: 200 })),
|
||
);
|
||
|
||
const res = await exportProjectAsPptx({ projectId: 'proj 1', fileName: 'decks/pitch.html' });
|
||
|
||
expect(res.ok).toBe(true);
|
||
expect(fetch).toHaveBeenCalledWith('/api/projects/proj%201/export/pptx', {
|
||
method: 'POST',
|
||
headers: { 'content-type': 'application/json' },
|
||
body: JSON.stringify({ fileName: 'decks/pitch.html', deck: true }),
|
||
});
|
||
expect(capturedFilename).toBe('pitch.pptx');
|
||
expect(await capturedBlob!.text()).toBe('PK-fake-pptx');
|
||
});
|
||
|
||
it('carries the project-pinned Workspace identity across every project export transport', async () => {
|
||
const workspaceContext = workspaceContextFixture({
|
||
workspaceId: 'workspace-a',
|
||
workspaceMemberId: 'member-a',
|
||
});
|
||
const fetchMock = vi.fn<typeof fetch>(async (input) => {
|
||
const url = String(input);
|
||
if (url.endsWith('/export/pdf')) {
|
||
return Response.json({ ok: true });
|
||
}
|
||
if (url.endsWith('/export/image')) {
|
||
return Response.json(
|
||
{ error: { message: 'desktop only' } },
|
||
{ status: 501 },
|
||
);
|
||
}
|
||
if (url.endsWith('/export/html')) {
|
||
return new Response('<!doctype html><p>exported</p>', { status: 200 });
|
||
}
|
||
return new Response('archive-or-rendered-bytes', {
|
||
status: 200,
|
||
headers: {
|
||
'content-type': 'application/octet-stream',
|
||
'content-disposition': 'attachment; filename="export.bin"',
|
||
},
|
||
});
|
||
});
|
||
vi.stubGlobal('fetch', fetchMock);
|
||
|
||
await exportProjectAsHtml({
|
||
projectId: 'project-a',
|
||
filePath: 'index.html',
|
||
fallbackTitle: 'HTML',
|
||
workspaceContext,
|
||
});
|
||
await exportProjectAsPdf({
|
||
deck: false,
|
||
fallbackPdf: vi.fn(),
|
||
filePath: 'index.html',
|
||
projectId: 'project-a',
|
||
title: 'PDF',
|
||
workspaceContext,
|
||
});
|
||
await exportProjectAsPptx({
|
||
projectId: 'project-a',
|
||
fileName: 'index.html',
|
||
workspaceContext,
|
||
});
|
||
await exportProjectImageDataUrl({
|
||
projectId: 'project-a',
|
||
fileName: 'index.html',
|
||
workspaceContext,
|
||
});
|
||
await exportProjectAsZip({
|
||
projectId: 'project-a',
|
||
filePath: 'index.html',
|
||
fallbackHtml: '<p>fallback</p>',
|
||
fallbackTitle: 'ZIP',
|
||
workspaceContext,
|
||
});
|
||
await downloadProjectArchive({
|
||
projectId: 'project-a',
|
||
fallbackTitle: 'Archive',
|
||
workspaceContext,
|
||
});
|
||
|
||
expect(fetchMock).toHaveBeenCalledTimes(6);
|
||
for (const [, init] of fetchMock.mock.calls) {
|
||
const headers = new Headers(init?.headers);
|
||
expect(headers.get('x-od-workspace-id')).toBe('workspace-a');
|
||
expect(headers.get('x-od-workspace-member-id')).toBe('member-a');
|
||
}
|
||
});
|
||
|
||
it('requests editable PPTX when the caller selects native shapes and text', async () => {
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn(async () => new Response('PK-editable-pptx', { status: 200 })),
|
||
);
|
||
|
||
const res = await exportProjectAsPptx({
|
||
projectId: 'proj 1',
|
||
fileName: 'decks/pitch.html',
|
||
editable: true,
|
||
});
|
||
|
||
expect(res.ok).toBe(true);
|
||
expect(fetch).toHaveBeenCalledWith('/api/projects/proj%201/export/pptx', {
|
||
method: 'POST',
|
||
headers: { 'content-type': 'application/json' },
|
||
body: JSON.stringify({ fileName: 'decks/pitch.html', deck: true, editable: true }),
|
||
});
|
||
expect(capturedFilename).toBe('pitch.pptx');
|
||
expect(await capturedBlob!.text()).toBe('PK-editable-pptx');
|
||
});
|
||
|
||
it('passes versionId to the screenshot export route', async () => {
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn(async () => new Response('PK-version-pptx', { status: 200 })),
|
||
);
|
||
|
||
const res = await exportProjectAsPptx({
|
||
projectId: 'proj 1',
|
||
fileName: 'decks/pitch.html',
|
||
versionId: 'v1',
|
||
});
|
||
|
||
expect(res.ok).toBe(true);
|
||
expect(fetch).toHaveBeenCalledWith('/api/projects/proj%201/export/pptx', {
|
||
method: 'POST',
|
||
headers: { 'content-type': 'application/json' },
|
||
body: JSON.stringify({ fileName: 'decks/pitch.html', versionId: 'v1', deck: true }),
|
||
});
|
||
expect(capturedFilename).toBe('pitch.pptx');
|
||
});
|
||
|
||
it('honors the server UTF-8 Content-Disposition filename over the local fallback', async () => {
|
||
// Production always returns a Content-Disposition (title/RFC-5987 based); the
|
||
// happy-path test above only exercises the no-header fallback. This pins the
|
||
// branch the download actually uses in the desktop runtime.
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn(
|
||
async () =>
|
||
new Response('PK-fake-pptx', {
|
||
status: 200,
|
||
headers: {
|
||
'content-disposition':
|
||
"attachment; filename=\"Caf_ Deck __.pptx\"; filename*=UTF-8''Caf%C3%A9%20Deck%20%E7%AE%80%E6%8A%A5.pptx",
|
||
},
|
||
}),
|
||
),
|
||
);
|
||
|
||
const res = await exportProjectAsPptx({ projectId: 'p', fileName: 'decks/pitch.html', title: 'Café Deck 简报' });
|
||
|
||
expect(res.ok).toBe(true);
|
||
expect(capturedFilename).toBe('Café Deck 简报.pptx');
|
||
});
|
||
|
||
it('routes pdf format to the raster pdf-image endpoint', async () => {
|
||
vi.stubGlobal('fetch', vi.fn(async () => new Response('%PDF-fake', { status: 200 })));
|
||
|
||
await exportProjectAsPptx({ projectId: 'p', fileName: 'deck.html', format: 'pdf' });
|
||
|
||
expect(fetch).toHaveBeenCalledWith('/api/projects/p/export/pdf-image', expect.anything());
|
||
expect(capturedFilename).toBe('deck.pdf');
|
||
});
|
||
|
||
it('reports 501 (no off-screen renderer) as unavailable, not a semantic error', async () => {
|
||
// The caller may fall back to the vector/browser PDF only on genuine
|
||
// unavailability — so 501 must surface as `unavailable`, with no error.
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn(async () => new Response(JSON.stringify({ error: { message: 'desktop only' } }), { status: 501 })),
|
||
);
|
||
|
||
const res = await exportProjectAsPptx({ projectId: 'p', fileName: 'deck.html' });
|
||
|
||
expect(res).toEqual({ ok: false, unavailable: true, reason: 'no-renderer' });
|
||
});
|
||
|
||
it('surfaces a semantic failure (non-501) as an error, not unavailable', async () => {
|
||
// A bad-deck 422 / renderer 502 must NOT be masked as "fall back to vector";
|
||
// it carries the daemon message so the caller can surface it.
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn(async () => new Response(JSON.stringify({ error: { message: 'this artifact is not a slide deck' } }), { status: 422 })),
|
||
);
|
||
|
||
const res = await exportProjectAsPptx({ projectId: 'p', fileName: 'deck.html' });
|
||
|
||
expect(res).toEqual({ ok: false, error: 'this artifact is not a slide deck' });
|
||
});
|
||
|
||
it('treats a transport failure as unavailable', async () => {
|
||
vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('offline'); }));
|
||
|
||
const res = await exportProjectAsPptx({ projectId: 'p', fileName: 'deck.html' });
|
||
|
||
expect(res).toEqual({ ok: false, unavailable: true, reason: 'unreachable' });
|
||
});
|
||
|
||
it('a post-response failure (renderer already produced bytes) is an error, not unavailable', async () => {
|
||
// The 200 came back — a failure reading the body / triggering the download
|
||
// must NOT be reported as `unavailable`, or the caller silently downgrades to
|
||
// the lower-fidelity vector PDF instead of surfacing the failure.
|
||
vi.stubGlobal(
|
||
'fetch',
|
||
vi.fn(async () => ({
|
||
ok: true,
|
||
status: 200,
|
||
headers: new Headers(),
|
||
blob: async () => {
|
||
throw new Error('corrupt response body');
|
||
},
|
||
})),
|
||
);
|
||
|
||
const res = await exportProjectAsPptx({ projectId: 'p', fileName: 'deck.html', format: 'pdf' });
|
||
|
||
expect(res).toEqual({ ok: false, error: 'corrupt response body' });
|
||
});
|
||
|
||
it('fetches the design-system archive endpoint and downloads the daemon-named zip', async () => {
|
||
vi.stubGlobal('fetch', vi.fn(async () => new Response('PKzip-bytes', {
|
||
status: 200,
|
||
headers: {
|
||
'content-type': 'application/zip',
|
||
'content-disposition': "attachment; filename=\"Acme.zip\"; filename*=UTF-8''Acme.zip",
|
||
},
|
||
})));
|
||
|
||
const ok = await downloadDesignSystemArchive({
|
||
designSystemId: 'user:acme brand',
|
||
fallbackTitle: 'Acme Brand',
|
||
});
|
||
|
||
expect(ok).toBe(true);
|
||
expect(fetch).toHaveBeenCalledWith('/api/design-systems/user%3Aacme%20brand/archive');
|
||
expect(capturedFilename).toBe('Acme.zip');
|
||
expect(await capturedBlob!.text()).toContain('zip-bytes');
|
||
});
|
||
|
||
it('returns false and does not download when the request fails', async () => {
|
||
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||
vi.stubGlobal('fetch', vi.fn(async () => new Response('not found', { status: 404 })));
|
||
|
||
const ok = await downloadDesignSystemArchive({
|
||
designSystemId: 'user:missing',
|
||
fallbackTitle: 'Missing',
|
||
});
|
||
|
||
expect(ok).toBe(false);
|
||
expect(capturedBlob).toBeUndefined();
|
||
});
|
||
|
||
it('downloads the backing project archive with the daemon filename', async () => {
|
||
vi.stubGlobal('fetch', vi.fn(async () => new Response('project-zip', {
|
||
status: 200,
|
||
headers: {
|
||
'content-type': 'application/zip',
|
||
'content-disposition': "attachment; filename=\"Project.zip\"; filename*=UTF-8''Project.zip",
|
||
},
|
||
})));
|
||
|
||
const ok = await downloadProjectArchive({
|
||
projectId: 'project 123',
|
||
fallbackTitle: 'Fallback Project',
|
||
});
|
||
|
||
expect(ok).toBe(true);
|
||
expect(fetch).toHaveBeenCalledWith('/api/projects/project%20123/archive');
|
||
expect(capturedFilename).toBe('Project.zip');
|
||
expect(await capturedBlob!.text()).toContain('project-zip');
|
||
});
|
||
|
||
it('passes an optional root when downloading a project subfolder archive', async () => {
|
||
vi.stubGlobal('fetch', vi.fn(async () => new Response('folder-zip', {
|
||
status: 200,
|
||
headers: {
|
||
'content-type': 'application/zip',
|
||
},
|
||
})));
|
||
|
||
const ok = await downloadProjectArchive({
|
||
projectId: 'project-1',
|
||
fallbackTitle: 'Fallback Project',
|
||
root: '/system/',
|
||
});
|
||
|
||
expect(ok).toBe(true);
|
||
expect(fetch).toHaveBeenCalledWith('/api/projects/project-1/archive?root=system');
|
||
expect(capturedFilename).toBe('system.zip');
|
||
});
|
||
|
||
it('downloads version ZIPs from the daemon inline HTML export endpoint', async () => {
|
||
vi.stubGlobal('fetch', vi.fn(async () => new Response('<!doctype html><p>version</p>', {
|
||
status: 200,
|
||
headers: { 'content-type': 'text/html' },
|
||
})));
|
||
|
||
await exportProjectAsZip({
|
||
projectId: 'proj 1',
|
||
filePath: 'screens/main page.html',
|
||
fallbackHtml: '<main>fallback</main>',
|
||
fallbackTitle: 'Main Page v1',
|
||
versionId: 'v1',
|
||
});
|
||
|
||
expect(fetch).toHaveBeenCalledWith('/api/projects/proj%201/export/screens/main%20page.html?inline=1&versionId=v1');
|
||
expect(capturedFilename).toBe('Main-Page-v1.zip');
|
||
expect(capturedBlob?.type).toBe('application/zip');
|
||
});
|
||
});
|
||
|
||
// `exportAsMd` is a pass-through (the file body is the artifact source
|
||
// verbatim, only the extension and Content-Type flip). Tests exercise it
|
||
// end-to-end by stubbing the few DOM globals `triggerDownload` touches —
|
||
// we run under `environment: 'node'`, so `document` and `URL` aren't
|
||
// available by default. See issue #279.
|
||
describe('exportAsMd', () => {
|
||
let capturedBlob: Blob | undefined;
|
||
let capturedFilename: string | undefined;
|
||
|
||
beforeEach(() => {
|
||
capturedBlob = undefined;
|
||
capturedFilename = undefined;
|
||
vi.stubGlobal('URL', {
|
||
createObjectURL: (blob: Blob) => {
|
||
capturedBlob = blob;
|
||
return 'blob:test';
|
||
},
|
||
revokeObjectURL: () => {},
|
||
});
|
||
vi.stubGlobal('document', {
|
||
createElement: () => {
|
||
const anchor = { href: '', click: () => {} } as { href: string; download?: string; click: () => void };
|
||
Object.defineProperty(anchor, 'download', {
|
||
set(value: string) {
|
||
capturedFilename = value;
|
||
},
|
||
get() {
|
||
return capturedFilename ?? '';
|
||
},
|
||
});
|
||
return anchor;
|
||
},
|
||
body: { appendChild: () => {}, removeChild: () => {} },
|
||
});
|
||
});
|
||
|
||
afterEach(() => {
|
||
vi.unstubAllGlobals();
|
||
});
|
||
|
||
it('downloads the source bytes verbatim under a `.md` extension', async () => {
|
||
const source = '<!doctype html>\n<html lang="en"><body>hi</body></html>\n';
|
||
|
||
exportAsMd(source, 'TTC — Seed Round · 2026');
|
||
|
||
expect(capturedBlob).toBeDefined();
|
||
expect(capturedBlob!.type).toBe('text/markdown;charset=utf-8');
|
||
// Critical: no transformation, no normalization, no trimming. Whatever
|
||
// the Source view shows is what lands in the .md.
|
||
expect(await capturedBlob!.text()).toBe(source);
|
||
expect(capturedFilename).toBe('TTC-Seed-Round-2026.md');
|
||
});
|
||
|
||
it('falls back to "artifact.md" when the title is empty or unsafe', () => {
|
||
exportAsMd('hello', '');
|
||
expect(capturedFilename).toBe('artifact.md');
|
||
|
||
exportAsMd('hello', '???');
|
||
expect(capturedFilename).toBe('artifact.md');
|
||
});
|
||
|
||
it('keeps multi-byte content (UTF-8) intact end-to-end', async () => {
|
||
const source = '# 中文标题\n\n这是 markdown 文件 — でも本当は HTML 源代码 (مرحبا)。\n';
|
||
|
||
exportAsMd(source, 'mixed');
|
||
|
||
expect(await capturedBlob!.text()).toBe(source);
|
||
});
|
||
});
|
||
|
||
describe('sandboxed preview Blob exports', () => {
|
||
let capturedBlob: Blob | undefined;
|
||
let openedFeatures: string | undefined;
|
||
let mockWin: { opener: unknown; location: { href: string } };
|
||
let openCalls: string[][];
|
||
|
||
beforeEach(() => {
|
||
capturedBlob = undefined;
|
||
openedFeatures = undefined;
|
||
openCalls = [];
|
||
mockWin = { opener: {}, location: { href: '' } };
|
||
vi.stubGlobal('URL', {
|
||
createObjectURL: (blob: Blob) => {
|
||
capturedBlob = blob;
|
||
return 'blob:test';
|
||
},
|
||
revokeObjectURL: vi.fn(),
|
||
});
|
||
vi.stubGlobal('window', {
|
||
location: {
|
||
href: 'https://open-design.test/plugins/example',
|
||
origin: 'https://open-design.test',
|
||
},
|
||
open: (_url: string, _target: string, features?: string) => {
|
||
openCalls.push([_url, _target]);
|
||
openedFeatures = features;
|
||
return mockWin;
|
||
},
|
||
addEventListener: () => {},
|
||
});
|
||
vi.stubGlobal('alert', vi.fn());
|
||
});
|
||
|
||
afterEach(() => {
|
||
vi.unstubAllGlobals();
|
||
});
|
||
|
||
it('wraps generated HTML in an opaque-origin sandbox for new-tab previews', async () => {
|
||
openSandboxedPreviewInNewTab('<script>window.parent.localStorage.clear()</script>', 'Unsafe preview');
|
||
|
||
expect(openedFeatures).toBe('noopener,noreferrer');
|
||
expect(capturedBlob).toBeDefined();
|
||
const wrapper = await capturedBlob!.text();
|
||
expect(wrapper).toContain('sandbox="allow-scripts"');
|
||
expect(wrapper).not.toContain('allow-same-origin');
|
||
expect(wrapper).toContain('<script>window.parent.localStorage.clear()</script>');
|
||
expect(wrapper).not.toContain('<script>window.parent.localStorage.clear()</script>');
|
||
});
|
||
|
||
it('anchors new-tab srcdoc previews to the current origin when no explicit base is provided', async () => {
|
||
openSandboxedPreviewInNewTab('<img src="/api/plugins/example/assets/hero.png"><img src="assets/card.png">', 'Plugin preview');
|
||
|
||
expect(capturedBlob).toBeDefined();
|
||
const wrapper = await capturedBlob!.text();
|
||
expect(wrapper).toContain(
|
||
'<base href="https://open-design.test/" data-od-project-preview-base>',
|
||
);
|
||
});
|
||
|
||
it('passes srcdoc options through the sandboxed new-tab wrapper', async () => {
|
||
openSandboxedPreviewInNewTab('<section class="slide">One</section>', 'Deck preview', {
|
||
deck: true,
|
||
baseHref: '/artifacts/project/assets/',
|
||
initialSlideIndex: 2,
|
||
});
|
||
|
||
expect(openedFeatures).toBe('noopener,noreferrer');
|
||
expect(capturedBlob).toBeDefined();
|
||
const wrapper = await capturedBlob!.text();
|
||
expect(wrapper).toContain('sandbox="allow-scripts"');
|
||
expect(wrapper).not.toContain('allow-same-origin');
|
||
expect(wrapper).toContain(
|
||
'<base href="/artifacts/project/assets/" data-od-project-preview-base>',
|
||
);
|
||
expect(wrapper).toContain('od:slide');
|
||
});
|
||
|
||
it('can build a print wrapper without granting same-origin access', () => {
|
||
const wrapper = buildSandboxedPreviewDocument('<!doctype html><title>x</title>', 'Print', {
|
||
allowModals: true,
|
||
});
|
||
|
||
expect(wrapper).toContain('sandbox="allow-scripts allow-modals"');
|
||
expect(wrapper).not.toContain('allow-same-origin');
|
||
});
|
||
|
||
it('uses a sandboxed Blob wrapper with synchronous popup detection for PDF exports', async () => {
|
||
await exportAsPdf('<script>window.parent.document.body.innerHTML="owned"</script>', 'PDF');
|
||
|
||
expect(openCalls).toEqual([['', '_blank']]);
|
||
expect(mockWin.opener).toBeNull();
|
||
expect(mockWin.location.href).toBe('blob:test');
|
||
expect(capturedBlob).toBeDefined();
|
||
const wrapper = await capturedBlob!.text();
|
||
expect(wrapper).toContain('sandbox="allow-scripts allow-modals"');
|
||
expect(wrapper).not.toContain('allow-same-origin');
|
||
expect(wrapper).toContain('<script>window.parent.document.body.innerHTML="owned"</script>');
|
||
expect(wrapper).not.toContain('<script>window.parent.document.body.innerHTML="owned"</script>');
|
||
});
|
||
|
||
it('preserves deck print handling inside sandboxed PDF exports', async () => {
|
||
await exportAsPdf('<section class="slide">One</section>', 'Deck PDF', { deck: true });
|
||
|
||
expect(openCalls).toEqual([['', '_blank']]);
|
||
expect(mockWin.opener).toBeNull();
|
||
expect(mockWin.location.href).toBe('blob:test');
|
||
expect(capturedBlob).toBeDefined();
|
||
const wrapper = await capturedBlob!.text();
|
||
expect(wrapper).toContain('sandbox="allow-scripts allow-modals"');
|
||
expect(wrapper).toContain('data-deck-print="injected"');
|
||
expect(wrapper).toContain('page-break-after: always;');
|
||
});
|
||
|
||
it('waits for the injected print-ready cache before calling window.print() in the browser fallback', async () => {
|
||
await exportAsPdf('<div><img src="https://example.com/slow.png" alt="slow"/></div>', 'Ready PDF');
|
||
|
||
expect(capturedBlob).toBeDefined();
|
||
const wrapper = await capturedBlob!.text();
|
||
expect(wrapper).toContain('__odPrintReady');
|
||
expect(wrapper).toContain('__odPrintReadyStarted');
|
||
expect(wrapper).toContain("window.__odPrintReady===true");
|
||
expect(wrapper).toContain("window.__odPrintReadyStarted===false");
|
||
expect(wrapper).toContain("e.data.type==='OD_PRINT_READY'");
|
||
expect(wrapper).toContain("e.data.type==='OD_PRINT_READY_STARTED'");
|
||
expect(wrapper).toContain('window.addEventListener(\'message\'');
|
||
expect(wrapper).toContain('document.fonts');
|
||
expect(wrapper).toContain('waitForCssBackgroundImages');
|
||
expect(wrapper).toContain("setTimeout(doPrint,300)");
|
||
expect(wrapper).toContain('window.print()');
|
||
});
|
||
|
||
it('allows explicit trusted PDF opt-out without changing the secure default', async () => {
|
||
await exportAsPdf('<main>Trusted local document</main>', 'Trusted PDF', {
|
||
sandboxedPreview: false,
|
||
});
|
||
|
||
expect(openCalls).toEqual([['', '_blank']]);
|
||
expect(mockWin.opener).toEqual({});
|
||
expect(mockWin.location.href).toBe('blob:test');
|
||
expect(capturedBlob).toBeDefined();
|
||
const doc = await capturedBlob!.text();
|
||
expect(doc).not.toContain('sandbox="allow-scripts allow-modals"');
|
||
expect(doc).toContain('<main>Trusted local document</main>');
|
||
expect(doc).toContain('__odPrintReady');
|
||
expect(doc).toContain("window.__odPrintReady===true");
|
||
});
|
||
|
||
it('shows an alert and revokes the blob URL when the popup is blocked', async () => {
|
||
vi.stubGlobal('window', {
|
||
open: () => null,
|
||
addEventListener: () => {},
|
||
});
|
||
|
||
const revokeSpy = URL.revokeObjectURL as ReturnType<typeof vi.fn>;
|
||
revokeSpy.mockClear();
|
||
|
||
await exportAsPdf('<p>test</p>', 'Blocked');
|
||
|
||
expect(alert).toHaveBeenCalledWith('Popup blocked! Click the popup-blocked icon in your browser address bar (or browser menu), choose "Always allow pop-ups" for this site, then retry Export PDF.');
|
||
expect(revokeSpy).toHaveBeenCalledWith('blob:test');
|
||
});
|
||
|
||
it('uses the desktop native print bridge when the host PDF bridge is available', async () => {
|
||
const printPdfMock = vi.fn().mockResolvedValue({ ok: true });
|
||
const restoreHost = installMockOpenDesignHost({
|
||
host: { pdf: { print: printPdfMock } },
|
||
});
|
||
|
||
try {
|
||
await exportAsPdf('<script>window.parent.document.body.innerHTML="owned"</script>', 'Desktop PDF');
|
||
} finally {
|
||
restoreHost();
|
||
}
|
||
|
||
expect(printPdfMock).toHaveBeenCalledTimes(1);
|
||
expect(openCalls).toEqual([]);
|
||
|
||
const htmlArg = printPdfMock.mock.calls[0]![0];
|
||
expect(htmlArg).toContain('sandbox="allow-scripts"');
|
||
expect(htmlArg).not.toContain('allow-modals');
|
||
expect(htmlArg).toContain('<script>window.parent.document.body.innerHTML="owned"</script>');
|
||
expect(htmlArg).not.toContain('<script>window.parent.document.body.innerHTML="owned"</script>');
|
||
// Verify the readiness handshake is present — the sandboxed iframe posts
|
||
// 'OD_PRINT_READY' to the parent once fonts and images are loaded.
|
||
expect(htmlArg).toContain('OD_PRINT_READY');
|
||
// Verify the parent-wrapper cache script is present so the handshake is
|
||
// never missed even if 'OD_PRINT_READY' fires before the listener attaches.
|
||
expect(htmlArg).toContain('__odPrintReady');
|
||
// Verify the print script is NOT injected — Electron renders via the
|
||
// native printToPDF path, so a self-printing document would trigger a
|
||
// second print dialog.
|
||
expect(htmlArg).not.toContain('window.print()');
|
||
});
|
||
|
||
it('passes deck intent through the desktop native print bridge', async () => {
|
||
const printPdfMock = vi.fn().mockResolvedValue({ ok: true });
|
||
const restoreHost = installMockOpenDesignHost({
|
||
host: { pdf: { print: printPdfMock } },
|
||
});
|
||
|
||
try {
|
||
await exportAsPdf('<section class="slide">One</section>', 'Desktop Deck', { deck: true });
|
||
} finally {
|
||
restoreHost();
|
||
}
|
||
|
||
expect(printPdfMock).toHaveBeenCalledTimes(1);
|
||
expect(printPdfMock.mock.calls[0]![2]).toEqual({ deck: true });
|
||
expect(printPdfMock.mock.calls[0]![0]).toContain('data-deck-print="injected"');
|
||
});
|
||
|
||
it('injects image-waiting logic into the print-ready handshake for the desktop bridge', async () => {
|
||
const printPdfMock = vi.fn().mockResolvedValue({ ok: true });
|
||
const restoreHost = installMockOpenDesignHost({
|
||
host: { pdf: { print: printPdfMock } },
|
||
});
|
||
|
||
// HTML with an intentionally non-loadable image to exercise the
|
||
// incomplete-image detection in the injected handshake.
|
||
const html = '<div><img src="https://example.com/will-not-load.png" alt="test"/></div>';
|
||
try {
|
||
await exportAsPdf(html, 'Image Test');
|
||
} finally {
|
||
restoreHost();
|
||
}
|
||
|
||
const htmlArg = printPdfMock.mock.calls[0]![0];
|
||
// In the sandboxed wrapper the srcdoc attribute is HTML-escaped, so the
|
||
// handshake script content is present as unescaped JS fragments.
|
||
expect(htmlArg).toContain('document.images');
|
||
expect(htmlArg).toContain("img.loading==='lazy'");
|
||
expect(htmlArg).toContain("img.loading='eager'");
|
||
expect(htmlArg).toContain("img.addEventListener('load'");
|
||
expect(htmlArg).toContain("img.addEventListener('error'");
|
||
expect(htmlArg).toContain('img.complete');
|
||
expect(htmlArg).toContain('waitForCssBackgroundImages');
|
||
expect(htmlArg).toContain('window.getComputedStyle');
|
||
expect(htmlArg).toContain('style.backgroundImage');
|
||
expect(htmlArg).toContain('style.borderImageSource');
|
||
expect(htmlArg).toContain('style.listStyleImage');
|
||
expect(htmlArg).toContain('new Image()');
|
||
expect(htmlArg).toContain('requestAnimationFrame');
|
||
// The original font- and load-waiting logic must still be present.
|
||
expect(htmlArg).toContain('document.fonts');
|
||
expect(htmlArg).toContain('OD_PRINT_READY');
|
||
// The handshake posts an object with a per-export nonce to prevent
|
||
// spoofing by untrusted artifact code.
|
||
expect(htmlArg).toContain("type:'OD_PRINT_READY'");
|
||
expect(htmlArg).toContain("nonce:'");
|
||
// The cache script also validates the nonce and event source.
|
||
expect(htmlArg).toContain("e.data.type==='OD_PRINT_READY'");
|
||
expect(htmlArg).toContain("e.data.nonce===");
|
||
expect(htmlArg).toContain('e.source===');
|
||
// The parent cache should still be injected.
|
||
expect(htmlArg).toContain('__odPrintReady');
|
||
// No window.print() since the desktop bridge handles printing natively.
|
||
expect(htmlArg).not.toContain('window.print()');
|
||
});
|
||
|
||
it('reports the artifact content size through the handshake so the desktop page is sized to the content, not the wrapper viewport (issue #4067)', async () => {
|
||
const printPdfMock = vi.fn().mockResolvedValue({ ok: true });
|
||
const restoreHost = installMockOpenDesignHost({
|
||
host: { pdf: { print: printPdfMock } },
|
||
});
|
||
|
||
try {
|
||
await exportAsPdf('<div style="height:4000px">tall artifact</div>', 'Tall PDF');
|
||
} finally {
|
||
restoreHost();
|
||
}
|
||
|
||
const htmlArg = printPdfMock.mock.calls[0]![0];
|
||
// The in-iframe handshake measures the artifact's own document dimensions.
|
||
// The parent wrapper cannot do this itself: the sandboxed preview iframe is
|
||
// `allow-scripts` with no `allow-same-origin`, so iframe.contentDocument is
|
||
// null. Measuring from inside is the only way to learn the real size.
|
||
expect(htmlArg).toContain('document.documentElement');
|
||
expect(htmlArg).toContain('scrollHeight');
|
||
expect(htmlArg).toContain('offsetHeight');
|
||
// ...and it ships that size to the parent, but only once the content has a
|
||
// usable (non-zero) size, by driving the measurement through
|
||
// reportPrintSizeWhenStable. The polling/gating behavior itself is covered
|
||
// by the reportPrintSizeWhenStable unit tests above (real-logic behavior
|
||
// assertions, not string presence); here we assert the handshake is wired
|
||
// to it so a heavier artifact that lays out late is not reported at size 0,
|
||
// which would blank the PDF (#4458).
|
||
expect(htmlArg).toContain('reportPrintSizeWhenStable');
|
||
expect(htmlArg).toContain('width:size.width');
|
||
expect(htmlArg).toContain('height:size.height');
|
||
// The parent wrapper caches the reported size for inferPageSize() to read,
|
||
// gating it through isUsablePrintSize so a malformed/oversized message
|
||
// cannot poison the page size. The finite check matters: `Infinity > 0` is
|
||
// true, so a bare `typeof === 'number'` guard would cache a non-finite
|
||
// dimension and let it leak into the page size. (isUsablePrintSize's own
|
||
// boundary behavior is covered by its unit tests above.)
|
||
expect(htmlArg).toContain('window.__odPrintSize');
|
||
expect(htmlArg).toContain('__odUsable(e.data.width,e.data.height)');
|
||
expect(htmlArg).toContain('Number.isFinite(width)');
|
||
});
|
||
|
||
it('injects the readiness cache for non-sandboxed desktop exports too', async () => {
|
||
const printPdfMock = vi.fn().mockResolvedValue({ ok: true });
|
||
const restoreHost = installMockOpenDesignHost({
|
||
host: { pdf: { print: printPdfMock } },
|
||
});
|
||
|
||
try {
|
||
await exportAsPdf('<main>Trusted local document</main>', 'Trusted', {
|
||
sandboxedPreview: false,
|
||
});
|
||
} finally {
|
||
restoreHost();
|
||
}
|
||
|
||
expect(printPdfMock).toHaveBeenCalledTimes(1);
|
||
const htmlArg = printPdfMock.mock.calls[0]![0];
|
||
// No sandbox wrapper — the document is passed through directly.
|
||
expect(htmlArg).not.toContain('sandbox="allow-scripts"');
|
||
expect(htmlArg).toContain('<main>Trusted local document</main>');
|
||
// The readiness handshake must still be injected.
|
||
expect(htmlArg).toContain('OD_PRINT_READY');
|
||
// The cache must be present so waitForPrintReadyHandshake never hangs.
|
||
expect(htmlArg).toContain('__odPrintReady');
|
||
// No window.print() since the desktop bridge handles printing natively.
|
||
expect(htmlArg).not.toContain('window.print()');
|
||
});
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Image screenshot export
|
||
// ---------------------------------------------------------------------------
|
||
|
||
describe('requestPreviewSnapshot', () => {
|
||
let listeners: Map<string, Set<(ev: unknown) => void>>;
|
||
|
||
beforeEach(() => {
|
||
listeners = new Map();
|
||
vi.stubGlobal('window', {
|
||
addEventListener: (type: string, fn: (ev: unknown) => void) => {
|
||
if (!listeners.has(type)) listeners.set(type, new Set());
|
||
listeners.get(type)!.add(fn);
|
||
},
|
||
removeEventListener: (type: string, fn: (ev: unknown) => void) => {
|
||
listeners.get(type)?.delete(fn);
|
||
},
|
||
dispatchEvent: (ev: { type: string }) => {
|
||
for (const fn of listeners.get(ev.type) ?? []) fn(ev);
|
||
},
|
||
});
|
||
});
|
||
|
||
afterEach(() => {
|
||
vi.unstubAllGlobals();
|
||
vi.restoreAllMocks();
|
||
});
|
||
|
||
it('returns null when the iframe has no contentWindow', async () => {
|
||
const iframe = { contentWindow: null } as unknown as HTMLIFrameElement;
|
||
const result = await requestPreviewSnapshot(iframe);
|
||
expect(result).toBeNull();
|
||
});
|
||
|
||
it('resolves with snapshot data when the bridge responds', async () => {
|
||
const postMessageMock = vi.fn();
|
||
const contentWindow = { postMessage: postMessageMock };
|
||
const iframe = { contentWindow } as unknown as HTMLIFrameElement;
|
||
|
||
const promise = requestPreviewSnapshot(iframe);
|
||
|
||
expect(postMessageMock).toHaveBeenCalledOnce();
|
||
const { id } = postMessageMock.mock.calls[0]![0] as { type: string; id: string };
|
||
|
||
// Simulate the bridge responding — source must match iframe.contentWindow
|
||
window.dispatchEvent(
|
||
{ type: 'message', source: contentWindow, data: { type: 'od:snapshot:result', id, dataUrl: 'data:image/png;base64,abc', w: 100, h: 50 } } as unknown as Event,
|
||
);
|
||
|
||
const result = await promise;
|
||
expect(result).toEqual({ dataUrl: 'data:image/png;base64,abc', w: 100, h: 50 });
|
||
});
|
||
|
||
it('can request a full-document snapshot from the bridge', async () => {
|
||
const postMessageMock = vi.fn();
|
||
const contentWindow = { postMessage: postMessageMock };
|
||
const iframe = { contentWindow } as unknown as HTMLIFrameElement;
|
||
|
||
const promise = requestPreviewSnapshot(iframe, 100, { full: true });
|
||
|
||
expect(postMessageMock).toHaveBeenCalledOnce();
|
||
const message = postMessageMock.mock.calls[0]![0] as { type: string; id: string; full?: boolean };
|
||
expect(message).toMatchObject({ type: 'od:snapshot', full: true });
|
||
|
||
window.dispatchEvent(
|
||
{ type: 'message', source: contentWindow, data: { type: 'od:snapshot:result', id: message.id, dataUrl: 'data:image/png;base64,abc', w: 100, h: 200 } } as unknown as Event,
|
||
);
|
||
|
||
const result = await promise;
|
||
expect(result).toEqual({ dataUrl: 'data:image/png;base64,abc', w: 100, h: 200 });
|
||
});
|
||
|
||
it('resolves null when the bridge responds with an error', async () => {
|
||
const postMessageMock = vi.fn();
|
||
const contentWindow = { postMessage: postMessageMock };
|
||
const iframe = { contentWindow } as unknown as HTMLIFrameElement;
|
||
|
||
const promise = requestPreviewSnapshot(iframe);
|
||
const { id } = postMessageMock.mock.calls[0]![0] as { type: string; id: string };
|
||
|
||
window.dispatchEvent(
|
||
{ type: 'message', source: contentWindow, data: { type: 'od:snapshot:result', id, error: 'snapshot image failed' } } as unknown as Event,
|
||
);
|
||
|
||
const result = await promise;
|
||
expect(result).toBeNull();
|
||
});
|
||
|
||
it('resolves null on timeout', async () => {
|
||
vi.useFakeTimers();
|
||
const iframe = {
|
||
contentWindow: { postMessage: vi.fn() },
|
||
} as unknown as HTMLIFrameElement;
|
||
|
||
const promise = requestPreviewSnapshot(iframe, 100);
|
||
vi.advanceTimersByTime(150);
|
||
|
||
const result = await promise;
|
||
expect(result).toBeNull();
|
||
vi.useRealTimers();
|
||
});
|
||
|
||
it('ignores messages with a mismatched id', async () => {
|
||
vi.useFakeTimers();
|
||
const postMessageMock = vi.fn();
|
||
const contentWindow = { postMessage: postMessageMock };
|
||
const iframe = { contentWindow } as unknown as HTMLIFrameElement;
|
||
|
||
const promise = requestPreviewSnapshot(iframe, 100);
|
||
|
||
// Correct source but wrong id — should be ignored
|
||
window.dispatchEvent(
|
||
{ type: 'message', source: contentWindow, data: { type: 'od:snapshot:result', id: 'wrong-id', dataUrl: 'data:image/png;base64,abc', w: 100, h: 50 } } as unknown as Event,
|
||
);
|
||
|
||
vi.advanceTimersByTime(150);
|
||
const result = await promise;
|
||
expect(result).toBeNull();
|
||
vi.useRealTimers();
|
||
});
|
||
|
||
it('ignores messages from a different source window', async () => {
|
||
vi.useFakeTimers();
|
||
const postMessageMock = vi.fn();
|
||
const contentWindow = { postMessage: postMessageMock };
|
||
const iframe = { contentWindow } as unknown as HTMLIFrameElement;
|
||
|
||
const promise = requestPreviewSnapshot(iframe, 100);
|
||
const { id } = postMessageMock.mock.calls[0]![0] as { type: string; id: string };
|
||
|
||
// Correct id but wrong source — should be ignored
|
||
window.dispatchEvent(
|
||
{ type: 'message', source: { other: true }, data: { type: 'od:snapshot:result', id, dataUrl: 'data:image/png;base64,abc', w: 100, h: 50 } } as unknown as Event,
|
||
);
|
||
|
||
vi.advanceTimersByTime(150);
|
||
const result = await promise;
|
||
expect(result).toBeNull();
|
||
vi.useRealTimers();
|
||
});
|
||
});
|
||
|
||
describe('exportAsImage', () => {
|
||
let clickMock: ReturnType<typeof vi.fn>;
|
||
let createObjectURLMock: ReturnType<typeof vi.fn>;
|
||
let anchors: Array<{ href: string; download: string; click: ReturnType<typeof vi.fn> }>;
|
||
|
||
beforeEach(() => {
|
||
clickMock = vi.fn();
|
||
createObjectURLMock = vi.fn(() => 'blob:mock-url');
|
||
anchors = [];
|
||
vi.stubGlobal('URL', { createObjectURL: createObjectURLMock, revokeObjectURL: vi.fn() });
|
||
vi.stubGlobal('document', {
|
||
createElement: () => {
|
||
const el = { href: '', download: '', click: clickMock };
|
||
anchors.push(el);
|
||
return el;
|
||
},
|
||
body: {
|
||
appendChild: vi.fn(),
|
||
removeChild: vi.fn(),
|
||
},
|
||
});
|
||
// triggerDownload calls setTimeout for deferred revoke
|
||
vi.stubGlobal('setTimeout', (fn: () => void) => fn());
|
||
});
|
||
|
||
afterEach(() => {
|
||
vi.unstubAllGlobals();
|
||
vi.restoreAllMocks();
|
||
});
|
||
|
||
it('triggers a download with a .png filename', () => {
|
||
const dataUrl = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
|
||
exportAsImage(dataUrl, 'My Design');
|
||
|
||
expect(clickMock).toHaveBeenCalledOnce();
|
||
expect(anchors).toHaveLength(1);
|
||
expect(anchors[0]!.download).toBe('My-Design.png');
|
||
});
|
||
|
||
it('sanitizes the title into a safe filename', () => {
|
||
exportAsImage('data:image/png;base64,AA==', 'Hello <World> / Test!');
|
||
|
||
expect(anchors[0]!.download).toBe('Hello-World-Test.png');
|
||
});
|
||
|
||
it('does not download an empty image snapshot', () => {
|
||
expect(() => exportAsImage('data:image/png;base64,', 'Empty')).toThrow('Image snapshot is empty');
|
||
|
||
expect(clickMock).not.toHaveBeenCalled();
|
||
expect(anchors).toHaveLength(0);
|
||
});
|
||
|
||
it('downloads a validated image data URL without creating a blob URL', () => {
|
||
const dataUrl = 'data:image/png;base64,AA==';
|
||
|
||
downloadImageDataUrl(dataUrl, 'workspace.png');
|
||
|
||
expect(clickMock).toHaveBeenCalledOnce();
|
||
expect(createObjectURLMock).not.toHaveBeenCalled();
|
||
expect(anchors[0]!.href).toBe(dataUrl);
|
||
expect(anchors[0]!.download).toBe('workspace.png');
|
||
});
|
||
|
||
it('does not download an empty image data URL', () => {
|
||
expect(() => downloadImageDataUrl('data:image/png;base64,', 'workspace.png')).toThrow('Image snapshot is empty');
|
||
|
||
expect(clickMock).not.toHaveBeenCalled();
|
||
expect(anchors).toHaveLength(0);
|
||
});
|
||
|
||
it('falls back to download when the native save picker is blocked', async () => {
|
||
const showSaveFilePicker = vi.fn().mockRejectedValue(
|
||
new DOMException('Must be handling a user gesture to show a file picker.', 'SecurityError'),
|
||
);
|
||
vi.stubGlobal('window', { showSaveFilePicker });
|
||
|
||
const target = await prepareImageExportTarget('My Design', 'jpeg');
|
||
|
||
expect(showSaveFilePicker).toHaveBeenCalledOnce();
|
||
expect(target?.method).toBe('download');
|
||
expect(target?.filename).toBe('My-Design.jpg');
|
||
|
||
await target?.save(new Blob(['jpeg'], { type: 'image/jpeg' }));
|
||
|
||
expect(clickMock).toHaveBeenCalledOnce();
|
||
expect(anchors[0]!.download).toBe('My-Design.jpg');
|
||
});
|
||
|
||
it('falls back to download when the native save picker reports a cross-realm SecurityError', async () => {
|
||
const securityError = Object.assign(new Error('Must be handling a user gesture to show a file picker.'), {
|
||
name: 'SecurityError',
|
||
});
|
||
const showSaveFilePicker = vi.fn().mockRejectedValue(securityError);
|
||
vi.stubGlobal('window', { showSaveFilePicker });
|
||
|
||
const target = await prepareImageExportTarget('My Design', 'webp');
|
||
|
||
expect(showSaveFilePicker).toHaveBeenCalledOnce();
|
||
expect(target?.method).toBe('download');
|
||
expect(target?.filename).toBe('My-Design.webp');
|
||
});
|
||
|
||
it('can skip the native save picker to avoid pre-creating empty files', async () => {
|
||
const showSaveFilePicker = vi.fn();
|
||
vi.stubGlobal('window', { showSaveFilePicker });
|
||
|
||
const target = await prepareImageExportTarget('My Design', 'png', { useNativePicker: false });
|
||
|
||
expect(showSaveFilePicker).not.toHaveBeenCalled();
|
||
expect(target?.method).toBe('download');
|
||
expect(target?.filename).toBe('My-Design.png');
|
||
});
|
||
});
|