Files
open-design/apps/daemon/tests/export-inline-route.test.ts
1fb099916c feat(daemon): export self-contained HTML via /export/*?inline=1 endpoint (#1312)
* test(daemon): add Red unit tests for inlineRelativeAssets helper

14 cases pinning the behavior contract for the upcoming
apps/daemon/src/inline-assets.ts helper:

- link/script inlining with verbatim body preservation
- non-src script attrs preserved (type=module, defer, crossorigin)
- relative path resolution (root + nested + deep-nested owners)
- self-closing and single-quoted attr forms
- negative cases: missing rel, rel=preload, absolute/data/blob/leading-slash
- escaping: </style and </script inside body
- null-fileReader graceful degradation
- duplicate identical tags fully replaced (diverges from
  apps/web/src/components/FileViewer.tsx:5313's first-match-only;
  locked decision per plan §3.3)
- HTML-escaped data-od-inline-asset attr

Tests intentionally Red — module ../src/inline-assets.js does not yet
exist. Phase B-G of plan declarative-roaming-gosling.md will turn them
green by porting FileViewer.tsx:5248-5354 server-side.

Refs nexu-io/open-design#368.

* feat(daemon): port inlineRelativeAssets server-side for export endpoint

Adds apps/daemon/src/inline-assets.ts — a pure helper that takes
(html, ownerFileName, fileReader closure) and returns the HTML with
every relative <link rel=stylesheet> and <script src> contents inlined
into <style data-od-inline-asset="…">/<script>…</script> blocks. The
fileReader closure keeps the helper free of fs/Express coupling so the
route handler owns the filesystem boundary.

Port source: apps/web/src/components/FileViewer.tsx:5248-5354 — five
functions (inlineRelativeAssets, resolveProjectRelativePath, baseDirFor,
readHtmlAttr, escapeHtmlAttr). The fetch hop becomes the fileReader
closure; replace-all replaces first-match-only per locked design
decision §3.3 (inline comment in inline-assets.ts cites the divergence
from FileViewer.tsx:5313 and notes the web inline path is on a
deprecation track since PR #384 made URL-load the default).

Phase B-G of plan declarative-roaming-gosling.md. All 14 unit cases
from the Red commit (a60a9023) now pass; tightens one case to use a
realistic '&'-only filename (the original `<`/`>`-bearing filename was
unreachable in real filesystems and exposed a regex limitation the
web client carries too).

Daemon delta: +14 tests (1704 → 1718). Typecheck clean.

Refs nexu-io/open-design#368.

* test(daemon): add Red integration tests for /export/*?inline=1 route

9 HTTP cases against GET /api/projects/:id/export/*?inline=1:

- 3-file React-ish layout returns self-contained HTML (wiring guard:
  body assertions catch removal of the await inlineRelativeAssets(...)
  line, not just helper-internals changes)
- missing inline / non-canonical values (0, false, foo, empty) → 400
- non-HTML file → 400 UNSUPPORTED_FILE_TYPE
- missing file → 404 FILE_NOT_FOUND
- invalid project id (..) → some 4xx (Express normalizes before route)
- null-origin OPTIONS preflight → 204 + Access-Control-Allow-Origin: *
- missing sibling asset → 200 with <link> tag intact, other asset inlined
- nested HTML entry (pages/index.html + ../shared/util.js) → 200 inlined

8 of 9 tests Red (404 / 403); the invalid-project-id case is tolerant
about how Express rejects .. so it accidentally passes Red — Green
will tighten to 400 BAD_REQUEST via isSafeId.

Phase C-R of plan declarative-roaming-gosling.md. C-G will register
the route in apps/daemon/src/import-export-routes.ts.

Refs nexu-io/open-design#368.

* feat(daemon): wire GET /api/projects/:id/export/*?inline=1 endpoint

Adds the export-inline endpoint into registerProjectExportRoutes
(import-export-routes.ts) alongside /export/pdf and /archive. The
route:

- Validates project id via ctx.validation.isSafeId
- Requires ?inline=1 (accept-list: 1 / true / yes / on, matching Part
  1's parseForceInline at file-viewer-render-mode.ts:59-66)
- Reads the owner HTML via ctx.projectFiles.readProjectFile; maps
  ENOENT to 404 FILE_NOT_FOUND, everything else to 400 BAD_REQUEST
- Gates non-HTML callers with 400 UNSUPPORTED_FILE_TYPE
- Builds a fileReader closure that silently returns null on any sibling
  read failure (failure-local, not fatal — matches the web client's
  null-filter at FileViewer.tsx:5311)
- Hands the buffer + relPath to inlineRelativeAssets and returns the
  result as text/html

DI: RegisterProjectExportRoutesDeps gains 'projectFiles' | 'validation';
server.ts:2879 passes the corresponding deps. Mirrors the dep shape of
RegisterFinalizeRoutesDeps used by PR #832's /finalize/anthropic.

Null-origin support intentionally omitted (decision §10 in the PR
description): the daemon's null-origin allowlist is /raw/* and
/codex-pets/.../spritesheet only, and export consumers are same-origin
UI or server-side tooling — sandboxed-iframe srcdoc previews fetch
/raw/* instead. Integration test #7 pins the 403 contract so a future
allowlist change is deliberate.

Phase C-G of plan declarative-roaming-gosling.md. All 23 tests green
(14 unit + 9 integration); full daemon suite 1727 passing (delta +9
over B-G's 1718). Typecheck clean.

Refs nexu-io/open-design#368.

* test(daemon): add Red regression for inlined-body tag-literal corruption

Reproduces the correctness bug Siri-Ray (looper) and codex-bot flagged
on PR #1312: the reduce/split-join approach in inlineRelativeAssets
re-scans the progressively mutated HTML, so a tag literal that happens
to appear inside an already-inlined asset body gets the inner literal
also replaced — corrupting the body and producing duplicate inlining.

Concrete reproducer (CSS, where </style escape doesn't touch <link>):

  HTML:    <link rel="stylesheet" href="a.css">
           <link rel="stylesheet" href="b.css">
  a.css:   /* see also <link rel="stylesheet" href="b.css"> */
  b.css:   body{color:red}

Under split/join the second pass splits on `<link rel="stylesheet"
href="b.css">` and matches BOTH the real outer tag AND the literal
inside a.css's comment. Result: b.css's <style> block is injected
inside a.css's comment, and b.css gets inlined twice.

Phase F-R of plan declarative-roaming-gosling.md (post-PR-#1312
review round). F-G will rewrite the helper to collect matches by
position in the original HTML and concat slices in a single pass,
so already-inlined content is never re-scanned.

Refs nexu-io/open-design#1312 review threads at
apps/daemon/src/inline-assets.ts:122 (Siri-Ray looper + codex bot).

* feat(daemon): replace inliner reduce/split-join with position-based concat

Fixes the inlined-body tag-literal corruption Siri-Ray (looper) +
codex-bot flagged on PR #1312. The previous `replaceAllOccurrences`
(`source.split(from).join(to)`) re-scanned the progressively mutated
HTML on each pass, so a tag literal that appeared inside an already-
inlined CSS/JS body got the inner literal replaced too, producing
duplicate inlining and corrupted bodies.

