mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
686 lines
31 KiB
TypeScript
686 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');
|
|
});
|
|
});
|