mirror of
https://github.com/nexu-io/open-design.git
synced 2026-09-20 06:15:06 +08:00
7a37c1462d
* feat(export): add standalone HTML export Bundle project-local HTML, CSS, JavaScript, images, fonts, workers, and iframe dependencies into one offline-safe file. Expose the same contract through the web download flow and od CLI, with structured failures and bounded output assembly. * fix(daemon): honor standalone source URL semantics Resolve document assets against local base href values, reject browser-invalid TypeScript and JSX sources with a structured 422, and count unquoted first-level resources before graph traversal. Validated with daemon tests, workspace typecheck, and guard. * fix(daemon): handle standalone data scripts and CSS URL tokens Preserve non-JavaScript script payloads as data blocks and scan CSS values without treating strings or comments as project dependencies. Validated with focused and full daemon tests, workspace typecheck, and guard. * fix(daemon): classify standalone export resources correctly Preserve non-HTML iframe MIME types, inline only resource-bearing link relations, and propagate unexpected Vite dist read failures instead of silently falling back. Validated with focused export tests, the full daemon suite, guard, and workspace typecheck. * fix(export): preserve version and stylesheet semantics Hide standalone HTML export for historical versions until dependency snapshots are available, avoiding an action that always fails. Keep disabled and alternate stylesheets as embedded link elements so standalone export preserves activation semantics. Validated with focused red/green coverage, the full web suite, pnpm guard, and pnpm typecheck. --------- Co-authored-by: Cheems <94773058+itscheems@users.noreply.github.com>
908 lines
41 KiB
TypeScript
908 lines
41 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;
|
|
|
|
const createProject = await fetch(`${baseUrl}/api/projects`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({
|
|
id: projectId,
|
|
name: 'Standalone export integration fixture',
|
|
metadata: { kind: 'prototype', entryFile: 'index.html' },
|
|
skipDiscoveryBrief: true,
|
|
}),
|
|
});
|
|
expect(createProject.status).toBe(200);
|
|
|
|
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('exports a Vite dev HTML entry through the built dist artifact for offline HTML downloads', async () => {
|
|
const dir = path.join(projectsRoot, projectId);
|
|
await mkdir(path.join(dir, 'dist', 'assets'), { recursive: true });
|
|
await writeFile(
|
|
path.join(dir, 'vite-entry.html'),
|
|
'<!doctype html><html><head><script type="module" src="/src/main.tsx"></script></head><body><div id="root"></div></body></html>',
|
|
);
|
|
await writeFile(
|
|
path.join(dir, 'dist', 'index.html'),
|
|
'<!doctype html><html><head>' +
|
|
'<script type="module" crossorigin src="/assets/app.js"></script>' +
|
|
'<link rel="stylesheet" crossorigin href="/assets/app.css">' +
|
|
'</head><body><div id="root"></div></body></html>',
|
|
);
|
|
await writeFile(path.join(dir, 'dist', 'assets', 'app.js'), 'window.VITE_EXPORT_OK = true;');
|
|
await writeFile(path.join(dir, 'dist', 'assets', 'app.css'), 'body{background:#123456}');
|
|
|
|
const res = await fetch(exportUrl('vite-entry.html'));
|
|
expect(res.status).toBe(200);
|
|
const body = await res.text();
|
|
|
|
expect(body).toContain('window.VITE_EXPORT_OK = true;');
|
|
expect(body).toContain('body{background:#123456}');
|
|
expect(body).not.toContain('/src/main.tsx');
|
|
expect(body).not.toContain('/assets/app.js');
|
|
expect(body).not.toContain('/assets/app.css');
|
|
expect(body).toContain('data-od-inline-asset="assets/app.css"');
|
|
});
|
|
|
|
it('exports a nested Vite dev HTML entry through its sibling built dist artifact', async () => {
|
|
const dir = path.join(projectsRoot, projectId, 'pages');
|
|
await mkdir(path.join(dir, 'dist', 'assets'), { recursive: true });
|
|
await writeFile(
|
|
path.join(dir, 'nested-vite.html'),
|
|
'<!doctype html><html><head><script type="module" src="/src/main.tsx"></script></head><body><div id="root"></div></body></html>',
|
|
);
|
|
await writeFile(
|
|
path.join(dir, 'dist', 'index.html'),
|
|
'<!doctype html><html><head>' +
|
|
'<script type="module" crossorigin src="/assets/nested.js"></script>' +
|
|
'<link rel="stylesheet" crossorigin href="/assets/nested.css">' +
|
|
'</head><body><div id="root"></div></body></html>',
|
|
);
|
|
await writeFile(path.join(dir, 'dist', 'assets', 'nested.js'), 'window.NESTED_VITE_EXPORT_OK = true;');
|
|
await writeFile(path.join(dir, 'dist', 'assets', 'nested.css'), 'body{background:#abcdef}');
|
|
|
|
const res = await fetch(exportUrl('pages/nested-vite.html'));
|
|
expect(res.status).toBe(200);
|
|
const body = await res.text();
|
|
|
|
expect(body).toContain('window.NESTED_VITE_EXPORT_OK = true;');
|
|
expect(body).toContain('body{background:#abcdef}');
|
|
expect(body).not.toContain('/src/main.tsx');
|
|
expect(body).not.toContain('/assets/nested.js');
|
|
expect(body).not.toContain('/assets/nested.css');
|
|
expect(body).toContain('data-od-inline-asset="assets/nested.css"');
|
|
});
|
|
|
|
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');
|
|
});
|
|
});
|
|
|
|
describe('POST /api/projects/:id/export/html route', () => {
|
|
let server: http.Server;
|
|
let baseUrl: string;
|
|
let projectsRoot: string;
|
|
const projectId = 'proj-standalone-html-test';
|
|
|
|
beforeAll(async () => {
|
|
const started = (await startServer({ port: 0, returnServer: true })) as {
|
|
url: string;
|
|
server: http.Server;
|
|
};
|
|
baseUrl = started.url;
|
|
server = started.server;
|
|
const created = await fetch(`${baseUrl}/api/projects`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({
|
|
id: projectId,
|
|
name: 'Standalone HTML route fixture',
|
|
metadata: { kind: 'prototype', entryFile: 'pages/index.html' },
|
|
skipDiscoveryBrief: true,
|
|
}),
|
|
});
|
|
expect(created.status).toBe(200);
|
|
|
|
projectsRoot = path.join(process.env.OD_DATA_DIR!, 'projects');
|
|
const root = path.join(projectsRoot, projectId);
|
|
await Promise.all([
|
|
mkdir(path.join(root, 'pages'), { recursive: true }),
|
|
mkdir(path.join(root, 'styles'), { recursive: true }),
|
|
mkdir(path.join(root, 'scripts'), { recursive: true }),
|
|
mkdir(path.join(root, 'assets'), { recursive: true }),
|
|
]);
|
|
await mkdir(path.join(root, 'pages', 'dist', 'index.html'), { recursive: true });
|
|
await writeFile(
|
|
path.join(root, 'pages', 'index.html'),
|
|
'<!doctype html><html><head>' +
|
|
'<link rel="stylesheet" href="../styles/site.css">' +
|
|
'</head><body><img src="../assets/hero.png?rev=1" ' +
|
|
'srcset="/assets/hero.png 1x, ../assets/hero@2x.png#crop 2x">' +
|
|
'<script type="module" src="../scripts/main.js"></script></body></html>',
|
|
);
|
|
await writeFile(
|
|
path.join(root, 'styles', 'site.css'),
|
|
'@import "./theme.css"; .hero{background:url("../assets/bg.svg#shape")}',
|
|
);
|
|
await writeFile(path.join(root, 'styles', 'theme.css'), '@font-face{font-family:x;src:url("../assets/font.woff2")}');
|
|
await writeFile(
|
|
path.join(root, 'scripts', 'main.js'),
|
|
'import { start } from "./motion.js"; const icon = new URL("../assets/icon.svg", import.meta.url); start(icon);',
|
|
);
|
|
await writeFile(path.join(root, 'scripts', 'motion.js'), 'export const start = (icon) => document.body.dataset.motion = icon.href;');
|
|
await writeFile(
|
|
path.join(root, 'scripts', 'invalid.tsx'),
|
|
'const label: string = "offline"; document.body.append(<div>{label}</div>);',
|
|
);
|
|
await writeFile(path.join(root, 'assets', 'hero.png'), Buffer.from([0x89, 0x50, 0x4e, 0x47]));
|
|
await writeFile(path.join(root, 'assets', 'hero@2x.png'), Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x32]));
|
|
await writeFile(path.join(root, 'assets', 'bg.svg'), '<svg id="shape"/>');
|
|
await writeFile(path.join(root, 'assets', 'icon.svg'), '<svg/>');
|
|
await writeFile(path.join(root, 'assets', 'font.woff2'), Buffer.from([1, 2, 3, 4]));
|
|
await writeFile(
|
|
path.join(root, 'pages', 'missing.html'),
|
|
'<!doctype html><img src="../assets/does-not-exist.png">',
|
|
);
|
|
await writeFile(
|
|
path.join(root, 'pages', 'invalid-source.html'),
|
|
'<!doctype html><script type="module" src="../scripts/invalid.tsx"></script>',
|
|
);
|
|
await writeFile(
|
|
path.join(root, 'pages', 'vite-dist-read-error.html'),
|
|
'<!doctype html><script type="module" src="/src/main.tsx"></script>',
|
|
);
|
|
});
|
|
|
|
afterAll(() => new Promise<void>((resolve) => server.close(() => resolve())));
|
|
|
|
const postExport = (body: Record<string, unknown>) => fetch(
|
|
`${baseUrl}/api/projects/${projectId}/export/html`,
|
|
{
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify(body),
|
|
},
|
|
);
|
|
|
|
it('embeds nested HTML, CSS, image, font, and module dependencies into one file', async () => {
|
|
const response = await postExport({ fileName: 'pages/index.html', title: 'Offline Demo' });
|
|
expect(response.status).toBe(200);
|
|
expect(response.headers.get('content-type')).toContain('text/html');
|
|
expect(response.headers.get('content-disposition')).toContain('Offline-Demo.html');
|
|
expect(response.headers.get('content-security-policy')).toBe('sandbox allow-scripts');
|
|
const html = await response.text();
|
|
expect(html).toContain('data:image/png;base64,');
|
|
expect(html).toContain('data:image/svg+xml;base64,');
|
|
expect(html).toContain('data:font/woff2;base64,');
|
|
expect(html).toContain(Buffer.from(
|
|
'export const start = (icon) => document.body.dataset.motion = icon.href;',
|
|
).toString('base64'));
|
|
expect(html).toContain('type="importmap"');
|
|
expect(html).not.toContain('../assets/hero.png');
|
|
expect(html).not.toContain('../assets/bg.svg');
|
|
expect(html).not.toContain('../assets/font.woff2');
|
|
expect(html).not.toContain('../scripts/main.js');
|
|
});
|
|
|
|
it('returns a structured dependency chain instead of a successful broken file', async () => {
|
|
const response = await postExport({ fileName: 'pages/missing.html' });
|
|
expect(response.status).toBe(422);
|
|
const body = (await response.json()) as {
|
|
error: { code: string; details: { kind: string; dependency: string; chain: string[] } };
|
|
};
|
|
expect(body.error.code).toBe('VALIDATION_FAILED');
|
|
expect(body.error.details).toEqual({
|
|
kind: 'missing-local-dependency',
|
|
dependency: 'assets/does-not-exist.png',
|
|
chain: ['pages/missing.html', 'assets/does-not-exist.png'],
|
|
});
|
|
});
|
|
|
|
it('returns 422 for TypeScript or JSX that cannot execute directly in a browser', async () => {
|
|
const response = await postExport({ fileName: 'pages/invalid-source.html' });
|
|
expect(response.status).toBe(422);
|
|
const body = (await response.json()) as {
|
|
error: { code: string; details: { kind: string; dependency: string; chain: string[] } };
|
|
};
|
|
expect(body.error.code).toBe('VALIDATION_FAILED');
|
|
expect(body.error.details).toEqual({
|
|
kind: 'invalid-source',
|
|
dependency: 'scripts/invalid.tsx',
|
|
chain: ['pages/invalid-source.html', 'scripts/invalid.tsx'],
|
|
});
|
|
});
|
|
|
|
it('reports a Vite dist read failure instead of silently falling back to dev HTML', async () => {
|
|
const response = await postExport({ fileName: 'pages/vite-dist-read-error.html' });
|
|
expect(response.status).toBe(400);
|
|
const body = (await response.json()) as { error: { code: string; message: string } };
|
|
expect(body.error.code).toBe('BAD_REQUEST');
|
|
expect(body.error.message).toContain('EISDIR');
|
|
});
|
|
|
|
it('rejects historical entries until their dependency graph is versioned', async () => {
|
|
const response = await postExport({ fileName: 'pages/index.html', versionId: 'v1' });
|
|
expect(response.status).toBe(409);
|
|
const body = (await response.json()) as { error: { code: string; details: { kind: string } } };
|
|
expect(body.error.code).toBe('CONFLICT');
|
|
expect(body.error.details.kind).toBe('historical-dependency-snapshot-unavailable');
|
|
});
|
|
});
|