New shape: collect every match's {start, end} byte span from the
ORIGINAL html via `matchAll`, await the per-match replacements in
parallel, sort by start, and concat slices of the original html with
the replacement strings in a single pass. Text introduced by an
earlier replacement is never scanned for matches.

The dup-tag fix (decision §8 — replace every occurrence, not
first-match-only) is preserved: every original-tag position gets its
own slice, so all duplicates are inlined.

Also extracts buildInlineStyleBlock / buildInlineScriptBlock so the
match-collection loops stay readable.

Phase F-G of plan declarative-roaming-gosling.md. Regression test
(c809bccc) goes Green; all 24 unit + integration tests pass; daemon
suite still clean.

Refs nexu-io/open-design#1312.

* test(daemon): add Red CSP-sandbox test + P3 coverage gaps from PR #1312 review

Three tests covering lefarcen's review on PR #1312:

1. [Red] CSP sandbox header (P2, lefarcen @ import-export-routes.ts:423).
   Top-level browser navigation to /export/*?inline=1 sends no Origin
   header, so the daemon middleware lets it through and any JS in the
   exported document runs with daemon-origin privileges. Asserts the
   response sends `Content-Security-Policy: sandbox allow-scripts` so
   the browser treats it as a sandboxed iframe with an opaque origin
   (scripts still run, but no cookies / no /api/ access). This test
   fails until G1-G adds the header in the handler.

2. [Green-on-commit] Accept-list cases (P3, lefarcen @ test.ts:262).
   PR body decision §7 promises `inline=true/yes/on` case-insensitive,
   but round-1 tests only exercised inline=1. Pin the full accept list
   (true / yes / on + TRUE / Yes / ON). Already passes — the route's
   parser already implements the accept list; this just makes the
   contract testable.

3. [Green-on-commit] isSafeId guard (P3, lefarcen @ test.ts:287).
   Previous `..` test was normalized by Express before reaching the
   route. New input uses `bad!id` (URL-safe, but outside isSafeId's
   /^[A-Za-z0-9._-]+$/ char class), so Express passes it into
   req.params unchanged and isSafeId rejects with the documented
   400 BAD_REQUEST envelope.

Phase G1-R / H of plan declarative-roaming-gosling.md. Refs
nexu-io/open-design#1312 review comments.

* feat(daemon): send Content-Security-Policy: sandbox allow-scripts on /export

Closes the same-origin XSS surface lefarcen flagged on PR #1312 (P2
at import-export-routes.ts:423): top-level browser navigation to the
export URL sends no Origin header, so the daemon's /api middleware
admits the request and any JS in the exported document executes with
daemon-origin privileges (cookies, /api/, localStorage).

`Content-Security-Policy: sandbox allow-scripts` on the response
makes the browser treat the document as a sandboxed iframe with an
opaque origin. Scripts still execute (necessary for the screenshot
use case — the whole point of inlining JS), but they cannot read
cookies, hit /api/, or otherwise escalate to the daemon's origin.

Phase G1-G of plan declarative-roaming-gosling.md. Daemon delta: +3
tests (the Red CSP test from 58151356 turns Green; the P3 coverage
gap tests stay green).

Refs nexu-io/open-design#1312.

* test(daemon): add Red regression for <link> stylesheet attr preservation

Currently `<link rel="stylesheet" href="print.css" media="print">`
becomes a plain `<style data-od-inline-asset="print.css">…</style>`
with no media query — print-only styles apply unconditionally. Same
problem for `title` (alternate stylesheet sets), `disabled` (initial
disabled state), and `nonce` (CSP nonce). All four are valid on
both `<link rel=stylesheet>` and `<style>` per HTML spec, so the
inliner must carry them across.

PR #1312 round-2 review (lefarcen P2 @ inline-assets.ts:44). Phase
G2-R; G2-G will extend buildInlineStyleBlock to copy the four attrs
off the source <link>.

Refs nexu-io/open-design#1312.

* feat(daemon): preserve <link> stylesheet semantics on inlined <style>

Closes lefarcen's P2 review note on PR #1312 (inline-assets.ts:44):
`<link rel="stylesheet" href="print.css" media="print">` was becoming
a plain <style> with no media query, so print-only styles applied
unconditionally. Same issue for `title` (alternate stylesheet sets),
`disabled` (initial disabled state), and `nonce` (CSP nonce).

buildInlineStyleBlock now carries four attrs across from the source
<link>:
  - media, title, nonce  (value attrs, HTML-escaped via escapeHtmlAttr)
  - disabled             (boolean attr — copied as bare presence)

Other <link> attrs (rel, href, type, crossorigin, integrity,
referrerpolicy) don't apply to <style> and are intentionally dropped.

New `hasBooleanHtmlAttr` helper distinguishes presence-as-attr from
substring-inside-another-attr-value via a regex that requires a
word boundary after the name (whitespace, `=`, or `>`).

Phase G2-G of plan declarative-roaming-gosling.md. All 28 tests pass.

Refs nexu-io/open-design#1312.

* docs(daemon): narrow inliner contract claim + document size-limit policy

Closes lefarcen's P2 review notes on PR #1312:

1. "Self-contained" incomplete (inline-assets.ts:67): the helper
   only rewrites top-level <link rel=stylesheet> / <script src>.
   `<img src>`, CSS `url(...)`, CSS `@import`, ES module imports,
   font sources, and similar remain external in the response. The
   PR title/body claimed "self-contained HTML" which over-promised
   for screenshot tooling expecting bundled images/fonts.

   Module docstring now enumerates the full not-rewritten list and
   names the screenshot path as the primary use case (headless
   browser fetches each external asset on render, so inline-CSS-
   and-JS-only is sufficient). The route handler comment block
   mirrors the contract.

   A fully offline export with image/font bundling is filed as a
   follow-up — out of scope for this PR.

2. No response cap (inline-assets.ts:72): the helper does
   concurrent reads + multiple string copies and could spike daemon
   memory. The daemon is local-first (single-user, developer's
   machine — see open_design_architecture.md), so the effective
   ceiling is the size of the user's own project. The docstring now
   states this rationale and names the conditions under which a
   bounded-concurrency reader and output-size limit would be
   needed (non-trusted callers).

Docs-only — no behavior change, all 28 tests still pass.

Refs nexu-io/open-design#1312.

* test(daemon): add Red regression for hasBooleanHtmlAttr quoted-value match

PR #1312 round-2 review (lefarcen P3): `hasBooleanHtmlAttr` tests the
tag string with no attr-quoting awareness, so the literal text
`disabled` appearing inside any quoted attribute value followed by
another whitespace char satisfies `\sdisabled(?=\s|=|/?>)`.

  <link rel=stylesheet href=x.css data-note="content disabled stuff">

emits a <style disabled> block, silently disabling a stylesheet the
author wrote without that attr.

Also adds a counterweight test for the legitimate-disabled case
(<link … disabled>) so the next-commit fix doesn't over-correct
and start dropping real boolean attrs.

Phase I3-R of plan declarative-roaming-gosling.md (post-PR-#1312
round-2 review). I3-G will strip quoted attribute values from the
tag string before testing for the bare attr.

Refs nexu-io/open-design#1312.

* feat(daemon): make hasBooleanHtmlAttr quote-aware to avoid false positives

Closes lefarcen's P3 review note on PR #1312:
`hasBooleanHtmlAttr` previously ran `\sname(?=\s|=|/?>)` over the
full tag string, so the literal text `disabled` appearing inside any
quoted attribute value followed by whitespace satisfied the regex.
Source tags like `<link rel=stylesheet href=x.css
data-note="content disabled stuff">` were emitting a <style
disabled> block — silently disabling a stylesheet the author wrote
without that attr.

Fix: strip `="…"` and `='…'` substrings out of the tag with two
regex passes BEFORE testing for the bare attr. The lookahead still
requires `\s|=|/?>` after the attr name, so `<link disabled>`,
`<link disabled="">`, `<link disabled/>`, etc. all match — but the
attr name as a substring of any quoted value cannot match because
values have been stripped to `""` / `''`.

Phase I3-G of plan declarative-roaming-gosling.md. All 30 tests
green (28 prior + 2 round-3 regression cases: false-positive and
legitimate-disabled). Refs nexu-io/open-design#1312.

* test(daemon): add Red cap-enforcement tests + scaffold InlineOptions

PR #1312 round-2 review (lefarcen P2 — still open): round-2 only
documented that no cap is enforced. Reviewer pushed back: the helper
still builds unbounded candidate arrays + runs Promise.all over all
asset reads + concatenates the full output in memory. Need actual
limits in code.

This commit adds the Red test surface that drives the next commit's
enforcement:

  - InlineAssetsLimitError("owner") when owner HTML > maxOwnerBytes
  - InlineAssetsLimitError("candidates") when tag matches > maxCandidates
  - Per-asset graceful: oversized asset → tag stays as URL ref
  - InlineAssetsLimitError("total") when assembled output > maxTotalBytes
  - Bounded read concurrency: peak in-flight reads ≤ maxReadConcurrency
  - Integration: route maps the throw to 413 PAYLOAD_TOO_LARGE

InlineOptions interface is added to the helper signature as a no-op
test-door (per feedback_test_doors_over_fake_timers.md), so tests
can exercise tiny fixtures while production callers use module-level
defaults. The next commit (H3-G) wires the enforcement.

Phase H3-R of plan declarative-roaming-gosling.md. Daemon delta on
this commit: +6 tests (5 unit + 1 integration), all Red.

Refs nexu-io/open-design#1312.

* feat(daemon): enforce inliner caps + map limit errors to 413 PAYLOAD_TOO_LARGE

Closes lefarcen's still-open P2 review on PR #1312 round 2 ("the
code still builds unbounded candidate arrays + Promise.all over all
asset reads + concatenates the full output in memory"). Caps are now
enforced in code with the documented defaults:

  MAX_INLINE_OWNER_BYTES       = 2 MiB
  MAX_INLINE_ASSET_BYTES       = 5 MiB per sibling
  MAX_INLINE_CANDIDATES        = 500 link/script matches
  MAX_INLINE_TOTAL_BYTES       = 50 MiB assembled output
  MAX_INLINE_READ_CONCURRENCY  = 8 simultaneous fileReader calls

Enforcement points:

- Owner cap (input): fires immediately at function entry. Cheap —
  Buffer.byteLength of the already-decoded UTF-8 string.
- Candidate cap (planning): fires after matchAll, BEFORE any sibling
  read. Pathological HTML with thousands of <link>/<script src>
  tags is rejected without opening a single file descriptor.
- Asset cap (per-sibling): post-read length check; oversized assets
  return null from the wrapped reader, so the tag stays as a URL ref
  and the response is still 200. This is the only "graceful" cap —
  one bad asset doesn't fail the whole export.
- Total cap (output): tracked across the slice-and-concat loop,
  guarding both preserved-html slices AND injected replacements.
- Concurrency cap (planning): a tiny in-module runWithConcurrency
  worker-pool keeps at most maxReadConcurrency fileReader calls in
  flight, with order-preserving results.

`InlineAssetsLimitError` carries a `limit` discriminator so logs and
clients can disambiguate owner/asset/candidates/total. The route
handler catches it and emits 413 PAYLOAD_TOO_LARGE.

Drive-by error-envelope fix while in the route: UNSUPPORTED_FILE_TYPE
(an unregistered ApiErrorCode) → UNSUPPORTED_MEDIA_TYPE (the
canonical code) with HTTP 415. The round-1 string was a slip;
caught by reading packages/contracts/src/errors.ts:11 while wiring
PAYLOAD_TOO_LARGE.

Phase H3-G of plan declarative-roaming-gosling.md. All 36 tests
green (28 prior + 2 round-3 quoted-attr + 5 cap unit + 1 cap
integration). Refs nexu-io/open-design#1312.

* feat(daemon): enforce inliner caps pre-buffer via AssetHandle contract

Closes lefarcen's still-open P2 review on PR #1312 round 3 ("the
helper enforces maxTotalBytes only after all candidate assets have
already been read and converted to replacement strings" /
"maxAssetBytes is checked after fileReader fully buffers each
sibling"). Round-3 caps were defensive against the final output
size but did not bound peak memory during read fanout — 500 assets
at 5 MiB each could materialize ~2.5 GiB before the 413 fired.

Contract change: InlineAssetReader now returns `AssetHandle | null`
where AssetHandle is `{ readonly size: number; read(): Promise<...> }`.
Callers expose `size` from a cheap stat-equivalent (the route uses
`resolveProjectFilePath`) and defer the full materialization to
`read()`. The helper checks size against maxAssetBytes BEFORE
invoking read, and against the running total BEFORE the reservation
is committed.

Enforcement flow inside runWithConcurrency:

  1. await fileReader(p.resolved) → cheap stat-only call
  2. if (handle.size > maxAssetBytes) return null   ← pre-buffer
  3. if (runningBytes + handle.size > maxTotalBytes) ← pre-buffer
     totalAborted = true; return null
  4. runningBytes += handle.size                    ← reserve
  5. await handle.read()                            ← only now
  6. if (read returned null) runningBytes -= refund

`totalAborted` is a shared flag the workers check at entry, so
once the running total hits the cap, no new reads start. With
maxReadConcurrency = 8, at most ~8 stat-side calls finish after
abort — peak memory bounded.

The concat-time guard stays as the exact final assertion (the
pre-buffer reservation is approximate — it counts the original tag
bytes and skips wrapper overhead).

Route closure updated to do `resolveProjectFilePath` first, then
`readProjectFile` inside the deferred `read()`. Test reader helpers
(`readerFrom` + the concurrency-test reader) updated to the new
shape.

Two new unit tests pin the pre-buffer semantics:

  - `maxAssetBytes` is checked via handle.size BEFORE handle.read()
    (the reader's `read()` throws — must never run)
  - Running total abort stops further reads once exceeded (counting
    reader observes ≤ 2 reads when cap should fire after the first)

Phase K of plan declarative-roaming-gosling.md (post-PR-#1312
round-3 review). All 38 tests green (36 prior + 2 round-4
pre-buffer cases).

Refs nexu-io/open-design#1312.

* test(daemon): add Red test pinning owner pre-buffer 413 before mime 415

PR #1312 round-5 (lefarcen P2): the route currently reads the owner
file with readProjectFile() before any size check, so a 100 MiB owner
HTML is fully buffered into memory before the helper's ownerBytes
check fires. The fix is to stat with resolveProjectFilePath first,
reject pre-buffer with 413 PAYLOAD_TOO_LARGE on oversize, then fold
in the mime check (still 415 on mismatch, now pre-buffer), then
readProjectFile when both gates pass.

The Red→Green discriminator is the combination 'oversize AND
non-HTML': pre-fix the route reads the buffer first and the
text/plain mime check fires → 415; post-fix the route stats first
and the size check fires before the mime check → 413. Asserting
'got 413, not 415' pins both the pre-buffer property and the check
ordering (size before mime, per lefarcen's locked round-5 sequence).

2 MiB+1 byte fixture is acceptable in test setup; MAX_INLINE_OWNER_BYTES
is the production 2 MiB so no test-door is needed.

Red verified: AssertionError: expected 415 to be 413 (pre-fix flow
reads → mime → 415).

* feat(daemon): stat owner before readProjectFile in /export route to bound owner pre-buffer

PR #1312 round-5 (lefarcen P2 confirmed at PR-1312#issuecomment-4424868413
follow-up): the route previously called readProjectFile() unconditionally
on the owner, so a 100 MiB owner HTML was fully buffered into memory
before the helper's ownerBytes check fired with InlineAssetsLimitError
('owner'). That meant the 413 envelope returned to the caller but only
after peak memory had already hit the file size.

Fix mirrors the sibling-asset stat-then-read contract round 4 added via
the AssetHandle interface: call resolveProjectFilePath first (cheap
stat), reject pre-buffer with 413 PAYLOAD_TOO_LARGE on size >
MAX_INLINE_OWNER_BYTES, fold in the mime check (still 415
UNSUPPORTED_MEDIA_TYPE on mismatch, now also pre-buffer per lefarcen's
'fold-in is welcome'), then readProjectFile() only when both gates
pass. Size check fires before mime check, so an oversize non-HTML file
returns 413 rather than 415 — the observable Red→Green discriminator
for this round.

The helper's ownerBytes check (inline-assets.ts:127-133) stays as
defense-in-depth for direct in-process callers that skip the route and
for any drift between stat-reported size and the bytes returned by
readFile.

Verifies the round-5 Red at apps/daemon/tests/export-inline-route.ts
('returns 413 (not 415) for an oversize non-HTML file'). Daemon suite
1743/1743 passing.

* test(daemon): add Red test pinning stat-vs-actual byte reconciliation

PR #1312 round-5 (lefarcen P3 confirmed at PR-1312#issuecomment-4424868413
follow-up): the helper trusts handle.size for the running-total guard
and never reconciles with the actual byte length of content unless the
per-asset cap is exceeded. A reader that under-reports size (stale
stat, UTF-8 expansion at decode, sparse file, deliberate lie) can let
many strings materialize in memory before the concat-time guard at
the bottom of inlineRelativeAssets throws — defeating the round-4
pre-buffer cap intent.

Fix is lefarcen-confirmed path-a: post-read, the helper computes
actualBytes = Buffer.byteLength(content, 'utf8'), reconciles
runningBytes (add actualBytes, refund handle.size), and if running
total exceeds maxTotalBytes flips totalAborted = true and returns
null. Subsequent workers see totalAborted before invoking their own
read(). Helper still throws InlineAssetsLimitError('total') after
Promise.all settles — preserving the round-2/3/4 graceful-fallback
pattern instead of racing throws across in-flight workers.

Red→Green discriminator is read count. Pre-fix the helper trusts
the lying handle.size (10), so both reads complete (each returning
1000 bytes) under the reservation total of 56+10+10=76 < cap 500.
The concat-time guard then catches the 2000+-byte assembly and
throws 'total' — but only after both reads materialized in memory.
Post-fix worker 1's reconciliation trips totalAborted as soon as
actualBytes (1000) is folded into runningBytes; worker 2 skips its
read.

Red verified: AssertionError expected 1, received 2 (pre-fix flow
completes both reads before concat-guard fires).

* feat(daemon): reconcile inliner reservation with post-read actual bytes

PR #1312 round-5 (lefarcen P3 confirmed at PR-1312#issuecomment-4424868413
follow-up, path-a): the helper trusted handle.size for the running-
total guard and only reconciled with actual bytes for the per-asset
cap. A reader that under-reported size — stale stat, UTF-8 decode
expansion at read time, sparse file, deliberate lie — could let
many strings materialize before the concat-time guard at the bottom
of inlineRelativeAssets caught the excess. That defeated the
round-4 pre-buffer cap intent.

Fix: after a successful read(), compute actualBytes =
Buffer.byteLength(content, 'utf8'), reconcile runningBytes by
folding in (actualBytes - handle.size), and re-check the total cap.
If the reconciliation pushes runningBytes past maxTotalBytes,
drop the asset's inlining (tag stays as URL ref), set
totalAborted = true to block subsequent worker reads, and let
Promise.all settle. The helper then throws
InlineAssetsLimitError('total') below — matching the round-2/3/4
graceful-fallback pattern (no throw-before-settle race between
in-flight workers).

The per-asset cap check at line 228 is preserved for stat-lying
readers that blow a single asset past maxAssetBytes; that branch
refunds handle.size and drops without flipping totalAborted, so
sibling assets still get a fair shot.

Verifies the round-5 Red at apps/daemon/tests/export-inline-route.ts
('reconciles handle.size with actual content bytes'). Daemon suite
1744/1744 passing.

---------

Co-authored-by: DevForgeAI CI/CD Engineer <devforge-ai@development.ai>
2026-05-12 16:48:16 +08:00

687 lines
31 KiB
TypeScript

import type http from 'node:http';
import { mkdir, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { inlineRelativeAssets, type InlineAssetReader } from '../src/inline-assets.js';
import { startServer } from '../src/server.js';
// ---------------------------------------------------------------------------
// Unit — inlineRelativeAssets pure helper
// ---------------------------------------------------------------------------
//
// These tests pin the behavior contract documented in
// `~/.claude/plans/declarative-roaming-gosling.md` §2.3. The helper is a
// server-side port of the web-client logic at `apps/web/src/components/
// FileViewer.tsx:5248-5354` (@ base SHA 5bd97631); the divergence from
// `FileViewer.tsx:5313` (replace-all vs first-match) is locked decision §3.3.
function readerFrom(files: Record<string, string>) {
return async (relPath: string) => {
const value = files[relPath];
if (typeof value !== 'string') return null;
return {
size: Buffer.byteLength(value, 'utf8'),
read: async () => value,
};
};
}
describe('inlineRelativeAssets', () => {
it('inlines a single <link rel=stylesheet> with verbatim CSS body', async () => {
const html =
'<!doctype html><html><head><link rel="stylesheet" href="a.css"></head><body></body></html>';
const out = await inlineRelativeAssets(html, 'index.html', readerFrom({ 'a.css': 'body{color:red}' }));
expect(out).toContain('<style data-od-inline-asset="a.css">');
expect(out).toContain('body{color:red}');
expect(out).not.toContain('<link rel="stylesheet" href="a.css">');
});
it('inlines a <script src> preserving non-src attrs (type=module, defer, crossorigin)', async () => {
const html =
'<html><head><script type="module" defer crossorigin src="x.js"></script></head></html>';
const out = await inlineRelativeAssets(html, 'index.html', readerFrom({ 'x.js': 'console.log(1)' }));
expect(out).toMatch(/<script[^>]*type="module"[^>]*>/);
expect(out).toMatch(/<script[^>]*\bdefer\b[^>]*>/);
expect(out).toMatch(/<script[^>]*\bcrossorigin\b[^>]*>/);
expect(out).toContain('console.log(1)');
expect(out).not.toContain('src="x.js"');
});
it('resolves relative paths for both nested and root owners', async () => {
const nestedOut = await inlineRelativeAssets(
'<script src="../shared/util.js"></script>',
'pages/index.html',
readerFrom({ 'shared/util.js': 'export const x = 1;' }),
);
expect(nestedOut).toContain('export const x = 1;');
const rootOut = await inlineRelativeAssets(
'<link rel="stylesheet" href="a.css">',
'index.html',
readerFrom({ 'a.css': '.root{}' }),
);
expect(rootOut).toContain('.root{}');
});
it('handles self-closing <link …/> form', async () => {
const html = '<link rel="stylesheet" href="a.css" />';
const out = await inlineRelativeAssets(html, 'index.html', readerFrom({ 'a.css': '/*ok*/' }));
expect(out).toContain('/*ok*/');
expect(out).not.toContain('href="a.css"');
});
it("accepts single-quoted attrs (href='a.css')", async () => {
const html = `<link rel='stylesheet' href='a.css'>`;
const out = await inlineRelativeAssets(html, 'index.html', readerFrom({ 'a.css': '/*single*/' }));
expect(out).toContain('/*single*/');
});
it('does NOT rewrite a <link> tag without a rel attribute', async () => {
const html = '<link href="a.css">';
const out = await inlineRelativeAssets(html, 'index.html', readerFrom({ 'a.css': '.x{}' }));
expect(out).toBe(html);
});
it('does NOT rewrite <link rel="preload"> (only rel=stylesheet)', async () => {
const html = '<link rel="preload" href="x.css">';
const out = await inlineRelativeAssets(html, 'index.html', readerFrom({ 'x.css': '.x{}' }));
expect(out).toBe(html);
});
it('does NOT rewrite absolute / data / blob / mailto / tel / anchor / leading-slash refs', async () => {
const cases = [
'<link rel="stylesheet" href="https://cdn.example.com/x.css">',
'<link rel="stylesheet" href="http://cdn.example.com/x.css">',
'<link rel="stylesheet" href="data:text/css,body{}">',
'<link rel="stylesheet" href="blob:abc">',
'<link rel="stylesheet" href="/abs/path.css">',
'<script src="https://cdn.example.com/x.js"></script>',
'<script src="data:text/javascript,1+1"></script>',
'<script src="/abs/x.js"></script>',
];
const reader = readerFrom({}); // never called
for (const html of cases) {
const out = await inlineRelativeAssets(html, 'index.html', reader);
expect(out).toBe(html);
}
});
it('escapes </style inside CSS body to <\\/style', async () => {
const css = 'body::before{content:"</style>"}';
const out = await inlineRelativeAssets(
'<link rel="stylesheet" href="a.css">',
'index.html',
readerFrom({ 'a.css': css }),
);
expect(out).toContain('<\\/style');
expect(out).not.toMatch(/<\/style[^>]*?>\s*<\/style>/);
expect(out.match(/<\/style>/g)?.length).toBe(1);
});
it('escapes </script inside JS body to <\\/script', async () => {
const js = 'const x = "</script>"';
const out = await inlineRelativeAssets(
'<script src="x.js"></script>',
'index.html',
readerFrom({ 'x.js': js }),
);
expect(out).toContain('<\\/script');
expect(out.match(/<\/script>/g)?.length).toBe(1);
});
it('leaves tag intact when fileReader returns null, but still inlines other assets', async () => {
const html =
'<link rel="stylesheet" href="missing.css"><script src="present.js"></script>';
const out = await inlineRelativeAssets(
html,
'index.html',
readerFrom({ 'present.js': 'ok' }),
);
expect(out).toContain('<link rel="stylesheet" href="missing.css">');
expect(out).toContain('ok');
expect(out).not.toContain('src="present.js"');
});
it('replaces ALL occurrences of identical duplicate tags (diverges from FileViewer.tsx:5313)', async () => {
// The web client uses `.replace(from, () => to)` which only replaces the
// first match. Locked decision §3.3: the server helper replaces all.
const html = '<script src="x.js"></script>\n<script src="x.js"></script>';
const out = await inlineRelativeAssets(html, 'index.html', readerFrom({ 'x.js': 'BODY' }));
expect(out.match(/src="x\.js"/g) ?? []).toEqual([]);
expect(out.match(/BODY/g)?.length).toBe(2);
});
it('HTML-escapes the href value in data-od-inline-asset attr', async () => {
// Using `&` only — the realistic case for filenames that need escaping.
// `<`, `>`, `"` are forbidden in real filenames on most platforms and
// additionally break the tag-matching regex (a limitation inherited
// from the web client at FileViewer.tsx:5271). The escapeHtmlAttr fn
// itself covers `&`, `"`, `<`, `>` by inspection.
const href = 'weird&name.css';
const html = `<link rel="stylesheet" href="${href}">`;
const out = await inlineRelativeAssets(html, 'index.html', readerFrom({ [href]: '.x{}' }));
expect(out).toContain('data-od-inline-asset="weird&amp;name.css"');
expect(out).not.toContain(`data-od-inline-asset="${href}"`);
});
it('does not treat "disabled" inside a quoted attribute value as the disabled boolean attr', async () => {
// PR #1312 round-2 review (lefarcen P3): the current
// `hasBooleanHtmlAttr` regex `\sdisabled(?=\s|=|/?>)` tests the
// tag string with NO attr-quoting awareness, so the literal text
// `disabled` appearing inside any quoted attribute value, followed
// by another whitespace char, satisfies the lookahead. A source
// tag like
// <link rel=stylesheet href=x.css data-note="content disabled stuff">
// would then emit a <style disabled> block — silently disabling
// a stylesheet the author wrote without that attr.
const html =
'<link rel="stylesheet" href="x.css" data-note="content disabled stuff">';
const out = await inlineRelativeAssets(html, 'index.html', readerFrom({ 'x.css': '.x{}' }));
expect(out).toMatch(/<style\b[^>]*data-od-inline-asset/);
expect(out).not.toMatch(/<style\b[^>]*\bdisabled\b/);
});
it('still detects disabled when it is a real boolean attr (regression for the dedup fix)', async () => {
// Counterweight to the previous case: don't over-correct and
// start dropping the legitimate `disabled` attr.
const html = '<link rel="stylesheet" href="x.css" disabled>';
const out = await inlineRelativeAssets(html, 'index.html', readerFrom({ 'x.css': '.x{}' }));
expect(out).toMatch(/<style\b[^>]*\bdisabled\b/);
});
it('preserves <link> attrs (media, title, disabled, nonce) on the generated <style> tag', async () => {
// PR #1312 round-2 (lefarcen P2 @ inline-assets.ts:44): a stylesheet
// <link> with `media="print"` was becoming a plain <style> with no
// media query, so print-only styles applied unconditionally. Same
// problem for `title` (alternate stylesheet sets), `disabled`
// (initial disabled state), `nonce` (CSP nonce). All four are valid
// attributes on both <link rel=stylesheet> and <style> per HTML
// spec, so the inliner should copy them across.
const html =
'<link rel="stylesheet" href="print.css" media="print" title="Print">' +
'<link rel="stylesheet" href="alt.css" disabled>' +
'<link rel="stylesheet" href="csp.css" nonce="abc123">';
const out = await inlineRelativeAssets(
html,
'index.html',
readerFrom({
'print.css': '.p{}',
'alt.css': '.a{}',
'csp.css': '.c{}',
}),
);
expect(out).toMatch(/<style\b[^>]*\bmedia="print"[^>]*>[\s\S]*?\.p\{\}/);
expect(out).toMatch(/<style\b[^>]*\btitle="Print"[^>]*>[\s\S]*?\.p\{\}/);
expect(out).toMatch(/<style\b[^>]*\bdisabled\b[^>]*>[\s\S]*?\.a\{\}/);
expect(out).toMatch(/<style\b[^>]*\bnonce="abc123"[^>]*>[\s\S]*?\.c\{\}/);
});
it('resolves deep-nested owner (a/b/c/index.html + ../../shared/util.js)', async () => {
const out = await inlineRelativeAssets(
'<script src="../../shared/util.js"></script>',
'a/b/c/index.html',
readerFrom({ 'a/shared/util.js': 'DEEP' }),
);
expect(out).toContain('DEEP');
expect(out).not.toContain('src="../../shared/util.js"');
});
// ---- Cap enforcement (PR #1312 round-3, lefarcen P2) ---------------
// The helper accepts an InlineOptions bag (test-door per
// feedback_test_doors_over_fake_timers.md) so the tests can exercise
// each cap with tiny fixtures rather than 2-50 MiB on-disk writes.
// Production callers use the module-level defaults.
// --------------------------------------------------------------------
it('throws InlineAssetsLimitError("owner") when the owner html exceeds maxOwnerBytes', async () => {
const html = '<html><head>' + 'x'.repeat(500) + '</head></html>';
await expect(
inlineRelativeAssets(html, 'index.html', readerFrom({}), { maxOwnerBytes: 100 }),
).rejects.toMatchObject({
name: 'InlineAssetsLimitError',
limit: 'owner',
});
});
it('throws InlineAssetsLimitError("candidates") when tag matches exceed maxCandidates', async () => {
// Build HTML with 5 link tags, cap at 3.
const html = Array.from({ length: 5 }, (_, i) =>
`<link rel="stylesheet" href="a${i}.css">`,
).join('');
await expect(
inlineRelativeAssets(html, 'index.html', readerFrom({}), { maxCandidates: 3 }),
).rejects.toMatchObject({
name: 'InlineAssetsLimitError',
limit: 'candidates',
});
});
it('leaves a tag intact (no replacement) when its asset body exceeds maxAssetBytes', async () => {
const html =
'<link rel="stylesheet" href="big.css"><link rel="stylesheet" href="small.css">';
const out = await inlineRelativeAssets(
html,
'index.html',
readerFrom({
'big.css': 'a'.repeat(2000), // exceeds cap
'small.css': '.s{}',
}),
{ maxAssetBytes: 1000 },
);
// Oversized asset stays as a URL ref (graceful — the export still
// succeeds; the consumer sees an un-inlined link instead of inflated
// memory or a 413 for one bad asset).
expect(out).toContain('<link rel="stylesheet" href="big.css">');
// The small asset still inlines normally.
expect(out).toContain('.s{}');
expect(out).not.toContain('href="small.css"');
});
it('throws InlineAssetsLimitError("total") when the assembled output exceeds maxTotalBytes', async () => {
const html =
'<link rel="stylesheet" href="a.css"><link rel="stylesheet" href="b.css">';
const big = 'x'.repeat(800);
await expect(
inlineRelativeAssets(
html,
'index.html',
readerFrom({ 'a.css': big, 'b.css': big }),
{ maxTotalBytes: 1000 },
),
).rejects.toMatchObject({
name: 'InlineAssetsLimitError',
limit: 'total',
});
});
it('checks maxAssetBytes via handle.size BEFORE invoking handle.read()', async () => {
// PR #1312 round-4 (lefarcen P2): the maxAssetBytes cap must fire
// pre-buffer. A reader whose read() throws is fine — the helper
// must not invoke it once the stat-side size exceeds the cap.
let readsAttempted = 0;
const sizeOnlyReader = async (relPath: string) => ({
size: 10_000,
read: async (): Promise<string | null> => {
readsAttempted += 1;
throw new Error(`read should not happen for ${relPath}`);
},
});
const html = '<link rel="stylesheet" href="big.css">';
const out = await inlineRelativeAssets(html, 'index.html', sizeOnlyReader, {
maxAssetBytes: 1_000,
});
expect(readsAttempted).toBe(0);
expect(out).toContain('<link rel="stylesheet" href="big.css">');
});
it('stops dispatching reads once running total exceeds maxTotalBytes', async () => {
// PR #1312 round-4 (lefarcen P2): the running-total guard must
// abort the worker pool, not wait for the final concat. With a
// tiny totalBytes cap and 20 candidates each contributing 800
// bytes of stat-size, we expect at most a few reads to actually
// run before the abort flag short-circuits the rest. Concurrency
// is 1 so the abort timing is deterministic.
let reads = 0;
const countingReader = async (relPath: string) => ({
size: 800,
read: async () => {
reads += 1;
return `/* ${relPath} */`;
},
});
const html = Array.from({ length: 20 }, (_, i) =>
`<link rel="stylesheet" href="a${i}.css">`,
).join('');
await expect(
inlineRelativeAssets(html, 'index.html', countingReader, {
maxTotalBytes: 1_000,
maxReadConcurrency: 1,
}),
).rejects.toMatchObject({ name: 'InlineAssetsLimitError', limit: 'total' });
// Owner html is ~760 bytes. First asset's 800 stat-size pushes
// running over 1000 → abort. So at most ONE read should fire.
expect(reads).toBeLessThanOrEqual(2);
});
it('reconciles handle.size with actual content bytes — trips total abort post-read on stat-lying readers', async () => {
// PR #1312 round-5 (lefarcen P3 confirmed at PR-1312#issuecomment-4424868413
// follow-up, path-a): the helper must reconcile handle.size with the
// actual byte length of `content` AFTER `read()`, not just trust the
// stat-side number. A reader that under-reports size (stale stat,
// UTF-8 expansion at decode, sparse file, deliberate lie) would
// otherwise let many strings materialize before the concat-time
// guard at the bottom of the helper throws — defeating the round-4
// pre-buffer cap intent.
//
// Discriminator: read count. Pre-fix the helper trusts handle.size
// (10), so both reads complete (each returning 1000 bytes) under
// the reservation total of 56+10+10=76 < cap 500; the concat-time
// guard then catches the 2000+-byte assembly and throws 'total'.
// Post-fix worker 1's reconciliation trips totalAborted as soon as
// its actualBytes (1000) is added to runningBytes, pushing running
// over the cap; worker 2 then sees totalAborted and returns null
// without invoking read(). One read, not two.
//
// Lefarcen-confirmed path-a (drop-asset + abort + throw 'total'
// after Promise.all settles): preserves the round-2/3/4 graceful-
// fallback pattern instead of racing throws between in-flight
// workers.
let reads = 0;
const lyingReader: InlineAssetReader = async (_relPath: string) => ({
size: 10, // stat lies — actual is 100x
read: async () => {
reads += 1;
return 'x'.repeat(1000);
},
});
const html = '<script src="a.js"></script><script src="b.js"></script>';
await expect(
inlineRelativeAssets(html, 'index.html', lyingReader, {
maxTotalBytes: 500,
maxReadConcurrency: 1, // sequential so the abort timing is deterministic
}),
).rejects.toMatchObject({ name: 'InlineAssetsLimitError', limit: 'total' });
// Pre-fix: 2 (helper trusts stat → both reads complete → concat catches).
// Post-fix: 1 (worker 1 reconciles after read, trips abort; worker 2 skipped).
expect(reads).toBe(1);
});
it('caps concurrent file reads at maxReadConcurrency', async () => {
// A reader that records peak concurrency inside read(): increments
// on entry, decrements on exit, tracks the high-water mark. The
// size lookup is synchronous-fast so it doesn't contribute.
let inFlight = 0;
let peak = 0;
const readerWithCounter = async (relPath: string) => {
const body = `/* ${relPath} */`;
return {
size: Buffer.byteLength(body, 'utf8'),
read: async () => {
inFlight += 1;
if (inFlight > peak) peak = inFlight;
// Yield a microtask so other concurrent calls can interleave.
await new Promise((r) => setImmediate(r));
inFlight -= 1;
return body;
},
};
};
const html = Array.from({ length: 20 }, (_, i) =>
`<link rel="stylesheet" href="a${i}.css">`,
).join('');
await inlineRelativeAssets(html, 'index.html', readerWithCounter, { maxReadConcurrency: 4 });
expect(peak).toBeLessThanOrEqual(4);
expect(peak).toBeGreaterThan(0);
});
it('does not re-replace a tag literal that appears inside an already-inlined asset body', async () => {
// Regression for nexu-io/open-design#1312 review feedback (Siri-Ray
// looper + codex bot): the previous reduce/split-join approach
// re-scanned the progressively mutated HTML, so a tag literal that
// happened to appear inside an inlined asset body got the inner
// literal also replaced — corrupting the body.
//
// The reproducer uses two <link rel=stylesheet> tags where a.css's
// body contains the literal text of b.css's <link> tag (e.g. inside
// a CSS comment or content: declaration). The </style escape on
// CSS bodies doesn't touch <link>, so split/join over the mutated
// HTML finds the literal inside a.css's inline body and replaces
// it on the second pass — injecting b.css's inline body where the
// literal comment text used to be.
const html =
'<link rel="stylesheet" href="a.css"><link rel="stylesheet" href="b.css">';
const aCssBody = '/* see also <link rel="stylesheet" href="b.css"> */';
const bCssBody = 'body{color:red}';
const out = await inlineRelativeAssets(
html,
'index.html',
readerFrom({ 'a.css': aCssBody, 'b.css': bCssBody }),
);
// The literal <link> string inside a.css's comment must survive
// verbatim — position-based replacement only touches the original
// outer-tag spans, not text introduced by earlier replacements.
expect(out).toContain('/* see also <link rel="stylesheet" href="b.css"> */');
// b.css's body is inlined exactly once, at the real outer tag's
// position — not injected inside a.css's inline body.
expect(out.match(/body\{color:red\}/g)?.length).toBe(1);
// Neither original outer <link href="…"> survives as a URL ref.
expect(out).not.toMatch(/<link\b[^>]*\bhref="a\.css"/);
expect(out).not.toMatch(/<link\b[^>]*\bhref="b\.css"(?![^<]*\*\/)/);
});
});
// ---------------------------------------------------------------------------
// HTTP integration — GET /api/projects/:id/export/*?inline=1
// ---------------------------------------------------------------------------
describe('GET /api/projects/:id/export/*?inline=1 route', () => {
let server: http.Server;
let baseUrl: string;
let projectsRoot: string;
const projectId = 'proj-export-inline-test';
const cssBody = 'body{color:#0a0}';
const jsBody = 'window.OD_EXPORT_OK = 42;';
const nestedJsBody = 'export const N = 7;';
beforeAll(async () => {
const started = (await startServer({ port: 0, returnServer: true })) as {
url: string;
server: http.Server;
};
baseUrl = started.url;
server = started.server;
projectsRoot = path.join(process.env.OD_DATA_DIR!, 'projects');
const dir = path.join(projectsRoot, projectId);
const pages = path.join(dir, 'pages');
const shared = path.join(dir, 'shared');
await mkdir(dir, { recursive: true });
await mkdir(pages, { recursive: true });
await mkdir(shared, { recursive: true });
await writeFile(
path.join(dir, 'index.html'),
'<!doctype html><html><head>' +
'<link rel="stylesheet" href="app.css">' +
'<script src="app.js"></script>' +
'</head><body><div id="root"></div></body></html>',
);
await writeFile(path.join(dir, 'app.css'), cssBody);
await writeFile(path.join(dir, 'app.js'), jsBody);
await writeFile(
path.join(dir, 'partial.html'),
'<!doctype html><html><head>' +
'<link rel="stylesheet" href="missing.css">' +
'<script src="app.js"></script>' +
'</head><body></body></html>',
);
await writeFile(
path.join(pages, 'index.html'),
'<!doctype html><html><head>' +
'<script src="../shared/util.js"></script>' +
'</head></html>',
);
await writeFile(path.join(shared, 'util.js'), nestedJsBody);
});
afterAll(() => new Promise<void>((resolve) => server.close(() => resolve())));
const exportUrl = (name: string, query = 'inline=1') =>
`${baseUrl}/api/projects/${projectId}/export/${name}${query ? `?${query}` : ''}`;
it('returns a self-contained HTML body when ?inline=1 on a 3-file layout', async () => {
const res = await fetch(exportUrl('index.html'));
expect(res.status).toBe(200);
expect(res.headers.get('content-type')).toContain('text/html');
const body = await res.text();
// Wiring guard: removing the await inlineRelativeAssets(...) line in the
// handler fails these assertions, not just the helper-internals tests.
expect(body).toContain(cssBody);
expect(body).toContain(jsBody);
expect(body).not.toContain('href="app.css"');
expect(body).not.toContain('src="app.js"');
expect(body).toContain('<style data-od-inline-asset="app.css">');
});
it('returns 400 BAD_REQUEST when ?inline is missing', async () => {
const res = await fetch(exportUrl('index.html', ''));
expect(res.status).toBe(400);
const body = (await res.json()) as { error: { code: string } };
expect(body.error.code).toBe('BAD_REQUEST');
});
it('returns 400 for non-canonical inline values (0, false, foo)', async () => {
for (const q of ['inline=0', 'inline=false', 'inline=foo', 'inline=']) {
const res = await fetch(exportUrl('index.html', q));
expect(res.status).toBe(400);
}
});
it('returns 415 UNSUPPORTED_MEDIA_TYPE for non-HTML files', async () => {
// Drift fix discovered in PR #1312 round-3: the round-1 code emitted
// `UNSUPPORTED_FILE_TYPE` (status 400) which is not a registered
// ApiErrorCode in packages/contracts/src/errors.ts. The canonical
// code for "wrong content type" is UNSUPPORTED_MEDIA_TYPE with HTTP
// 415, so the route now uses both.
const res = await fetch(exportUrl('app.css'));
expect(res.status).toBe(415);
const body = (await res.json()) as { error: { code: string } };
expect(body.error.code).toBe('UNSUPPORTED_MEDIA_TYPE');
});
it('returns 404 FILE_NOT_FOUND for a nonexistent file', async () => {
const res = await fetch(exportUrl('missing.html'));
expect(res.status).toBe(404);
const body = (await res.json()) as { error: { code: string } };
expect(body.error.code).toBe('FILE_NOT_FOUND');
});
it('returns 400 BAD_REQUEST for an invalid project id (..)', async () => {
const res = await fetch(`${baseUrl}/api/projects/../export/index.html?inline=1`);
// Express normalizes `..` segments before routing, so this should not
// reach our handler; the daemon's middleware or routing answers first.
// Either way, the request must NOT succeed at extracting a parent
// directory.
expect(res.status).toBeGreaterThanOrEqual(400);
expect(res.status).toBeLessThan(500);
});
it('rejects null-origin requests with 403 (export is for same-origin / server-side callers only)', async () => {
// Unlike /raw/*, the /export/* route is NOT in the daemon's null-
// origin allowlist (server.ts _NULL_ORIGIN_SAFE_GET_RE). The export
// consumer set is the daemon UI (same-origin) and server-side
// screenshot tooling (no Origin header at all); sandboxed-iframe
// srcdoc previews fetch through /raw/ instead, where each asset has
// its own URL. This test pins the contract so a future change that
// adds /export/ to the allowlist has to update it deliberately.
const res = await fetch(exportUrl('index.html'), { headers: { Origin: 'null' } });
expect(res.status).toBe(403);
});
it('returns 200 with the <link> tag intact when a sibling asset is missing', async () => {
const res = await fetch(exportUrl('partial.html'));
expect(res.status).toBe(200);
const body = await res.text();
expect(body).toContain('<link rel="stylesheet" href="missing.css">');
expect(body).toContain(jsBody);
expect(body).not.toContain('src="app.js"');
});
it('inlines a nested HTML entry (pages/index.html + ../shared/util.js)', async () => {
const res = await fetch(exportUrl('pages/index.html'));
expect(res.status).toBe(200);
const body = await res.text();
expect(body).toContain(nestedJsBody);
expect(body).not.toContain('src="../shared/util.js"');
});
it('sends Content-Security-Policy: sandbox allow-scripts to block daemon-origin privilege escalation', async () => {
// PR #1312 round-2 review (lefarcen P2 @ import-export-routes.ts:423):
// top-level browser navigation to the export URL sends no Origin
// header, so the daemon middleware lets it through and any JS in
// the exported document runs with daemon-origin privileges (access
// to /api/, cookies, localStorage). CSP `sandbox allow-scripts`
// treats the response like a sandboxed iframe with an opaque origin:
// scripts execute (which the export needs — that's the whole point
// of inlining JS) but cannot read cookies, hit /api/, or otherwise
// escalate to the daemon's origin.
const res = await fetch(exportUrl('index.html'));
expect(res.status).toBe(200);
expect(res.headers.get('content-security-policy')).toBe('sandbox allow-scripts');
});
it('accepts inline=true / yes / on / TRUE / Yes / ON (case-insensitive accept list per decision §7)', async () => {
// PR #1312 round-2 review (lefarcen P3 @ export-inline-route.test.ts:262):
// PR body decision §7 promises `inline=true/yes/on` case-insensitive
// matching parseForceInline at file-viewer-render-mode.ts:59-66, but
// round-1 tests only exercised inline=1. Pin the full accept list.
for (const q of ['inline=true', 'inline=yes', 'inline=on', 'inline=TRUE', 'inline=Yes', 'inline=ON']) {
const res = await fetch(exportUrl('index.html', q));
expect(res.status).toBe(200);
}
});
it('returns 413 PAYLOAD_TOO_LARGE when the owner file blows past the candidates cap', async () => {
// PR #1312 round-3 (lefarcen P2): the route must surface the
// InlineAssetsLimitError as a structured 413 envelope, not let it
// propagate as a 400 BAD_REQUEST. Generated owner has 501
// `<link rel=stylesheet>` tags, one above the default
// MAX_INLINE_CANDIDATES (500). The candidates cap fires after
// matchAll, BEFORE any sibling read, so the fact that `a.css`
// doesn't exist on disk is irrelevant.
const dir = path.join(projectsRoot, projectId);
const huge = '<!doctype html><html><head>' +
'<link rel="stylesheet" href="a.css">'.repeat(501) +
'</head></html>';
await writeFile(path.join(dir, 'too-many-tags.html'), huge);
const res = await fetch(exportUrl('too-many-tags.html'));
expect(res.status).toBe(413);
const body = (await res.json()) as { error: { code: string } };
expect(body.error.code).toBe('PAYLOAD_TOO_LARGE');
});
it('returns 413 (not 415) for an oversize non-HTML file — proves owner cap fires pre-buffer', async () => {
// PR #1312 round-5 (lefarcen P2): the route must stat the owner with
// resolveProjectFilePath BEFORE readProjectFile and reject sizes
// above MAX_INLINE_OWNER_BYTES with 413 PAYLOAD_TOO_LARGE. The
// Red→Green discriminator is the combination "oversize AND
// non-HTML": pre-fix, the route reads the buffer first and the
// text/plain mime check at file.mime fires → 415. Post-fix, the
// route stats first and the size check fires before the mime
// check → 413. Asserting "got 413, not 415" pins both the
// pre-buffer property and the check ordering (size before mime,
// per lefarcen's locked round-5 sequence).
//
// 2 MiB+1 byte fixture is acceptable in test setup; MAX_INLINE_OWNER_BYTES
// is 2 MiB so this is the minimal fixture that exceeds the cap with the
// production constant (no test-door needed).
const dir = path.join(projectsRoot, projectId);
const overCap = 2 * 1024 * 1024 + 1;
await writeFile(path.join(dir, 'huge.txt'), Buffer.alloc(overCap, 0x61));
const res = await fetch(exportUrl('huge.txt'));
expect(res.status).toBe(413);
const body = (await res.json()) as { error: { code: string } };
expect(body.error.code).toBe('PAYLOAD_TOO_LARGE');
});
it('rejects an invalid project id (chars outside isSafeId char class) with 400 BAD_REQUEST', async () => {
// PR #1312 round-2 review (lefarcen P3 @ export-inline-route.test.ts:287):
// the previous `..` test was rejected by Express path normalization
// before the route saw it, so it didn't actually exercise the
// isSafeId guard. We need an id that (a) Express passes through
// unchanged into req.params and (b) isSafeId rejects. The `!` char
// is URL-safe (no percent-encoding needed) and not in isSafeId's
// /^[A-Za-z0-9._-]+$/ char class, so it hits the route's first
// checkpoint and returns the documented envelope.
const res = await fetch(exportUrl('index.html').replace(`/${projectId}/`, '/bad!id/'));
expect(res.status).toBe(400);
const body = (await res.json()) as { error: { code: string; message: string } };
expect(body.error.code).toBe('BAD_REQUEST');
expect(body.error.message).toContain('invalid project id');
});
});