mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
* 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 from58151356turns 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>
687 lines
31 KiB
TypeScript
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&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');
|
|
});
|
|
});
|