mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
* feat(od-next): open registered runtimes and CLI BYOK Use the effective runtime registry for OD Next admission and remove retired capability qualification. Keep native observation and existing resume/cold recovery, with the evidence graph owned by observability contracts. Add run-scoped BYOK connection flags with caller-selected environment key input and reuse the existing provider bridge. Accept equivalent frozen Skill JSON and Base64 representations while preserving content and ownership boundaries. Validated with focused contracts, runtime, public Run and CLI regressions; eight real native agents and a real BYOK request/production pair; guard and workspace typecheck. Refs #8701. * fix(web): describe BYOK project file tools Explain that API-provider project runs use OpenCode file tools and require OpenCode to be available. Update the existing notice in all 19 locales and its component/browser regressions. Validated with red/green notice checks, real settings and preview follow-ups, guard and workspace typecheck. Refs #8701. * fix(od-next): preserve hidden prototype form feedback Keep HTML hidden semantics within the shared layout layer without overriding authored product CSS or hidden-until-found behavior. This fixes name errors remaining visible after a successful registration. Validated with a red/green Chromium regression, three resource-boundary checks, a real Claude follow-up with desktop/mobile form interaction, guard and workspace typecheck. Refs #8701. * test(od-next): make Run fixtures select their execution path Explicitly select ordinary Runs for retry persistence, plain stdout replay and the compatibility single-run telemetry case. Keep task hierarchy and terminal fallback telemetry cases active, and assert the selected path. Restore owner write permission to the leftover-agent fixture's frozen directories only during teardown so recursive deletion works on POSIX. Skip symlinks and preserve cleanup errors and every original behavior check. Reproduced all four CI failures before the fix. Focused daemon and E2E checks passed (12 tests), along with guard, workspace typecheck and focused LSP diagnostics. Refs #8701. * fix(daemon): avoid passing Pi resource directories as prompt files Pi reads existing --append-system-prompt paths as files. Stop passing skills, design systems and task input directories there; their resource paths already travel in the composed RPC prompt. Validated with 104 focused runtime/RPC tests, a real Pi 1.1.0 offline startup red/green probe, workspace typecheck and guard. * fix(web): coalesce streamed calls in generation progress Fold in-flight snapshots and terminal inputs through the existing tool-event reducer before rendering the Design Files progress feed. Keep the latest input, stable call identity and the limit of twelve distinct calls. Reproduced the duplicate React key warning before the fix. Runtime and progress-feed regressions now pass alongside existing in-flight tool-row tests. * fix(web): preserve forms and browser storage in prototype previews Permit client-side form submission in opaque interactive previews. Route Web Storage artifacts through the existing isolated powered preview, including daemon hints for large files, and retain guards when isolation is unavailable. Verified URL and srcDoc form interaction, persisted values after reload, and host isolation with focused Chromium regressions. Focused viewer/runtime and daemon tests, workspace typecheck and guard passed. * test(web): keep srcDoc fixtures on the opaque preview path Use an existing non-storage sandbox-shim signal in transport lifecycle fixtures now that Web Storage artifacts select the isolated preview origin. Preserve every reload, activation, recovery and manual-save assertion. Validated all 338 cases in the three affected FileViewer test files, workspace guard and typecheck, and focused diagnostics. * fix(byok): accept keyless IPv6 loopback providers Recognize the bracketed IPv6 hostname returned by WHATWG URL parsing in both settings validation and the OpenCode BYOK runtime. Keep remote hosts key-required. Validated with focused daemon and Web tests, settings browser checks, workspace typecheck and guard. * fix(daemon): restore Pi sessions and preserve completed RPC turns Load prior Pi history with switch_session and capture the authoritative sessionFile through nonblocking get_state. Treat a host-requested SIGTERM after a clean completed turn as shutdown, including Pi builds that report exit 143, while preserving real errors and cancellation. Validated with focused protocol and public Run lifecycle tests, a real Pi generation and native resumed edit, workspace typecheck and guard. * fix(preview): isolate project storage and preserve hidden controls Give powered previews stable per-project loopback origins and reserve those hosts for matching powered GETs before API or static exceptions. Advertise the capability so newer Web clients retain the opaque fallback with older daemons. Reuse the active powered transport in Present. Share a low-specificity hidden-control guard across URL and srcDoc previews without overriding explicit author rules or until-found; preserve doctype mode when synthesizing a missing head. Validated with actual two-project storage, Workers, reload, Present and API isolation browser witnesses, form/hidden regressions, focused Web and daemon checks, workspace typecheck and guard. * test(e2e): align public canaries with best-effort OD Next Read the remaining recipe identity marker without the retired serialized package hash. Make the fake OpenCode help probe finite. Exercise the current public question-form action, completed question turns/new answer tasks, and no-artifact normal endings while retaining cancellation, artifact and deck assertions. Validated with six fake-runtime cases and focused active/off public browser canaries with explicit rollout flags; no production completion policy or timeout was relaxed. * fix(od-next): guide focus restoration after list updates Prefer native modal lifecycle and restore focus to a current control after list rerenders, falling back to the primary action when a filtered item disappears. Real OpenCode and Pi prototypes exposed detached-trigger focus loss. Keep this as best-effort build guidance, without output contracts or automatic repair rounds. Validated the actual artifacts through normal public edit tasks and browser interaction checks; profile resource tests passed. * fix(od-next): retain focus through inline task updates Extend the existing live-control guidance to inline list actions. Real Copilot and Qoder boards also lost keyboard focus when status updates rebuilt their cards; hidden filtered items need a visible fallback. * fix: align Discovery integration tests and presentation storage * test(daemon): tolerate transient memory origin cleanup races --------- Co-authored-by: zhouzheng <zhouzheng@refly.ai>
1851 lines
90 KiB
TypeScript
1851 lines
90 KiB
TypeScript
import http from 'node:http';
|
|
import { createHash } from 'node:crypto';
|
|
import { mkdtempSync, rmSync } from 'node:fs';
|
|
import { mkdir, symlink, writeFile } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import path from 'node:path';
|
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
|
import { createRequire } from 'node:module';
|
|
|
|
import { parseByteRange, resolveProjectFilePath } from '../src/projects.js';
|
|
import { startServer } from '../src/server.js';
|
|
import { load } from 'cheerio';
|
|
|
|
// Keep DOM globals out of the daemon's Node/undici type environment, as in the
|
|
// other daemon JSDOM tests. Only script evaluation and lifecycle cross this seam.
|
|
const { JSDOM } = createRequire(import.meta.url)('jsdom') as {
|
|
JSDOM: new (html: string, options: { url: string; runScripts: 'outside-only'; pretendToBeVisual: boolean }) => {
|
|
window: { eval(source: string): unknown; close(): void };
|
|
};
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// parseByteRange — RFC 7233 unit tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('parseByteRange', () => {
|
|
it('returns null when header is undefined', () => {
|
|
expect(parseByteRange(undefined, 1000)).toBeNull();
|
|
});
|
|
|
|
it('returns null when header is an empty string', () => {
|
|
expect(parseByteRange('', 1000)).toBeNull();
|
|
});
|
|
|
|
it('returns null for non-bytes unit', () => {
|
|
expect(parseByteRange('none=0-100', 1000)).toBeNull();
|
|
});
|
|
|
|
it('returns null for multi-range (caller falls back to full 200)', () => {
|
|
expect(parseByteRange('bytes=0-100, 200-300', 1000)).toBeNull();
|
|
});
|
|
|
|
it('parses a standard start-end range', () => {
|
|
expect(parseByteRange('bytes=0-499', 1000)).toEqual({ start: 0, end: 499 });
|
|
});
|
|
|
|
it('clamps an over-long end to fileSize - 1', () => {
|
|
expect(parseByteRange('bytes=0-9999', 1000)).toEqual({ start: 0, end: 999 });
|
|
});
|
|
|
|
it('parses an open-ended range (bytes=N-)', () => {
|
|
expect(parseByteRange('bytes=500-', 1000)).toEqual({ start: 500, end: 999 });
|
|
});
|
|
|
|
it('parses a suffix range (bytes=-N)', () => {
|
|
expect(parseByteRange('bytes=-200', 1000)).toEqual({ start: 800, end: 999 });
|
|
});
|
|
|
|
it('clamps suffix larger than fileSize to the whole file', () => {
|
|
expect(parseByteRange('bytes=-9999', 1000)).toEqual({ start: 0, end: 999 });
|
|
});
|
|
|
|
it('returns unsatisfiable when start equals fileSize', () => {
|
|
expect(parseByteRange('bytes=1000-1999', 1000)).toBe('unsatisfiable');
|
|
});
|
|
|
|
it('returns unsatisfiable when start exceeds fileSize', () => {
|
|
expect(parseByteRange('bytes=5000-5999', 1000)).toBe('unsatisfiable');
|
|
});
|
|
|
|
it('returns unsatisfiable for a zero-length suffix range (bytes=-0)', () => {
|
|
expect(parseByteRange('bytes=-0', 1000)).toBe('unsatisfiable');
|
|
});
|
|
|
|
it('returns unsatisfiable for a negative suffix', () => {
|
|
expect(parseByteRange('bytes=--1', 1000)).toBe('unsatisfiable');
|
|
});
|
|
|
|
it('returns null for non-integer start', () => {
|
|
expect(parseByteRange('bytes=1.5-499', 1000)).toBeNull();
|
|
});
|
|
|
|
it('returns null for non-integer end', () => {
|
|
expect(parseByteRange('bytes=0-499.9', 1000)).toBeNull();
|
|
});
|
|
|
|
it('returns null when end < start', () => {
|
|
expect(parseByteRange('bytes=500-100', 1000)).toBeNull();
|
|
});
|
|
|
|
it('returns null for alphabetic range values', () => {
|
|
expect(parseByteRange('bytes=abc-xyz', 1000)).toBeNull();
|
|
});
|
|
|
|
it('handles a single-byte range (bytes=0-0)', () => {
|
|
expect(parseByteRange('bytes=0-0', 1000)).toEqual({ start: 0, end: 0 });
|
|
});
|
|
|
|
it('handles a range that exactly covers the last byte', () => {
|
|
expect(parseByteRange('bytes=999-999', 1000)).toEqual({ start: 999, end: 999 });
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// resolveProjectFilePath — integration test (real temp files)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('resolveProjectFilePath', () => {
|
|
let projectsRoot = '';
|
|
const projectId = 'proj-range-test';
|
|
|
|
beforeEach(async () => {
|
|
projectsRoot = mkdtempSync(path.join(tmpdir(), 'od-range-'));
|
|
const dir = path.join(projectsRoot, projectId);
|
|
await mkdir(dir, { recursive: true });
|
|
await writeFile(path.join(dir, 'clip.mp4'), Buffer.alloc(2048));
|
|
await writeFile(path.join(dir, 'index.html'), '<html/>');
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (projectsRoot) rmSync(projectsRoot, { recursive: true, force: true });
|
|
});
|
|
|
|
it('returns the correct size and mime for a video file', async () => {
|
|
const result = await resolveProjectFilePath(projectsRoot, projectId, 'clip.mp4');
|
|
expect(result.size).toBe(2048);
|
|
expect(result.mime).toBe('video/mp4');
|
|
expect(result.kind).toBe('video');
|
|
expect(path.isAbsolute(result.filePath)).toBe(true);
|
|
});
|
|
|
|
it('returns the correct mime for an html file', async () => {
|
|
const result = await resolveProjectFilePath(projectsRoot, projectId, 'index.html');
|
|
expect(result.mime).toBe('text/html; charset=utf-8');
|
|
});
|
|
|
|
it('throws ENOENT for a missing file', async () => {
|
|
await expect(
|
|
resolveProjectFilePath(projectsRoot, projectId, 'missing.mp4'),
|
|
).rejects.toMatchObject({ code: 'ENOENT' });
|
|
});
|
|
|
|
it('rejects path traversal attempts', async () => {
|
|
await expect(
|
|
resolveProjectFilePath(projectsRoot, projectId, '../other-project/secret.mp4'),
|
|
).rejects.toThrow();
|
|
});
|
|
|
|
it('rejects symlink escapes inside managed projects', async () => {
|
|
const outsideRoot = mkdtempSync(path.join(tmpdir(), 'od-range-outside-'));
|
|
try {
|
|
await writeFile(path.join(outsideRoot, 'secret.txt'), 'secret');
|
|
await symlink(
|
|
path.join(outsideRoot, 'secret.txt'),
|
|
path.join(projectsRoot, projectId, 'linked-secret.txt'),
|
|
);
|
|
|
|
await expect(
|
|
resolveProjectFilePath(projectsRoot, projectId, 'linked-secret.txt'),
|
|
).rejects.toMatchObject({ code: 'EPATHESCAPE' });
|
|
} finally {
|
|
rmSync(outsideRoot, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// GET /api/projects/:id/raw/* — HTTP route-level tests
|
|
// Exercises the actual endpoint the VideoViewer and AudioViewer components
|
|
// call, confirming 206 / Accept-Ranges / Content-Range behaviour end-to-end.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('GET /api/projects/:id/raw/* range request route', () => {
|
|
let server: http.Server;
|
|
let baseUrl: string;
|
|
let projectsRoot: string;
|
|
const projectId = 'proj-raw-range-test';
|
|
const FILE_SIZE = 512;
|
|
|
|
beforeAll(async () => {
|
|
const started = await startServer({ port: 0, returnServer: true }) as {
|
|
url: string;
|
|
server: http.Server;
|
|
};
|
|
baseUrl = started.url;
|
|
server = started.server;
|
|
|
|
const createResponse = await fetch(`${baseUrl}/api/projects`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({ id: projectId, name: 'Raw range fixture' }),
|
|
});
|
|
expect(createResponse.status).toBe(200);
|
|
|
|
// Write a test video file into the daemon's projects root.
|
|
// OD_DATA_DIR is set by tests/setup.ts so we can derive the path.
|
|
projectsRoot = path.join(process.env.OD_DATA_DIR!, 'projects');
|
|
const dir = path.join(projectsRoot, projectId);
|
|
await mkdir(dir, { recursive: true });
|
|
await writeFile(path.join(dir, 'clip.mp4'), Buffer.alloc(FILE_SIZE, 0x42));
|
|
await writeFile(path.join(dir, 'audio.mp3'), Buffer.alloc(FILE_SIZE, 0x43));
|
|
await writeFile(path.join(dir, 'page.html'), Buffer.from('<html/>'));
|
|
await writeFile(
|
|
path.join(dir, 'large.html'),
|
|
Buffer.from(`<!doctype html><html><body><main>Large Preview</main>${'x'.repeat((2 * 1024 * 1024) + 256)}</body></html>`),
|
|
);
|
|
await writeFile(
|
|
path.join(dir, 'large-powered.html'),
|
|
Buffer.from(`<!doctype html><html><body>${'x'.repeat((2 * 1024 * 1024) + 256)}<script>new Worker("worker.js")</script></body></html>`),
|
|
);
|
|
await writeFile(
|
|
path.join(dir, 'large-storage.html'),
|
|
Buffer.from(`<!doctype html><html><body>${'x'.repeat((2 * 1024 * 1024) + 256)}<script>localStorage.setItem("task", "saved")</script></body></html>`),
|
|
);
|
|
await writeFile(path.join(dir, 'body.html'), Buffer.from('<html><body><main>Preview</main></body></html>'));
|
|
// `<head>` is optional markup, so a document can legally have none while a
|
|
// script string contains one — the head-open half of
|
|
// nexu-io/open-design#7410. `<header>` is here because a bare
|
|
// `/<head[^>]*>/` matches it too.
|
|
await writeFile(
|
|
path.join(dir, 'script-literal-head.html'),
|
|
Buffer.from(
|
|
'<!doctype html><html><body><header>Nav</header>'
|
|
+ '<script>\n'
|
|
+ ' const doc = `<head><title>Slip</title></head>`;\n'
|
|
+ " document.getElementById('slot').textContent = doc.length;\n"
|
|
+ '</script>'
|
|
+ '<main id="slot"></main></body></html>',
|
|
),
|
|
);
|
|
// Three more from review: a raw-text *name* under SVG is a foreign
|
|
// element (its CDATA is still a section), `=` may be followed by
|
|
// whitespace before an unquoted value, and CR is a tag-name delimiter.
|
|
await writeFile(
|
|
path.join(dir, 'foreign-and-delimiters.html'),
|
|
Buffer.from(
|
|
'<!doctype html><html><head></head><body>'
|
|
+ '<svg><script><![CDATA[x > <\/script></svg><body>slip</body>]]><\/script></svg>'
|
|
+ '<svg data= http://x/><![CDATA[x > </svg><body>slip</body>]]></svg>'
|
|
+ '<main id="slot">real</main></body></html>',
|
|
),
|
|
);
|
|
// CR delimits a tag name at *both* ends: it opens `<script\r>` and it
|
|
// closes `</textarea\r>`. Missing the closing half leaves the scan inside
|
|
// the raw text, where the authored `</body>` reads as a real end tag.
|
|
// Namespace is a stack, not a depth. `<![CDATA[` is character data in
|
|
// foreign content and a bogus comment in HTML, and `<script>` inverts the
|
|
// same way, so both rules have to follow the adjusted current node —
|
|
// including a `<math>` that re-enters MathML beneath an HTML integration
|
|
// point, and `annotation-xml`, which is only an integration point when its
|
|
// `encoding` names an HTML type.
|
|
await writeFile(
|
|
path.join(dir, 'foreign-namespaces.html'),
|
|
Buffer.from(
|
|
'<!doctype html><html><head></head><body>'
|
|
+ '<svg><![CDATA[label > <\/body>]]></svg>'
|
|
+ '<svg><foreignObject><math><ms><![CDATA[y > <\/body>]]></ms></math></foreignObject></svg>'
|
|
+ '<svg><foreignObject><script>var q = "<\/body>";<\/script></foreignObject></svg>'
|
|
+ '<math><annotation-xml encoding="text/html"><script>var r = "<\/body>";<\/script></annotation-xml></math>'
|
|
+ '<math><annotation-xml><![CDATA[z > <\/body>]]></annotation-xml></math>'
|
|
+ '<template><svg><![CDATA[x > </template><body>slip</body>]]></svg></template>'
|
|
+ '<main id="slot">real</main></body></html>',
|
|
),
|
|
);
|
|
// The foreign-content dispatcher has exceptions that depend on the token
|
|
// as well as the namespace: a breakout tag (`<p>`, `<font color>`, …) pops
|
|
// the foreign element and is reprocessed as HTML, while `<mglyph>` and
|
|
// `<malignmark>` stay in MathML beneath a text integration point. Read by
|
|
// namespace alone, each of these puts the scan back inside author content.
|
|
await writeFile(
|
|
path.join(dir, 'foreign-breakout.html'),
|
|
Buffer.from(
|
|
'<!doctype html><html><head></head><body>'
|
|
+ '<svg><p><script>const x = "<\/svg><body>slip</body>";<\/script></p></svg>'
|
|
+ '<svg><font color="red"><script>const y = "<\/svg><body>slip</body>";<\/script></font></svg>'
|
|
+ '<math><mi><mglyph><![CDATA[x > </math><body>slip</body>]]></mglyph></mi></math>'
|
|
+ '<math><mtext><malignmark><![CDATA[q > </math><body>slip</body>]]></malignmark></mtext></math>'
|
|
+ '<main id="slot">real</main></body></html>',
|
|
),
|
|
);
|
|
// The two attribute-driven rules above (`<font color>` breaking out of
|
|
// foreign content, `annotation-xml encoding` becoming an integration
|
|
// point) must read parsed attributes, not tag text: a value may contain
|
|
// anything, including something spelled like another attribute.
|
|
await writeFile(
|
|
path.join(dir, 'quoted-fake-attrs.html'),
|
|
Buffer.from(
|
|
'<!doctype html><html><head></head><body>'
|
|
+ '<svg><font data-note=" color=red"><![CDATA[x > <\/svg><body>slip</body>]]></font></svg>'
|
|
+ '<math><annotation-xml data-note=" encoding=text/html">'
|
|
+ '<![CDATA[x > <\/math><body>slip</body>]]></annotation-xml></math>'
|
|
// The mirror case: the tokenizer resolves character references in a
|
|
// value, so this one *is* an integration point and its `<script>` is
|
|
// HTML raw text — reading the source spelling keeps it MathML.
|
|
+ '<math><annotation-xml encoding="text/html">'
|
|
+ '<script>const enc = "<\/math><body>slip</body>";<\/script></annotation-xml></math>'
|
|
+ '<main id="slot">real</main></body></html>',
|
|
),
|
|
);
|
|
await writeFile(
|
|
path.join(dir, 'encoding-near-misses.html'),
|
|
Buffer.from(
|
|
'<!doctype html><html><head></head><body>'
|
|
+ '<math><annotation-xml encoding=" text/html ">'
|
|
+ '<![CDATA[p > <\/math><body>slip</body>]]></annotation-xml></math>'
|
|
+ '<math><annotation-xml encoding="text&solhtml">'
|
|
+ '<![CDATA[u > <\/math><body>slip</body>]]></annotation-xml></math>'
|
|
+ '<main id="slot">real</main></body></html>',
|
|
),
|
|
);
|
|
// `<head data==">` is a complete start tag: the second `=` opens an
|
|
// *unquoted* value and the quote is a character of it, so the tag ends at
|
|
// the very next `>`. Reading that quote as a value delimiter runs the scan
|
|
// on to the next quote in the document and reports a `>` from author text.
|
|
await writeFile(
|
|
path.join(dir, 'unquoted-equals-tag.html'),
|
|
Buffer.from(
|
|
'<!doctype html><html><head data==">'
|
|
+ '<script>const marker = "inside>";<\/script></head>'
|
|
+ '<body><main id="slot">real</main></body></html>',
|
|
),
|
|
);
|
|
// Named character references are case-sensitive: `/` is one, `&SOL;`
|
|
// is not, and the parser leaves the latter as literal text — so this
|
|
// `annotation-xml` stays MathML and its CDATA stays character data.
|
|
await writeFile(
|
|
path.join(dir, 'encoding-case.html'),
|
|
Buffer.from(
|
|
'<!doctype html><html><head></head><body>'
|
|
+ '<math><annotation-xml encoding="text&SOL;html">'
|
|
+ '<![CDATA[c > <\/math><body>slip</body>]]></annotation-xml></math>'
|
|
+ '<main id="slot">real</main></body></html>',
|
|
),
|
|
);
|
|
// Only TAB, LF, FF, CR and SPACE separate tokens. Every other C0 code
|
|
// point is an ordinary character, so `color\0=x` names an attribute the
|
|
// parser writes as `color\uFFFD` — not `color`, and therefore not a
|
|
// `<font>` presentational attribute that breaks out of foreign content.
|
|
await writeFile(
|
|
path.join(dir, 'control-char-attr.html'),
|
|
Buffer.from(
|
|
'<!doctype html><html><head></head><body>'
|
|
+ `<svg><font color\u0000=x>`
|
|
+ '<![CDATA[q > <\/svg><body>slip</body>]]></font></svg>'
|
|
+ '<main id="slot">real</main></body></html>',
|
|
),
|
|
);
|
|
// Leaving script-data-double-escaped through `-->`. In the
|
|
// double-escaped *dash-dash* state a `>` returns the tokenizer to plain
|
|
// script data, so the `</script>` that follows is a real close and what
|
|
// comes after it is markup.
|
|
await writeFile(
|
|
path.join(dir, 'double-escape-exit.html'),
|
|
Buffer.from(
|
|
'<!doctype html><html><head></head><body>'
|
|
// The shape from review, which has a second `</script>` to fall back
|
|
// on, so both readings eventually find some boundary.
|
|
+ '<script><!--<script>--><\/script><body>slip</body><\/script>'
|
|
// The same state without that fallback. Read as staying
|
|
// double-escaped, this script never closes, swallows `<main>` and
|
|
// the real body close, and the scan has no boundary left at all.
|
|
+ '<script><!--<script>--><\/script>'
|
|
+ '<main id="slot">real</main></body></html>',
|
|
),
|
|
);
|
|
// A solidus only self-closes when `>` comes immediately after it; the
|
|
// self-closing start tag state reconsumes anything else in the
|
|
// before-attribute-name state. So `<svg/ >` is an ordinary open tag, and a
|
|
// scan that treats it as closed reads the element's contents as markup.
|
|
await writeFile(
|
|
path.join(dir, 'solidus-then-space.html'),
|
|
Buffer.from(
|
|
'<!doctype html><html><head></head><body>'
|
|
+ '<svg/ ><![CDATA[x > <\/svg><body>slip</body>]]></svg>'
|
|
+ '<main id="slot">real</main></body></html>',
|
|
),
|
|
);
|
|
// The dispatcher hands `<svg>` beneath a MathML `annotation-xml` to the
|
|
// HTML rules even with no `encoding`, so that `<svg>` lands in SVG rather
|
|
// than inheriting MathML — and the `<foreignObject>` under it is then a
|
|
// real integration point whose `<script>` is HTML raw text again.
|
|
await writeFile(
|
|
path.join(dir, 'annotation-xml-svg.html'),
|
|
Buffer.from(
|
|
'<!doctype html><html><head></head><body>'
|
|
+ '<math><annotation-xml><svg><foreignObject>'
|
|
+ `<script>const x = '<\/math><body>slip</body>';<\/script>`
|
|
+ '</foreignObject></svg></annotation-xml></math>'
|
|
+ '<main id="slot">real</main></body></html>',
|
|
),
|
|
);
|
|
// Every token the tokenizer turns into a comment may precede the doctype
|
|
// without changing the document's mode — an XML prologue and a stray
|
|
// `<!foo>` among them. Putting the payload in front of one puts a character
|
|
// before the doctype, which silently drops the artifact into quirks mode.
|
|
await writeFile(
|
|
path.join(dir, 'bogus-prologue.html'),
|
|
Buffer.from(
|
|
`<?xml version='1.0'?><!bogus><!doctype html>`
|
|
+ '<plaintext>tail</body></html>',
|
|
),
|
|
);
|
|
// The in-select insertion mode ignores an `<svg>` / `<math>` start tag
|
|
// outright, so no foreign element exists and what follows is still
|
|
// ordinary HTML — here a `<script>`, whose string contains both a
|
|
// breakout tag and a `</body>`.
|
|
await writeFile(
|
|
path.join(dir, 'select-foreign-start.html'),
|
|
Buffer.from(
|
|
'<!doctype html><html><head></head><body>'
|
|
+ '<select><svg></select>'
|
|
+ '<script>const x = "<table><\/body>";<\/script>'
|
|
+ '<main id="slot">real</main></body></html>',
|
|
),
|
|
);
|
|
// A nested `<svg>` beneath `<foreignObject>` shares the root's name but is
|
|
// an ordinary element. Tracking the subtree with a name counter alongside
|
|
// the stack lets the two disagree the moment `</foreignObject>` unwinds
|
|
// past that inner element, and the walk then never ends.
|
|
await writeFile(
|
|
path.join(dir, 'nested-svg-foreignobject.html'),
|
|
Buffer.from(
|
|
'<!doctype html><html><head></head><body>'
|
|
+ '<svg><foreignObject><svg></foreignObject></svg>'
|
|
+ '<script>const x = "<table><\/body>";<\/script>'
|
|
+ '<main id="slot">real</main></body></html>',
|
|
),
|
|
);
|
|
// Same shape in MathML.
|
|
await writeFile(
|
|
path.join(dir, 'nested-math-mi.html'),
|
|
Buffer.from(
|
|
'<!doctype html><html><head></head><body>'
|
|
+ '<math><mi><math></mi></math>'
|
|
+ '<script>var y = "<p><\/body>";<\/script>'
|
|
+ '<main id="slot">real</main></body></html>',
|
|
),
|
|
);
|
|
// A `<select>` start tag while a select is open closes it rather than
|
|
// nesting, so the SVG that follows really is foreign content.
|
|
await writeFile(
|
|
path.join(dir, 'nested-select.html'),
|
|
Buffer.from(
|
|
'<!doctype html><html><head></head><body>'
|
|
+ '<select><select></select>'
|
|
+ '<svg><![CDATA[x > <\/body>]]></svg>'
|
|
+ '<main id="slot">real</main></body></html>',
|
|
),
|
|
);
|
|
// The in-select mode ends in more ways than `</select>`: `input`, `keygen`
|
|
// and `textarea` close it and are then reprocessed, so the SVG that
|
|
// follows really is foreign content and its CDATA really is character
|
|
// data. `textarea` is also raw text, so the select state has to be updated
|
|
// before that branch consumes the tag.
|
|
for (const [name, closer] of [
|
|
['select-exit-input.html', '<input>'],
|
|
['select-exit-keygen.html', '<keygen>'],
|
|
['select-exit-textarea.html', '<textarea></textarea>'],
|
|
] as const) {
|
|
await writeFile(
|
|
path.join(dir, name),
|
|
Buffer.from(
|
|
'<!doctype html><html><head></head><body>'
|
|
+ `<select>${closer}`
|
|
+ '<svg><![CDATA[x > <\/body>]]></svg>'
|
|
+ '<main id="slot">real</main></body></html>',
|
|
),
|
|
);
|
|
}
|
|
// A `<table>` cannot put itself in a table. In select mode with no table
|
|
// already open the token is ignored outright, so it neither ends the mode
|
|
// nor joins the table stack, and the `<svg>` after it is ignored too.
|
|
await writeFile(
|
|
path.join(dir, 'select-table-token.html'),
|
|
Buffer.from(
|
|
'<!doctype html><html><head></head><body>'
|
|
+ '<select><table><svg>'
|
|
+ '<script>const x = "<table><\/body>";<\/script></select>'
|
|
+ '<main id="slot">real</main></body></html>',
|
|
),
|
|
);
|
|
// A `<base>` under an HTML integration point is an HTML-namespace element
|
|
// and governs the document, even though the structural scan skips the
|
|
// whole foreign subtree. One directly under `<svg>` is an SVG element and
|
|
// inert, and one inside a `<template>` belongs to an inert fragment.
|
|
await writeFile(
|
|
path.join(dir, 'integration-point-base.html'),
|
|
Buffer.from(
|
|
'<!doctype html><html><head></head><body>'
|
|
+ '<svg><foreignObject><base href="https://author.example/assets/">'
|
|
+ '</foreignObject></svg><main id="slot">real</main></body></html>',
|
|
),
|
|
);
|
|
await writeFile(
|
|
path.join(dir, 'foreign-namespace-base.html'),
|
|
Buffer.from(
|
|
'<!doctype html><html><head></head><body>'
|
|
+ '<svg><base href="https://ignored.example/"></svg>'
|
|
+ '<template><base href="https://inert.example/"></template>'
|
|
+ '<main id="slot">real</main></body></html>',
|
|
),
|
|
);
|
|
// A template's contents are parsed with the same insertion modes as the
|
|
// document, so the in-select mode applies inside one too. Modelling it at
|
|
// the top level and not here made the two walkers disagree on the same
|
|
// bytes.
|
|
await writeFile(
|
|
path.join(dir, 'template-select-foreign.html'),
|
|
Buffer.from(
|
|
'<!doctype html><html><head></head><body>'
|
|
+ '<template><select><svg></select>'
|
|
+ '<script>const x = "<p></template><body>slip</body>";<\/script></template>'
|
|
+ '<main id="slot">real</main></body></html>',
|
|
),
|
|
);
|
|
// A foreign element may also be called `template`, and it creates no inert
|
|
// fragment — so a `<base>` beneath it is live and must still suppress the
|
|
// generated containment base.
|
|
await writeFile(
|
|
path.join(dir, 'svg-template-base.html'),
|
|
Buffer.from(
|
|
'<!doctype html><html><head></head><body>'
|
|
+ '<svg><template><foreignObject>'
|
|
+ '<base href="https://author.example/assets/">'
|
|
+ '</foreignObject></template></svg>'
|
|
+ '<main id="slot">real</main></body></html>',
|
|
),
|
|
);
|
|
// A stray end tag inside foreign content is usually ignored, but `</p>`
|
|
// and `</br>` each have an "in body" rule that synthesises the element and
|
|
// closes it, popping the foreign element on the way. The scan does not
|
|
// model that, so it refuses rather than keeping a frame the parser dropped.
|
|
await writeFile(
|
|
path.join(dir, 'foreign-stray-p.html'),
|
|
Buffer.from(
|
|
'<!doctype html><html><head></head><body>'
|
|
+ '<svg></p><script>const x = "<body>slip</body>";<\/script>'
|
|
+ '<main id="slot">real</main></body></html>',
|
|
),
|
|
);
|
|
// Every other stray end tag leaves the foreign element open, so this one
|
|
// still resolves precisely — the refusal above must not widen to it.
|
|
await writeFile(
|
|
path.join(dir, 'foreign-stray-div.html'),
|
|
Buffer.from(
|
|
'<!doctype html><html><head></head><body>'
|
|
+ '<svg></div><![CDATA[q > <\/body>]]></svg>'
|
|
+ '<main id="slot">real</main></body></html>',
|
|
),
|
|
);
|
|
// A nested template's contents get a fresh insertion mode and the outer one
|
|
// is restored at its close, so the inner `<svg>` is real foreign content
|
|
// even though the outer fragment is in select mode.
|
|
await writeFile(
|
|
path.join(dir, 'nested-template-select.html'),
|
|
Buffer.from(
|
|
'<!doctype html><html><head></head><body>'
|
|
+ '<template><select><template><svg>'
|
|
+ '<![CDATA[x > </template></template><body>slip</body>]]>'
|
|
+ '</svg></template></select></template>'
|
|
+ '<main id="slot">real</main></body></html>',
|
|
),
|
|
);
|
|
// A leading BOM is the encoding signature and only counts at byte zero, so
|
|
// the no-boundary fallback has to insert after it rather than in front of
|
|
// it — otherwise the doctype stops applying and the artifact silently
|
|
// renders in quirks mode. `<plaintext>` is what removes the boundary here.
|
|
await writeFile(
|
|
path.join(dir, 'bom-plaintext.html'),
|
|
Buffer.from(
|
|
// No `<head>` and no `<html>`: the head/html anchors must miss so the
|
|
// doctype fallback is the branch under test.
|
|
'\uFEFF<!doctype html><plaintext>tail</body></html>',
|
|
),
|
|
);
|
|
await writeFile(
|
|
path.join(dir, 'cr-delimiter.html'),
|
|
Buffer.from(
|
|
'<!doctype html><html><head></head><body>'
|
|
+ '<script\r>const doc = "<body>slip</body>";<\/script>'
|
|
+ '<textarea>note </textarea\r>'
|
|
+ '<main id="slot">real</main></body></html>',
|
|
),
|
|
);
|
|
// Token shapes an audit against parse5 turned up, each of which had the
|
|
// scan resume inside author text: an end-tag-open on a non-letter (bogus
|
|
// comment), the `--!>` and `<!-->` comment ends, a custom element whose
|
|
// name merely starts with a raw-text name, and an unquoted attribute value
|
|
// ending in `/` (not a self-closing solidus).
|
|
await writeFile(
|
|
path.join(dir, 'token-shapes.html'),
|
|
Buffer.from(
|
|
'<!doctype html><html><head></head><body>'
|
|
+ '</ note: use </body> carefully>'
|
|
+ '<!-- a --!>'
|
|
+ '<iframe-x>a</iframe-x>'
|
|
+ '<svg data-href=http://x/><text>t</text></svg>'
|
|
+ '<script>var s = "</iframe> --> </body>";<\/script>'
|
|
+ '<main id="slot">real</main></body></html>',
|
|
),
|
|
);
|
|
// `<plaintext>` has no way out of PLAINTEXT state: everything after it is
|
|
// character data, `</plaintext>` included. A scan that honours the close
|
|
// tag resumes in text that the parser never treats as markup.
|
|
await writeFile(
|
|
path.join(dir, 'plaintext-tail.html'),
|
|
Buffer.from(
|
|
'<!doctype html><html><head></head><body><plaintext></body></plaintext>'
|
|
+ '<main id="slot">real</main></body></html>',
|
|
),
|
|
);
|
|
// Foreign content is the one place `<![CDATA[ … ]]>` is real markup. The
|
|
// `>` inside the section is still text, so a scan that ends declarations at
|
|
// the next `>` resumes inside it and returns the `</body>` it holds. Inside
|
|
// an HTML integration point the parser is back in HTML, where `<![CDATA[`
|
|
// is a bogus comment again.
|
|
await writeFile(
|
|
path.join(dir, 'foreign-content.html'),
|
|
Buffer.from(
|
|
'<!doctype html><html><head></head><body>'
|
|
+ '<svg><foreignObject><![CDATA[x > </svg><body>slip</body>]]></foreignObject></svg>'
|
|
+ '<template><svg><![CDATA[x > </template><body>slip</body>]]></svg></template>'
|
|
+ '<math><annotation-xml><![CDATA[x > </math><body>slip</body>]]></annotation-xml></math>'
|
|
+ '<main id="slot">real</main></body></html>',
|
|
),
|
|
);
|
|
// Tokenizer states the scan has to follow, not just tag text: a
|
|
// `</template>` inside a nested script is content, and after `<!--` a
|
|
// nested `<script` puts script data in double-escaped state, where
|
|
// `</script>` steps back out instead of closing.
|
|
await writeFile(
|
|
path.join(dir, 'tokenizer-states.html'),
|
|
Buffer.from(
|
|
'<!doctype html><html><head></head><body>'
|
|
+ '<template><script>const a = "</template><body>slip</body>";<\/script></template>'
|
|
+ '<script><!--\nconst open = "<script>";\nconst b = "</script><body>slip</body>";\n//--><\/script>'
|
|
+ '<main id="slot">real</main></body></html>',
|
|
),
|
|
);
|
|
// A raw-text element only closes on `</name` followed by whitespace, `/` or
|
|
// `>`. A longer name that merely starts with it stays character data, so a
|
|
// scan that accepts the prefix resumes inside the author's script and hands
|
|
// back a boundary from inside their string.
|
|
await writeFile(
|
|
path.join(dir, 'raw-text-prefix.html'),
|
|
Buffer.from(
|
|
'<!doctype html><html><head></head><body>'
|
|
+ '<script>const doc = "</script-template><body>slip</body>";<\/script>'
|
|
+ '<textarea id="t"></textarea-note></textarea>'
|
|
+ '<main id="slot"></main></body></html>',
|
|
),
|
|
);
|
|
// The other places a `</body>` is character data rather than a boundary:
|
|
// raw-text elements the parser never reads as markup, and `<template>`
|
|
// content, which the tree builder keeps out of the document entirely (an
|
|
// injection placed there would silently never run).
|
|
await writeFile(
|
|
path.join(dir, 'inert-literal-body.html'),
|
|
Buffer.from(
|
|
'<!doctype html><html><head></head><body>'
|
|
+ '<template></body></template>'
|
|
+ '<noscript></body></noscript>'
|
|
+ '<xmp></body></xmp>'
|
|
+ '<main id="slot"></main></body></html>',
|
|
),
|
|
);
|
|
// The same tags are just as ordinary inside an attribute value — storing a
|
|
// template on a `data-` attribute is the other half of the #7410 shape.
|
|
// Browsers accept a literal `<` there, and the injected bridge carries
|
|
// quotes, so splicing into the value would close the attribute early.
|
|
await writeFile(
|
|
path.join(dir, 'attr-literal-body.html'),
|
|
Buffer.from(
|
|
'<!doctype html><html><head><title>Print</title></head><body>'
|
|
+ '<div id="tpl" data-tpl="<body>slip</body>"></div>'
|
|
+ '<main id="slot"></main></body></html>',
|
|
),
|
|
);
|
|
// A prototype whose inline script builds an HTML document string — the
|
|
// shape behind nexu-io/open-design#7410. The literal `</body>` inside the
|
|
// template literal precedes the document's real one, so an injector that
|
|
// splices at the FIRST `</body>` lands inside the script and truncates it.
|
|
await writeFile(
|
|
path.join(dir, 'script-literal-body.html'),
|
|
Buffer.from(
|
|
'<!doctype html><html><head><title>Print</title></head><body>'
|
|
+ '<script type="text/babel">\n'
|
|
+ ' const doc = `<body>slip</body>`;\n'
|
|
+ " document.getElementById('slot').textContent = doc.length;\n"
|
|
+ '</script>'
|
|
+ '<main id="slot"></main></body></html>',
|
|
),
|
|
);
|
|
await writeFile(
|
|
path.join(dir, 'guarded.html'),
|
|
Buffer.from('<!doctype html><html><head><script src="./boot.js"></script></head><body><input autofocus></body></html>'),
|
|
);
|
|
const complexPreviewDir = path.join(dir, 'prototypes', 'booking');
|
|
await mkdir(path.join(complexPreviewDir, 'styles'), { recursive: true });
|
|
await mkdir(path.join(complexPreviewDir, 'scripts'), { recursive: true });
|
|
await mkdir(path.join(complexPreviewDir, 'components'), { recursive: true });
|
|
await mkdir(path.join(complexPreviewDir, 'assets'), { recursive: true });
|
|
const babelScripts = Array.from(
|
|
{ length: 43 },
|
|
(_, index) => `<script type="text/babel" src="./components/screen-${index + 1}.jsx"></script>`,
|
|
).join('');
|
|
await writeFile(
|
|
path.join(complexPreviewDir, 'index.html'),
|
|
Buffer.from([
|
|
'<!doctype html><html><head>',
|
|
'<link rel="stylesheet" href="./styles/app.css">',
|
|
'<script src="./scripts/support.js"></script>',
|
|
babelScripts,
|
|
'<script type="module" src="./scripts/module.js"></script>',
|
|
'</head><body>',
|
|
'<img src="./assets/card.svg" srcset="./assets/card.svg 1x, ./assets/card@2x.svg 2x">',
|
|
'<main id="root"></main>',
|
|
'</body></html>',
|
|
].join('')),
|
|
);
|
|
await writeFile(
|
|
path.join(complexPreviewDir, 'styles', 'app.css'),
|
|
'@import "./theme.css"; .card { background-image: url("../assets/card.svg"); }',
|
|
);
|
|
await writeFile(path.join(complexPreviewDir, 'styles', 'theme.css'), ':root { --accent: #0a7; }');
|
|
await writeFile(
|
|
path.join(complexPreviewDir, 'scripts', 'support.js'),
|
|
'window.__supportLoaded = true; fetch("./data.json").then((response) => response.json());',
|
|
);
|
|
await writeFile(path.join(complexPreviewDir, 'scripts', 'module.js'), 'export const ready = true;');
|
|
for (let index = 1; index <= 43; index += 1) {
|
|
await writeFile(
|
|
path.join(complexPreviewDir, 'components', `screen-${index}.jsx`),
|
|
`window.__screen${index} = () => <section>Screen ${index}</section>;`,
|
|
);
|
|
}
|
|
await writeFile(path.join(complexPreviewDir, 'data.json'), '{"ready":true}');
|
|
await writeFile(path.join(complexPreviewDir, 'assets', 'card.svg'), '<svg xmlns="http://www.w3.org/2000/svg"/>');
|
|
await writeFile(path.join(complexPreviewDir, 'assets', 'card@2x.svg'), '<svg xmlns="http://www.w3.org/2000/svg" width="2"/>');
|
|
await writeFile(
|
|
path.join(dir, 'bridged.html'),
|
|
Buffer.from('<html><body><script data-od-url-scroll-bridge></script><main>Preview</main></body></html>'),
|
|
);
|
|
await writeFile(
|
|
path.join(dir, 'selection-bridged.html'),
|
|
Buffer.from('<html><body><script data-od-url-selection-bridge></script><main>Preview</main></body></html>'),
|
|
);
|
|
await writeFile(
|
|
path.join(dir, 'snapshot-bridged.html'),
|
|
Buffer.from('<html><body><script data-od-url-snapshot-bridge></script><main>Preview</main></body></html>'),
|
|
);
|
|
await writeFile(
|
|
path.join(dir, 'observability-bridged.html'),
|
|
Buffer.from('<html><head><script data-od-preview-observability></script></head><body><main>Preview</main></body></html>'),
|
|
);
|
|
await mkdir(path.join(dir, 'dist', 'assets'), { recursive: true });
|
|
await writeFile(
|
|
path.join(dir, 'vite-entry.html'),
|
|
Buffer.from('<!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'),
|
|
Buffer.from(
|
|
'<!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>',
|
|
),
|
|
);
|
|
});
|
|
|
|
afterAll(() => new Promise<void>((resolve) => server.close(() => resolve())));
|
|
|
|
const rawUrl = (name: string) => `${baseUrl}/api/projects/${projectId}/raw/${name}`;
|
|
const poweredUrl = (name: string) => `${baseUrl}/api/projects/${projectId}/powered/${name}`;
|
|
const poweredOrigin = () => {
|
|
const url = new URL(baseUrl);
|
|
url.hostname = url.hostname === '127.0.0.1' ? 'localhost' : '127.0.0.1';
|
|
return url.origin;
|
|
};
|
|
|
|
it('advertises Accept-Ranges: bytes for a video file with no Range header', async () => {
|
|
const res = await fetch(rawUrl('clip.mp4'));
|
|
expect(res.status).toBe(200);
|
|
expect(res.headers.get('accept-ranges')).toBe('bytes');
|
|
expect(res.headers.get('content-type')).toContain('video/mp4');
|
|
expect(Number(res.headers.get('content-length'))).toBe(FILE_SIZE);
|
|
});
|
|
|
|
it('returns 206 with correct Content-Range for a partial video request', async () => {
|
|
const res = await fetch(rawUrl('clip.mp4'), {
|
|
headers: { Range: 'bytes=0-99' },
|
|
});
|
|
expect(res.status).toBe(206);
|
|
expect(res.headers.get('content-range')).toBe(`bytes 0-99/${FILE_SIZE}`);
|
|
expect(res.headers.get('content-length')).toBe('100');
|
|
expect(res.headers.get('accept-ranges')).toBe('bytes');
|
|
const buf = Buffer.from(await res.arrayBuffer());
|
|
expect(buf.length).toBe(100);
|
|
expect(buf[0]).toBe(0x42);
|
|
});
|
|
|
|
it('returns 206 for an open-ended range on an audio file', async () => {
|
|
const res = await fetch(rawUrl('audio.mp3'), {
|
|
headers: { Range: 'bytes=256-' },
|
|
});
|
|
expect(res.status).toBe(206);
|
|
expect(res.headers.get('content-range')).toBe(`bytes 256-${FILE_SIZE - 1}/${FILE_SIZE}`);
|
|
expect(res.headers.get('content-length')).toBe(String(FILE_SIZE - 256));
|
|
});
|
|
|
|
it('returns 206 for a suffix range', async () => {
|
|
const res = await fetch(rawUrl('clip.mp4'), {
|
|
headers: { Range: 'bytes=-128' },
|
|
});
|
|
expect(res.status).toBe(206);
|
|
expect(res.headers.get('content-range')).toBe(`bytes ${FILE_SIZE - 128}-${FILE_SIZE - 1}/${FILE_SIZE}`);
|
|
expect(res.headers.get('content-length')).toBe('128');
|
|
});
|
|
|
|
it('returns 416 for an out-of-bounds range', async () => {
|
|
const res = await fetch(rawUrl('clip.mp4'), {
|
|
headers: { Range: 'bytes=9999-99999' },
|
|
});
|
|
expect(res.status).toBe(416);
|
|
expect(res.headers.get('content-range')).toBe(`bytes */${FILE_SIZE}`);
|
|
});
|
|
|
|
it('does not stream small transformed HTML files (HTML returns full 200 without Accept-Ranges)', async () => {
|
|
const res = await fetch(rawUrl('page.html'));
|
|
expect(res.status).toBe(200);
|
|
expect(res.headers.get('accept-ranges')).toBeNull();
|
|
const text = await res.text();
|
|
expect(text).toBe('<html/>');
|
|
});
|
|
|
|
it('returns a truncated text preview for large HTML without reading the full file', async () => {
|
|
const res = await fetch(`${baseUrl}/api/projects/${projectId}/text-preview/large.html?limit=64`);
|
|
expect(res.status).toBe(200);
|
|
const body = await res.json() as {
|
|
text: string;
|
|
truncated: boolean;
|
|
size: number;
|
|
limit: number;
|
|
mime: string;
|
|
poweredPreview: {
|
|
required: boolean;
|
|
scannedBytes: number;
|
|
complete: boolean;
|
|
};
|
|
};
|
|
expect(body.text).toContain('<!doctype html>');
|
|
expect(body.text.length).toBeLessThanOrEqual(1024);
|
|
expect(body.truncated).toBe(true);
|
|
expect(body.size).toBeGreaterThan(2 * 1024 * 1024);
|
|
expect(body.limit).toBe(1024);
|
|
expect(body.mime).toContain('text/html');
|
|
expect(body.poweredPreview.required).toBe(false);
|
|
expect(body.poweredPreview.complete).toBe(true);
|
|
});
|
|
|
|
it('returns powered-preview hints even when the Worker/WASM signal is late in a large HTML file', async () => {
|
|
const res = await fetch(`${baseUrl}/api/projects/${projectId}/text-preview/large-powered.html?limit=64`);
|
|
expect(res.status).toBe(200);
|
|
const body = await res.json() as {
|
|
text: string;
|
|
poweredPreview: {
|
|
required: boolean;
|
|
scannedBytes: number;
|
|
complete: boolean;
|
|
};
|
|
};
|
|
expect(body.text.length).toBeLessThanOrEqual(1024);
|
|
expect(body.text).not.toContain('new Worker');
|
|
expect(body.poweredPreview.required).toBe(true);
|
|
expect(body.poweredPreview.scannedBytes).toBeGreaterThan(2 * 1024 * 1024);
|
|
});
|
|
|
|
it('returns a real-storage preview hint when the storage call is outside the returned HTML prefix', async () => {
|
|
const res = await fetch(`${baseUrl}/api/projects/${projectId}/text-preview/large-storage.html?limit=64`);
|
|
expect(res.status).toBe(200);
|
|
const body = await res.json() as {
|
|
text: string;
|
|
poweredPreview: { required: boolean; scannedBytes: number };
|
|
};
|
|
expect(body.text).not.toContain('localStorage');
|
|
expect(body.poweredPreview.required).toBe(true);
|
|
expect(body.poweredPreview.scannedBytes).toBeGreaterThan(2 * 1024 * 1024);
|
|
});
|
|
|
|
it('skips URL preview bridge injection for large HTML so first paint can stream', async () => {
|
|
const res = await fetch(`${rawUrl('large.html')}?odPreviewBridge=scroll&odPreviewBridge=selection&odPreviewBridge=snapshot&odPreviewBridge=observability`, {
|
|
headers: { Range: 'bytes=0-127' },
|
|
});
|
|
expect(res.status).toBe(206);
|
|
expect(res.headers.get('accept-ranges')).toBe('bytes');
|
|
expect(res.headers.get('content-range')).toMatch(/^bytes 0-127\//);
|
|
const html = await res.text();
|
|
expect(html).toContain('Large Preview');
|
|
expect(html).not.toContain('data-od-url-scroll-bridge');
|
|
expect(html).not.toContain('data-od-url-selection-bridge');
|
|
expect(html).not.toContain('data-od-url-snapshot-bridge');
|
|
expect(html).not.toContain('data-od-preview-observability');
|
|
});
|
|
|
|
it('injects the URL preview scroll bridge only when requested', async () => {
|
|
const plain = await fetch(rawUrl('page.html'));
|
|
expect(await plain.text()).toBe('<html/>');
|
|
|
|
const bridged = await fetch(`${rawUrl('page.html')}?odPreviewBridge=scroll`);
|
|
expect(bridged.status).toBe(200);
|
|
const html = await bridged.text();
|
|
expect(html).toContain('data-od-url-scroll-bridge');
|
|
expect(html).toContain("type: 'od:preview-scroll'");
|
|
expect(html).toContain("type: 'od:preview-content-size'");
|
|
expect(html).toContain('od:preview-content-size-request');
|
|
expect(html).toContain('lastContentSizeRequest.measurementId');
|
|
expect(html).toContain('lastContentSizeRequest.generation');
|
|
expect(html).toContain('documentEpoch: contentSizeDocumentEpoch');
|
|
expect(html).toContain("get('odPreviewEpoch')");
|
|
expect(html).toContain('scrollWidth: size && size.scrollWidth');
|
|
expect(html).toContain('clientWidth: size && size.clientWidth');
|
|
});
|
|
|
|
it('injects the URL preview scroll bridge before the closing body tag', async () => {
|
|
const bridged = await fetch(`${rawUrl('body.html')}?odPreviewBridge=scroll`);
|
|
expect(bridged.status).toBe(200);
|
|
const html = await bridged.text();
|
|
expect(html.indexOf('data-od-url-scroll-bridge')).toBeGreaterThan(-1);
|
|
expect(html.indexOf('data-od-url-scroll-bridge')).toBeLessThan(html.indexOf('</body>'));
|
|
});
|
|
|
|
it('injects the URL preview scroll bridge after a `</body>` written inside a script string', async () => {
|
|
// nexu-io/open-design#7410: splicing at the first raw-text `</body>` puts
|
|
// the bridge's own `</script>` inside the author's script, which ends that
|
|
// script early and dumps the rest of it on the page as text.
|
|
const bridged = await fetch(`${rawUrl('script-literal-body.html')}?odPreviewBridge=scroll`);
|
|
expect(bridged.status).toBe(200);
|
|
const html = await bridged.text();
|
|
const injectedAt = html.indexOf('data-od-url-scroll-bridge');
|
|
expect(injectedAt).toBeGreaterThan(-1);
|
|
// The author's script must survive intact...
|
|
expect(html).toContain('const doc = `<body>slip</body>`;');
|
|
// ...and the bridge must land after it closes, not inside it.
|
|
expect(injectedAt).toBeGreaterThan(html.indexOf('</script>'));
|
|
expect(injectedAt).toBeLessThan(html.lastIndexOf('</body>'));
|
|
});
|
|
|
|
it('injects the containment base after a `<head>` written inside a script string', async () => {
|
|
// The containment `<base>` runs after the bridges on every URL preview, and
|
|
// it used its own first-textual-`<head>` match. Requesting only the scroll
|
|
// bridge keeps any head-open guard from synthesizing a real `<head>` first,
|
|
// so the base injector has to find the boundary on its own.
|
|
const bridged = await fetch(`${rawUrl('script-literal-head.html')}?odPreviewBridge=scroll`);
|
|
expect(bridged.status).toBe(200);
|
|
const html = await bridged.text();
|
|
expect(html).toContain('data-od-project-preview-base');
|
|
expect(html).toContain('const doc = `<head><title>Slip</title></head>`;');
|
|
});
|
|
|
|
it('injects the URL preview sandbox shim after a `<head>` written inside a script string', async () => {
|
|
// nexu-io/open-design#7410, head-open half: the document has no real
|
|
// `<head>`, so the first textual match is the one inside the script.
|
|
const bridged = await fetch(`${rawUrl('script-literal-head.html')}?odPreviewBridge=sandbox`);
|
|
expect(bridged.status).toBe(200);
|
|
const html = await bridged.text();
|
|
const injectedAt = html.indexOf('data-od-sandbox-shim');
|
|
expect(injectedAt).toBeGreaterThan(-1);
|
|
expect(html).toContain('const doc = `<head><title>Slip</title></head>`;');
|
|
expect(injectedAt).toBeLessThan(html.indexOf('<script>'));
|
|
});
|
|
|
|
it('injects the URL preview scroll bridge after a `</body>` written in an attribute value', async () => {
|
|
const bridged = await fetch(`${rawUrl('attr-literal-body.html')}?odPreviewBridge=scroll`);
|
|
expect(bridged.status).toBe(200);
|
|
const html = await bridged.text();
|
|
const injectedAt = html.indexOf('data-od-url-scroll-bridge');
|
|
expect(injectedAt).toBeGreaterThan(-1);
|
|
expect(html).toContain('data-tpl="<body>slip</body>"');
|
|
expect(injectedAt).toBeGreaterThan(html.indexOf('data-tpl'));
|
|
expect(injectedAt).toBeLessThan(html.lastIndexOf('</body>'));
|
|
});
|
|
|
|
it('injects the URL preview scroll bridge after `</body>` in inert and raw-text content', async () => {
|
|
const bridged = await fetch(`${rawUrl('inert-literal-body.html')}?odPreviewBridge=scroll`);
|
|
expect(bridged.status).toBe(200);
|
|
const html = await bridged.text();
|
|
const injectedAt = html.indexOf('data-od-url-scroll-bridge');
|
|
expect(injectedAt).toBeGreaterThan(-1);
|
|
expect(html).toContain('<template></body></template>');
|
|
expect(html).toContain('<noscript></body></noscript>');
|
|
expect(html).toContain('<xmp></body></xmp>');
|
|
// Injected into the real body, after every inert copy.
|
|
expect(injectedAt).toBeGreaterThan(html.indexOf('<xmp>'));
|
|
expect(injectedAt).toBeLessThan(html.lastIndexOf('</body>'));
|
|
});
|
|
|
|
it('injects the URL preview scroll bridge after a raw-text close-tag prefix', async () => {
|
|
const bridged = await fetch(`${rawUrl('raw-text-prefix.html')}?odPreviewBridge=scroll`);
|
|
expect(bridged.status).toBe(200);
|
|
const html = await bridged.text();
|
|
const injectedAt = html.indexOf('data-od-url-scroll-bridge');
|
|
expect(injectedAt).toBeGreaterThan(-1);
|
|
// `</script-template>` does not close the script, so the `</body>` it
|
|
// contains is still the author's string, not this document's boundary.
|
|
expect(html).toContain('const doc = "</script-template><body>slip</body>";');
|
|
expect(html).toContain('<textarea id="t"></textarea-note></textarea>');
|
|
expect(injectedAt).toBeGreaterThan(html.indexOf('<main id="slot">'));
|
|
expect(injectedAt).toBeLessThan(html.lastIndexOf('</body>'));
|
|
});
|
|
|
|
it('injects the URL preview scroll bridge after template and script escape states', async () => {
|
|
const bridged = await fetch(`${rawUrl('tokenizer-states.html')}?odPreviewBridge=scroll`);
|
|
expect(bridged.status).toBe(200);
|
|
const html = await bridged.text();
|
|
const injectedAt = html.indexOf('data-od-url-scroll-bridge');
|
|
expect(injectedAt).toBeGreaterThan(-1);
|
|
expect(html).toContain('const a = "</template><body>slip</body>";');
|
|
expect(html).toContain('const b = "</script><body>slip</body>";');
|
|
expect(injectedAt).toBeGreaterThan(html.indexOf('<main id="slot">real</main>'));
|
|
expect(injectedAt).toBeLessThan(html.lastIndexOf('</body>'));
|
|
});
|
|
|
|
it('injects the URL preview scroll bridge after CDATA in foreign content', async () => {
|
|
const bridged = await fetch(`${rawUrl('foreign-content.html')}?odPreviewBridge=scroll`);
|
|
expect(bridged.status).toBe(200);
|
|
const html = await bridged.text();
|
|
const injectedAt = html.indexOf('data-od-url-scroll-bridge');
|
|
expect(injectedAt).toBeGreaterThan(-1);
|
|
// Whether these are CDATA sections or bogus comments depends on the
|
|
// adjusted current node's namespace, and the two spellings disagree about
|
|
// where the body ends. Beneath `<template><svg>` and `<math>` the parser is
|
|
// in foreign content, so those sections are character data and the
|
|
// `</body>` inside each is text — both survive byte for byte.
|
|
expect(html).toContain('<![CDATA[x > </template><body>slip</body>]]>');
|
|
expect(html).toContain('<![CDATA[x > </math><body>slip</body>]]>');
|
|
// Beneath `<foreignObject>` the parser is back in HTML, where `<![CDATA[x >`
|
|
// is a bogus comment and the `</body>` after it really does close the body.
|
|
// The scan stops at that first real boundary, which is what the tree builder
|
|
// does, so the bridge lands in body ahead of it.
|
|
const page = load(html);
|
|
expect(page('[data-od-url-scroll-bridge]').length).toBe(1);
|
|
expect(page('body > [data-od-url-scroll-bridge]').length).toBe(1);
|
|
expect(page('#slot').text()).toBe('real');
|
|
});
|
|
|
|
it('appends the URL preview scroll bridge rather than splicing into plaintext', async () => {
|
|
const bridged = await fetch(`${rawUrl('plaintext-tail.html')}?odPreviewBridge=scroll`);
|
|
expect(bridged.status).toBe(200);
|
|
const html = await bridged.text();
|
|
const injectedAt = html.indexOf('data-od-url-scroll-bridge');
|
|
expect(injectedAt).toBeGreaterThan(-1);
|
|
// Everything after `<plaintext>` is character data — both `</body>` copies
|
|
// included — so there is no boundary to splice at. Appending would not
|
|
// work either: PLAINTEXT never exits, so appended markup is text and the
|
|
// bridge would not exist as an element at all. The fallback therefore goes
|
|
// in at the top of the document, which costs the end-of-body placement but
|
|
// keeps the bridge live. Asserting the parsed result, not just the bytes.
|
|
expect(html).toContain('<plaintext></body></plaintext>');
|
|
expect(injectedAt).toBeLessThan(html.indexOf('<plaintext>'));
|
|
expect(load(html)('[data-od-url-scroll-bridge]').length).toBe(1);
|
|
});
|
|
|
|
it('injects the URL preview scroll bridge past bogus comments, custom elements and unquoted slashes', async () => {
|
|
const bridged = await fetch(`${rawUrl('token-shapes.html')}?odPreviewBridge=scroll`);
|
|
expect(bridged.status).toBe(200);
|
|
const html = await bridged.text();
|
|
const injectedAt = html.indexOf('data-od-url-scroll-bridge');
|
|
expect(injectedAt).toBeGreaterThan(-1);
|
|
expect(html).toContain('</ note: use </body> carefully>');
|
|
expect(html).toContain('<iframe-x>a</iframe-x>');
|
|
expect(html).toContain('var s = "</iframe> --> </body>";');
|
|
expect(injectedAt).toBeGreaterThan(html.indexOf('<main id="slot">real</main>'));
|
|
expect(injectedAt).toBeLessThan(html.lastIndexOf('</body>'));
|
|
});
|
|
|
|
it('refuses a boundary through CDATA under a raw-text-named foreign element', async () => {
|
|
const bridged = await fetch(`${rawUrl('foreign-and-delimiters.html')}?odPreviewBridge=scroll`);
|
|
expect(bridged.status).toBe(200);
|
|
const html = await bridged.text();
|
|
const injectedAt = html.indexOf('data-od-url-scroll-bridge');
|
|
expect(injectedAt).toBeGreaterThan(-1);
|
|
// `<svg><script>` is a foreign element, not HTML raw text, so its CDATA is
|
|
// still a section; and `data= http://x/` is an unquoted value, not a
|
|
// self-closing solidus. Both sections survive byte for byte.
|
|
expect(html).toContain('<![CDATA[x > <\/script></svg><body>slip</body>]]>');
|
|
expect(html).toContain('<![CDATA[x > </svg><body>slip</body>]]>');
|
|
// No boundary is picked here, so the bridge goes in at the top of the
|
|
// document rather than at the end of body — it still exists as an element,
|
|
// which is what actually matters, and every authored section is untouched.
|
|
expect(load(html)('[data-od-url-scroll-bridge]').length).toBe(1);
|
|
});
|
|
|
|
it('follows the adjusted current node through foreign content', async () => {
|
|
const bridged = await fetch(`${rawUrl('foreign-namespaces.html')}?odPreviewBridge=scroll`);
|
|
expect(bridged.status).toBe(200);
|
|
const html = await bridged.text();
|
|
const page = load(html);
|
|
// A live bridge, in body, with every authored section still intact.
|
|
expect(page('[data-od-url-scroll-bridge]').length).toBe(1);
|
|
expect(page('#slot').text()).toBe('real');
|
|
expect(page('body > [data-od-url-scroll-bridge]').length).toBe(1);
|
|
// None of the six `</body>` spellings above may be read as a boundary.
|
|
expect(html.indexOf('data-od-url-scroll-bridge'))
|
|
.toBeGreaterThan(html.indexOf('<main id="slot">real</main>'));
|
|
});
|
|
|
|
it('leaves foreign content on a breakout tag and keeps mglyph foreign', async () => {
|
|
const bridged = await fetch(`${rawUrl('foreign-breakout.html')}?odPreviewBridge=scroll`);
|
|
expect(bridged.status).toBe(200);
|
|
const html = await bridged.text();
|
|
const page = load(html);
|
|
expect(page('body > [data-od-url-scroll-bridge]').length).toBe(1);
|
|
expect(page('#slot').text()).toBe('real');
|
|
// None of the four authored `</body>` spellings may be read as a boundary.
|
|
expect(html).toContain('const x = "</svg><body>slip</body>";');
|
|
expect(html).toContain('const y = "</svg><body>slip</body>";');
|
|
expect(html).toContain('<![CDATA[x > </math><body>slip</body>]]>');
|
|
expect(html).toContain('<![CDATA[q > </math><body>slip</body>]]>');
|
|
});
|
|
|
|
it('does not treat a padded or unterminated encoding as an integration point', async () => {
|
|
// `annotation-xml` is an integration point only on an exact match, so a
|
|
// padded value and an ambiguous ampersand both stay MathML — and their
|
|
// CDATA stays character data.
|
|
const bridged = await fetch(`${rawUrl('encoding-near-misses.html')}?odPreviewBridge=scroll`);
|
|
expect(bridged.status).toBe(200);
|
|
const html = await bridged.text();
|
|
const page = load(html);
|
|
expect(page('body > [data-od-url-scroll-bridge]').length).toBe(1);
|
|
expect(page('#slot').text()).toBe('real');
|
|
expect(html).toContain('<![CDATA[p > </math><body>slip</body>]]>');
|
|
expect(html).toContain('<![CDATA[u > </math><body>slip</body>]]>');
|
|
});
|
|
|
|
it('reads parsed attributes, not attribute-like text in a quoted value', async () => {
|
|
const bridged = await fetch(`${rawUrl('quoted-fake-attrs.html')}?odPreviewBridge=scroll`);
|
|
expect(bridged.status).toBe(200);
|
|
const html = await bridged.text();
|
|
const page = load(html);
|
|
expect(page('body > [data-od-url-scroll-bridge]').length).toBe(1);
|
|
expect(page('#slot').text()).toBe('real');
|
|
// Neither section carries a real `color` or `encoding`, so both stay
|
|
// foreign and the `</body>` inside each is character data.
|
|
expect(html).toContain('<![CDATA[x > </svg><body>slip</body>]]>');
|
|
expect(html).toContain('<![CDATA[x > </math><body>slip</body>]]>');
|
|
expect(html).toContain('const enc = "</math><body>slip</body>";');
|
|
});
|
|
|
|
it('ends a tag whose unquoted value contains a quote at the next angle bracket', async () => {
|
|
const bridged = await fetch(`${rawUrl('unquoted-equals-tag.html')}?odPreviewBridge=scroll`);
|
|
expect(bridged.status).toBe(200);
|
|
const html = await bridged.text();
|
|
const page = load(html);
|
|
expect(page('[data-od-url-scroll-bridge]').length).toBe(1);
|
|
expect(page('#slot').text()).toBe('real');
|
|
// The authored script survives; nothing was spliced into its string.
|
|
expect(html).toContain('const marker = "inside>";');
|
|
});
|
|
|
|
it('treats a named character reference as case-sensitive', async () => {
|
|
const bridged = await fetch(`${rawUrl('encoding-case.html')}?odPreviewBridge=scroll`);
|
|
expect(bridged.status).toBe(200);
|
|
const html = await bridged.text();
|
|
const page = load(html);
|
|
expect(page('body > [data-od-url-scroll-bridge]').length).toBe(1);
|
|
expect(page('#slot').text()).toBe('real');
|
|
expect(html).toContain('<![CDATA[c > </math><body>slip</body>]]>');
|
|
});
|
|
|
|
it('separates tokens on HTML whitespace only, not the whole C0 range', async () => {
|
|
const bridged = await fetch(`${rawUrl('control-char-attr.html')}?odPreviewBridge=scroll`);
|
|
expect(bridged.status).toBe(200);
|
|
const html = await bridged.text();
|
|
const page = load(html);
|
|
expect(page('body > [data-od-url-scroll-bridge]').length).toBe(1);
|
|
expect(page('#slot').text()).toBe('real');
|
|
expect(html).toContain('<![CDATA[q > </svg><body>slip</body>]]>');
|
|
});
|
|
|
|
it('leaves script-data-double-escaped through the dash-dash greater-than', async () => {
|
|
const bridged = await fetch(`${rawUrl('double-escape-exit.html')}?odPreviewBridge=scroll`);
|
|
expect(bridged.status).toBe(200);
|
|
const html = await bridged.text();
|
|
const page = load(html);
|
|
// A live bridge inside `<body>`, and `<main>` still an element rather than
|
|
// script text. Read as staying double-escaped, the second script never
|
|
// closes: `#slot` is swallowed and the bridge falls back into `<head>`, so
|
|
// both assertions below fail. The bridge lands at the first `</body>` — the
|
|
// one the tree builder acts on — which is ahead of `<main>`; that position
|
|
// and the document's last `</body>` leave an identical tree, which is why
|
|
// this asserts live elements and not an offset.
|
|
expect(page('body > [data-od-url-scroll-bridge]').length).toBe(1);
|
|
expect(page('#slot').text()).toBe('real');
|
|
expect(html).toContain('<script><!--<script>--></script>');
|
|
});
|
|
|
|
it('does not treat a solidus followed by whitespace as self-closing', async () => {
|
|
const bridged = await fetch(`${rawUrl('solidus-then-space.html')}?odPreviewBridge=scroll`);
|
|
expect(bridged.status).toBe(200);
|
|
const html = await bridged.text();
|
|
const page = load(html);
|
|
expect(page('body > [data-od-url-scroll-bridge]').length).toBe(1);
|
|
expect(page('#slot').text()).toBe('real');
|
|
expect(html).toContain('<![CDATA[x > </svg><body>slip</body>]]>');
|
|
});
|
|
|
|
it('gives an svg beneath annotation-xml the SVG namespace', async () => {
|
|
const bridged = await fetch(`${rawUrl('annotation-xml-svg.html')}?odPreviewBridge=scroll`);
|
|
expect(bridged.status).toBe(200);
|
|
const html = await bridged.text();
|
|
const page = load(html);
|
|
expect(page('body > [data-od-url-scroll-bridge]').length).toBe(1);
|
|
expect(page('#slot').text()).toBe('real');
|
|
expect(html).toContain(`const x = '</math><body>slip</body>';`);
|
|
});
|
|
|
|
it('keeps a standards-mode doctype behind a bogus-comment prologue', async () => {
|
|
const bridged = await fetch(`${rawUrl('bogus-prologue.html')}?odPreviewBridge=scroll`);
|
|
expect(bridged.status).toBe(200);
|
|
const html = await bridged.text();
|
|
// The prologue and the doctype both still precede every injected token, so
|
|
// the served document keeps the mode it had before injection.
|
|
const doctypeAt = html.toLowerCase().indexOf('<!doctype');
|
|
const injectedAt = html.indexOf('data-od-url-scroll-bridge');
|
|
expect(doctypeAt).toBeGreaterThan(-1);
|
|
expect(injectedAt).toBeGreaterThan(doctypeAt);
|
|
expect(html.indexOf('<?xml')).toBeLessThan(doctypeAt);
|
|
expect(load(html)('[data-od-url-scroll-bridge]').length).toBe(1);
|
|
});
|
|
|
|
it('does not enter foreign content for an svg start tag inside select', async () => {
|
|
const bridged = await fetch(`${rawUrl('select-foreign-start.html')}?odPreviewBridge=scroll`);
|
|
expect(bridged.status).toBe(200);
|
|
const html = await bridged.text();
|
|
const page = load(html);
|
|
expect(page('body > [data-od-url-scroll-bridge]').length).toBe(1);
|
|
expect(page('#slot').text()).toBe('real');
|
|
expect(html).toContain('const x = "<table></body>";');
|
|
});
|
|
|
|
it('ends a foreign subtree by its stack, not by a name counter', async () => {
|
|
for (const [fixture, authored] of [
|
|
['nested-svg-foreignobject.html', 'const x = "<table></body>";'],
|
|
['nested-math-mi.html', 'var y = "<p></body>";'],
|
|
] as const) {
|
|
const bridged = await fetch(`${rawUrl(fixture)}?odPreviewBridge=scroll`);
|
|
expect(bridged.status).toBe(200);
|
|
const html = await bridged.text();
|
|
const page = load(html);
|
|
expect(page('body > [data-od-url-scroll-bridge]').length).toBe(1);
|
|
expect(page('#slot').text()).toBe('real');
|
|
expect(html).toContain(authored);
|
|
}
|
|
});
|
|
|
|
it('treats a second select start tag as closing the first', async () => {
|
|
const bridged = await fetch(`${rawUrl('nested-select.html')}?odPreviewBridge=scroll`);
|
|
expect(bridged.status).toBe(200);
|
|
const html = await bridged.text();
|
|
const page = load(html);
|
|
expect(page('body > [data-od-url-scroll-bridge]').length).toBe(1);
|
|
expect(page('#slot').text()).toBe('real');
|
|
expect(html).toContain('<![CDATA[x > </body>]]>');
|
|
});
|
|
|
|
it('leaves the in-select mode on input, keygen and textarea', async () => {
|
|
for (const fixture of [
|
|
'select-exit-input.html',
|
|
'select-exit-keygen.html',
|
|
'select-exit-textarea.html',
|
|
]) {
|
|
const bridged = await fetch(`${rawUrl(fixture)}?odPreviewBridge=scroll`);
|
|
expect(bridged.status).toBe(200);
|
|
const html = await bridged.text();
|
|
const page = load(html);
|
|
expect(page('body > [data-od-url-scroll-bridge]').length).toBe(1);
|
|
expect(page('#slot').text()).toBe('real');
|
|
expect(html).toContain('<![CDATA[x > </body>]]>');
|
|
}
|
|
});
|
|
|
|
it('does not let a table token put itself in a table while in select', async () => {
|
|
const bridged = await fetch(`${rawUrl('select-table-token.html')}?odPreviewBridge=scroll`);
|
|
expect(bridged.status).toBe(200);
|
|
const html = await bridged.text();
|
|
const page = load(html);
|
|
expect(page('[data-od-url-scroll-bridge]').length).toBe(1);
|
|
expect(page('#slot').text()).toBe('real');
|
|
expect(html).toContain('const x = "<table></body>";');
|
|
});
|
|
|
|
it('respects an authored base inside an HTML integration point', async () => {
|
|
const bridged = await fetch(`${rawUrl('integration-point-base.html')}?odPreviewBridge=scroll`);
|
|
expect(bridged.status).toBe(200);
|
|
const html = await bridged.text();
|
|
// The generated base would land in `<head>`, ahead of the authored one, and
|
|
// the first base with an href wins — so it must not be generated at all.
|
|
expect(html).not.toContain('data-od-project-preview-base');
|
|
expect(html).toContain('href="https://author.example/assets/"');
|
|
expect(load(html)('[data-od-url-scroll-bridge]').length).toBe(1);
|
|
});
|
|
|
|
it('does not treat a foreign-namespace or template base as authored', async () => {
|
|
const bridged = await fetch(`${rawUrl('foreign-namespace-base.html')}?odPreviewBridge=scroll`);
|
|
expect(bridged.status).toBe(200);
|
|
const html = await bridged.text();
|
|
// Neither of those governs the document, so containment still applies.
|
|
expect(html).toContain('data-od-project-preview-base');
|
|
expect(html).toContain('href="https://ignored.example/"');
|
|
expect(html).toContain('href="https://inert.example/"');
|
|
});
|
|
|
|
it('applies the in-select mode inside template contents too', async () => {
|
|
const bridged = await fetch(`${rawUrl('template-select-foreign.html')}?odPreviewBridge=scroll`);
|
|
expect(bridged.status).toBe(200);
|
|
const html = await bridged.text();
|
|
const page = load(html);
|
|
expect(page('body > [data-od-url-scroll-bridge]').length).toBe(1);
|
|
expect(page('#slot').text()).toBe('real');
|
|
expect(html).toContain('const x = "<p></template><body>slip</body>";');
|
|
});
|
|
|
|
it('treats a foreign element named template as live, not inert', async () => {
|
|
const bridged = await fetch(`${rawUrl('svg-template-base.html')}?odPreviewBridge=scroll`);
|
|
expect(bridged.status).toBe(200);
|
|
const html = await bridged.text();
|
|
expect(html).not.toContain('data-od-project-preview-base');
|
|
expect(html).toContain('href="https://author.example/assets/"');
|
|
});
|
|
|
|
it('refuses a boundary past a stray end tag that reprocessing acts on', async () => {
|
|
const bridged = await fetch(`${rawUrl('foreign-stray-p.html')}?odPreviewBridge=scroll`);
|
|
expect(bridged.status).toBe(200);
|
|
const html = await bridged.text();
|
|
const page = load(html);
|
|
expect(page('[data-od-url-scroll-bridge]').length).toBe(1);
|
|
expect(page('#slot').text()).toBe('real');
|
|
expect(html).toContain('const x = "<body>slip</body>";');
|
|
});
|
|
|
|
it('keeps precise placement past a stray end tag that is ignored', async () => {
|
|
const bridged = await fetch(`${rawUrl('foreign-stray-div.html')}?odPreviewBridge=scroll`);
|
|
expect(bridged.status).toBe(200);
|
|
const html = await bridged.text();
|
|
const page = load(html);
|
|
expect(page('body > [data-od-url-scroll-bridge]').length).toBe(1);
|
|
expect(page('#slot').text()).toBe('real');
|
|
expect(html).toContain('<![CDATA[q > </body>]]>');
|
|
});
|
|
|
|
it('gives a nested template its own insertion mode', async () => {
|
|
const bridged = await fetch(`${rawUrl('nested-template-select.html')}?odPreviewBridge=scroll`);
|
|
expect(bridged.status).toBe(200);
|
|
const html = await bridged.text();
|
|
const page = load(html);
|
|
expect(page('body > [data-od-url-scroll-bridge]').length).toBe(1);
|
|
expect(page('#slot').text()).toBe('real');
|
|
expect(html).toContain('<![CDATA[x > </template></template><body>slip</body>]]>');
|
|
});
|
|
|
|
it('keeps a leading BOM at byte zero when there is no boundary', async () => {
|
|
const bridged = await fetch(`${rawUrl('bom-plaintext.html')}?odPreviewBridge=scroll`);
|
|
expect(bridged.status).toBe(200);
|
|
// `Response.text()` runs a UTF-8 decode, which *removes* a leading BOM —
|
|
// reading the body that way would assert nothing about the byte this spec
|
|
// exists for. Decode the bytes with the BOM preserved instead.
|
|
const html = new TextDecoder('utf-8', { ignoreBOM: true })
|
|
.decode(await bridged.arrayBuffer());
|
|
// The BOM stays the first code unit, and the doctype still precedes every
|
|
// injected token — the two conditions the browser needs to stay out of
|
|
// quirks mode.
|
|
expect(html.charCodeAt(0)).toBe(0xfeff);
|
|
const doctypeAt = html.toLowerCase().indexOf('<!doctype');
|
|
const injectedAt = html.indexOf('data-od-url-scroll-bridge');
|
|
expect(doctypeAt).toBe(1);
|
|
expect(injectedAt).toBeGreaterThan(doctypeAt);
|
|
expect(load(html)('[data-od-url-scroll-bridge]').length).toBe(1);
|
|
});
|
|
|
|
it('treats a carriage return as a tag-name delimiter', async () => {
|
|
const bridged = await fetch(`${rawUrl('cr-delimiter.html')}?odPreviewBridge=scroll`);
|
|
expect(bridged.status).toBe(200);
|
|
const html = await bridged.text();
|
|
const injectedAt = html.indexOf('data-od-url-scroll-bridge');
|
|
expect(injectedAt).toBeGreaterThan(-1);
|
|
expect(html).toContain('const doc = "<body>slip</body>";');
|
|
expect(injectedAt).toBeGreaterThan(html.indexOf('<main id="slot">real</main>'));
|
|
expect(injectedAt).toBeLessThan(html.lastIndexOf('</body>'));
|
|
// The bridge is a live element in body — not pushed into the head by a
|
|
// raw-text region the scan failed to close.
|
|
const page = load(html);
|
|
expect(page('body > [data-od-url-scroll-bridge]').length).toBe(1);
|
|
expect(page('#slot').text()).toBe('real');
|
|
});
|
|
|
|
it('injects the URL preview selection bridge only when requested', async () => {
|
|
const plain = await fetch(rawUrl('page.html'));
|
|
expect(await plain.text()).toBe('<html/>');
|
|
|
|
const bridged = await fetch(`${rawUrl('page.html')}?odPreviewBridge=selection`);
|
|
expect(bridged.status).toBe(200);
|
|
const html = await bridged.text();
|
|
expect(html).toContain('data-od-url-selection-bridge');
|
|
expect(html).toContain("type: 'od:comment-target'");
|
|
expect(html).toContain("type: 'od:preview-runtime-state-captured'");
|
|
expect(html).toContain("type: 'od:preview-open-file'");
|
|
expect(html).toContain('function previewHtmlFileForLink(');
|
|
expect(html).toContain('bodyHtml: bodyHtml');
|
|
expect(html).toContain("scriptAttrName.indexOf('data-od-url-') === 0");
|
|
expect(html).toContain('roots: roots');
|
|
expect(html).toContain("querySelectorAll('[id]')");
|
|
expect(html).toContain('candidate.contains(roots[r])');
|
|
expect(html).toContain('function postReady(');
|
|
expect(html).toContain('href: window.location.href');
|
|
expect(html).not.toContain('data-od-url-scroll-bridge');
|
|
});
|
|
|
|
it('URL selection falls back from an empty od-id to a matching screen-label selector', async () => {
|
|
const response = await fetch(`${rawUrl('body.html')}?odPreviewBridge=selection`);
|
|
expect(response.status).toBe(200);
|
|
const bridge = load(await response.text())('script[data-od-url-selection-bridge]').text();
|
|
expect(bridge).not.toBe('');
|
|
const dom = new JSDOM('<section data-od-id="" data-screen-label="Home">Home</section>', {
|
|
url: rawUrl('body.html'), runScripts: 'outside-only', pretendToBeVisual: true,
|
|
});
|
|
try {
|
|
dom.window.eval(`
|
|
window.posted = [];
|
|
window.postMessage = data => window.posted.push(data);
|
|
document.querySelector('section').getBoundingClientRect = () => new DOMRect(0, 0, 100, 100);
|
|
`);
|
|
dom.window.eval(bridge);
|
|
const result = dom.window.eval(`(() => {
|
|
window.dispatchEvent(new MessageEvent('message', {
|
|
data: { type: 'od:comment-mode', enabled: true, mode: 'picker' },
|
|
}));
|
|
const element = document.querySelector('section');
|
|
element.dispatchEvent(new MouseEvent('click', { bubbles: true, clientX: 10, clientY: 10 }));
|
|
const target = window.posted.find(data => data.type === 'od:comment-target');
|
|
return { target, matches: !!target && document.querySelector(target.selector) === element };
|
|
})()`);
|
|
expect(result).toMatchObject({
|
|
target: { elementId: 'Home', selector: '[data-screen-label="Home"]' }, matches: true,
|
|
});
|
|
} finally {
|
|
dom.window.close();
|
|
}
|
|
});
|
|
|
|
it('URL comment save validates the live selector without requiring the old identity', async () => {
|
|
const response = await fetch(`${rawUrl('body.html')}?odPreviewBridge=selection`);
|
|
const bridge = load(await response.text())('script[data-od-url-selection-bridge]').text();
|
|
const dom = new JSDOM('<body><button data-od-id="new">Changed copy</button></body>', {
|
|
url: rawUrl('body.html'), runScripts: 'outside-only', pretendToBeVisual: true,
|
|
});
|
|
try {
|
|
dom.window.eval(`window.posted=[]; window.postMessage=data=>window.posted.push(data);
|
|
document.querySelector('button').getBoundingClientRect=()=>new DOMRect(0,0,100,40);`);
|
|
dom.window.eval(bridge);
|
|
const result = dom.window.eval(`(()=>{
|
|
var query={type:'od:comment-active-target',validateForSave:true,elementId:'old-id',selector:'body > button:nth-of-type(1)',requestId:'save'};
|
|
window.dispatchEvent(new MessageEvent('message',{data:query}));
|
|
var found=window.posted.at(-1);
|
|
var navigationResults=[];
|
|
window.location.hash='section';
|
|
window.dispatchEvent(new MessageEvent('message',{data:query}));
|
|
navigationResults.push(window.posted.at(-1));
|
|
history.pushState({},'', '?view=next#section');
|
|
window.dispatchEvent(new MessageEvent('message',{data:query}));
|
|
navigationResults.push(window.posted.at(-1));
|
|
history.replaceState({},'', '?view=last');
|
|
window.dispatchEvent(new MessageEvent('message',{data:query}));
|
|
navigationResults.push(window.posted.at(-1));
|
|
window.dispatchEvent(new MessageEvent('message',{data:{type:'od:comment-mode',enabled:true,mode:'picker'}}));
|
|
document.querySelector('button').setAttribute('data-od-comment-id','12345678-1234-4234-8234-123456789abc');
|
|
window.dispatchEvent(new MessageEvent('message',{data:query}));
|
|
var replacement=window.posted.at(-1);
|
|
document.querySelector('button').remove();
|
|
window.dispatchEvent(new MessageEvent('message',{data:query}));
|
|
var missing=window.posted.at(-1);
|
|
var fallbackResults=['span','div'].map(function(tag){
|
|
document.body.innerHTML='<'+tag+'>Original</'+tag+'>';
|
|
var node=document.querySelector(tag);
|
|
node.getBoundingClientRect=()=>new DOMRect(0,0,100,40);
|
|
node.innerHTML=tag==='span'?'':'<span>A</span><span>B</span>';
|
|
window.dispatchEvent(new MessageEvent('message',{data:Object.assign({},query,{selector:'body > '+tag+':nth-of-type(1)'})}));
|
|
return window.posted.at(-1);
|
|
});
|
|
return {found,navigationResults,replacement,missing,fallbackResults};
|
|
})()`);
|
|
expect(result).toMatchObject({ fallbackResults: [
|
|
{ type: 'od:comment-active-target-update', text: '' },
|
|
{ type: 'od:comment-active-target-update', text: 'AB' },
|
|
] });
|
|
expect(result).toMatchObject({ navigationResults: Array.from({ length: 3 }, () => ({
|
|
type: 'od:comment-active-target-update', documentVersion: `url:${rawUrl('body.html')}`, elementId: 'new',
|
|
})) });
|
|
expect(result).toMatchObject({ found: { type: 'od:comment-active-target-update', documentVersion: `url:${rawUrl('body.html')}`, elementId: 'new', text: 'Changed copy' },
|
|
replacement: { elementId: 'cmt:12345678-1234-4234-8234-123456789abc',
|
|
selector: '[data-od-comment-id="12345678-1234-4234-8234-123456789abc"]', requestedSelector: 'body > button:nth-of-type(1)' },
|
|
missing: { type: 'od:comment-location-missing', requestId: 'save' } });
|
|
} finally { dom.window.close(); }
|
|
});
|
|
|
|
it('URL served complete bridge strictly describes and locates persisted comment identities', async () => {
|
|
const response = await fetch(`${rawUrl('body.html')}?odPreviewBridge=selection`);
|
|
expect(response.status).toBe(200);
|
|
const bridge = load(await response.text())('script[data-od-url-selection-bridge]').text();
|
|
const uuid = '12345678-1234-4234-8234-123456789abc';
|
|
const dom = new JSDOM(`<body><h2 data-od-id="tool" data-od-comment-id="${uuid}">Target</h2></body>`, {
|
|
url: rawUrl('body.html'), runScripts: 'outside-only', pretendToBeVisual: true,
|
|
});
|
|
try {
|
|
dom.window.eval(`window.posted=[]; window.postMessage=data=>window.posted.push(data);
|
|
window.requestAnimationFrame=fn=>{ fn(); return 1; };
|
|
document.querySelector('h2').getBoundingClientRect=()=>new DOMRect(0,0,100,100);
|
|
document.querySelector('h2').scrollIntoView=()=>{window.scrolled=true;};`);
|
|
dom.window.eval(bridge);
|
|
const result = dom.window.eval(`(()=>{
|
|
window.dispatchEvent(new MessageEvent('message',{data:{type:'od:comment-mode',enabled:true,mode:'picker'}}));
|
|
document.querySelector('h2').dispatchEvent(new MouseEvent('click',{bubbles:true}));
|
|
var selected=window.posted.find(data=>data.type==='od:comment-target');
|
|
var duplicate=document.createElement('h2'); duplicate.hidden=true; duplicate.setAttribute('data-od-comment-id','${uuid}'); document.body.appendChild(duplicate);
|
|
window.posted=[];
|
|
window.dispatchEvent(new MessageEvent('message',{data:{type:'od:comment-active-target',elementId:'cmt:${uuid}',selector:'[data-od-comment-id="${uuid}"]',locate:true,requestId:'hidden-duplicate'}}));
|
|
return {selected,scrolled:!!window.scrolled,messages:window.posted};
|
|
})()`);
|
|
expect(result).toMatchObject({ selected: { elementId: `cmt:${uuid}`, selector: `[data-od-comment-id="${uuid}"]` }, scrolled: false,
|
|
messages: expect.arrayContaining([expect.objectContaining({type:'od:comment-location-missing',requestId:'hidden-duplicate'})]) });
|
|
} finally { dom.window.close(); }
|
|
});
|
|
|
|
it.each(['hidden', 'wrong-prefix'] as const)('HTTP-served complete URL reader refuses %s after a healthy visible locate', async (failure) => {
|
|
const response = await fetch(`${rawUrl('body.html')}?odPreviewBridge=selection`);
|
|
expect(response.status).toBe(200);
|
|
const bridge = load(await response.text())('script[data-od-url-selection-bridge]').text();
|
|
const uuid = '12345678-1234-4234-8234-123456789abc';
|
|
const dom = new JSDOM(`<body><h2 data-od-comment-id="${uuid}">Target</h2></body>`, { url: rawUrl('body.html'), runScripts: 'outside-only', pretendToBeVisual: true });
|
|
try {
|
|
dom.window.eval(`window.posted=[]; window.scrollCount=0; window.postMessage=data=>window.posted.push(data);
|
|
window.requestAnimationFrame=fn=>{fn();return 1;};
|
|
document.querySelector('h2').getBoundingClientRect=()=>new DOMRect(0,0,100,100);
|
|
document.querySelector('h2').scrollIntoView=()=>{window.scrollCount++;};`);
|
|
dom.window.eval(bridge);
|
|
const healthy = dom.window.eval(`(()=>{
|
|
window.dispatchEvent(new MessageEvent('message',{data:{type:'od:comment-mode',enabled:true}}));
|
|
window.dispatchEvent(new MessageEvent('message',{data:{type:'od:comment-active-target',elementId:'cmt:${uuid}',selector:'[data-od-comment-id="${uuid}"]',locate:true,requestId:'healthy'}}));
|
|
return {scrolls:window.scrollCount,messages:window.posted};})()`);
|
|
expect(healthy).toMatchObject({ scrolls: 1, messages: expect.arrayContaining([expect.objectContaining({ type: 'od:comment-active-target-update', requestId: 'healthy' })]) });
|
|
const result = dom.window.eval(`(()=>{
|
|
window.posted=[];
|
|
${failure === 'hidden' ? "document.querySelector('h2').style.visibility='hidden';" : ''}
|
|
window.dispatchEvent(new MessageEvent('message',{data:{type:'od:comment-active-target',elementId:'${failure === 'wrong-prefix' ? 'bad:' : 'cmt:'}${uuid}',selector:'[data-od-comment-id="${uuid}"]',locate:true,requestId:'refused'}}));
|
|
return {scrolls:window.scrollCount,messages:window.posted};})()`);
|
|
expect(result).toMatchObject({ scrolls: 1, messages: expect.arrayContaining([expect.objectContaining({ type: 'od:comment-location-missing', requestId: 'refused' })]) });
|
|
} finally { dom.window.close(); }
|
|
});
|
|
|
|
it('injects the URL preview snapshot bridge only when requested', async () => {
|
|
const plain = await fetch(rawUrl('page.html'));
|
|
expect(await plain.text()).toBe('<html/>');
|
|
|
|
const bridged = await fetch(`${rawUrl('page.html')}?odPreviewBridge=snapshot`);
|
|
expect(bridged.status).toBe(200);
|
|
const html = await bridged.text();
|
|
expect(html).toContain('data-od-url-snapshot-bridge');
|
|
expect(html).toContain("type: 'od:snapshot:result'");
|
|
expect(html).not.toContain('data-od-url-scroll-bridge');
|
|
expect(html).not.toContain('data-od-url-selection-bridge');
|
|
});
|
|
|
|
it('injects URL preview observability before author scripts when requested', async () => {
|
|
const bridged = await fetch(`${rawUrl('body.html')}?odPreviewBridge=observability`);
|
|
expect(bridged.status).toBe(200);
|
|
const html = await bridged.text();
|
|
expect(html).toContain('data-od-preview-observability');
|
|
expect(html).toContain("send('runtime_error'");
|
|
expect(html).toContain("send('white_screen'");
|
|
expect(html.indexOf('data-od-preview-observability')).toBeLessThan(html.indexOf('<body>'));
|
|
});
|
|
|
|
it('injects passive URL guards before authored scripts', async () => {
|
|
const bridged = await fetch(
|
|
`${rawUrl('guarded.html')}?odPreviewBridge=sandbox&odPreviewBridge=focus&odPreviewBridge=redirect`,
|
|
);
|
|
expect(bridged.status).toBe(200);
|
|
const html = await bridged.text();
|
|
const authorScriptIndex = html.indexOf('<script src="./boot.js">');
|
|
expect(authorScriptIndex).toBeGreaterThan(-1);
|
|
expect(html).toContain('data-od-sandbox-shim');
|
|
expect(html).toContain('data-od-preview-focus-guard');
|
|
expect(html).toContain('data-od-preview-redirect-guard');
|
|
expect(html.indexOf('data-od-sandbox-shim')).toBeLessThan(authorScriptIndex);
|
|
expect(html.indexOf('data-od-preview-focus-guard')).toBeLessThan(authorScriptIndex);
|
|
expect(html.indexOf('data-od-preview-redirect-guard')).toBeLessThan(authorScriptIndex);
|
|
});
|
|
|
|
it('preserves and serves complex nested external resources through a guarded URL preview', async () => {
|
|
const response = await fetch(
|
|
`${rawUrl('prototypes/booking/index.html')}?odPreviewBridge=scroll&odPreviewBridge=selection&odPreviewBridge=snapshot&odPreviewBridge=observability&odPreviewBridge=sandbox&odPreviewBridge=focus`,
|
|
);
|
|
expect(response.status).toBe(200);
|
|
const html = await response.text();
|
|
|
|
const firstAuthorScript = html.indexOf('<script src="./scripts/support.js">');
|
|
expect(firstAuthorScript).toBeGreaterThan(-1);
|
|
expect(html.indexOf('data-od-sandbox-shim')).toBeLessThan(firstAuthorScript);
|
|
expect(html.indexOf('data-od-preview-focus-guard')).toBeLessThan(firstAuthorScript);
|
|
expect(html.match(/type="text\/babel"/g)).toHaveLength(43);
|
|
expect(html).toContain('<script type="module" src="./scripts/module.js"></script>');
|
|
expect(html).toContain('srcset="./assets/card.svg 1x, ./assets/card@2x.svg 2x"');
|
|
|
|
const baseHref = html.match(/<base href="([^"]+)" data-od-project-preview-base>/)?.[1];
|
|
expect(baseHref).toBeTruthy();
|
|
const previewBase = new URL(baseHref!, baseUrl);
|
|
const expectedResources = new Map([
|
|
['./styles/app.css', '@import "./theme.css"; .card { background-image: url("../assets/card.svg"); }'],
|
|
['./styles/theme.css', ':root { --accent: #0a7; }'],
|
|
['./scripts/support.js', 'window.__supportLoaded = true; fetch("./data.json").then((response) => response.json());'],
|
|
['./scripts/module.js', 'export const ready = true;'],
|
|
['./components/screen-43.jsx', 'window.__screen43 = () => <section>Screen 43</section>;'],
|
|
['./data.json', '{"ready":true}'],
|
|
['./assets/card.svg', '<svg xmlns="http://www.w3.org/2000/svg"/>'],
|
|
['./assets/card@2x.svg', '<svg xmlns="http://www.w3.org/2000/svg" width="2"/>'],
|
|
]);
|
|
for (const [relativePath, expectedBody] of expectedResources) {
|
|
const assetResponse = await fetch(new URL(relativePath, previewBase));
|
|
expect(assetResponse.status, relativePath).toBe(200);
|
|
expect(await assetResponse.text(), relativePath).toBe(expectedBody);
|
|
}
|
|
});
|
|
|
|
it('serves built dist HTML for Vite dev entries so previews do not load /src from daemon root', async () => {
|
|
const res = await fetch(rawUrl('vite-entry.html'));
|
|
expect(res.status).toBe(200);
|
|
const html = await res.text();
|
|
|
|
expect(html).not.toContain('/src/main.tsx');
|
|
expect(html).not.toContain('src="/assets/app.js"');
|
|
expect(html).not.toContain('href="/assets/app.css"');
|
|
expect(html).toContain('src="dist/assets/app.js"');
|
|
expect(html).toContain('href="dist/assets/app.css"');
|
|
});
|
|
|
|
it('does not expose powered preview project files to foreign browser origins through CORS', async () => {
|
|
const browserOrigin = new URL(baseUrl);
|
|
browserOrigin.hostname = browserOrigin.hostname === '127.0.0.1'
|
|
? 'localhost'
|
|
: '127.0.0.1';
|
|
|
|
const res = await fetch(poweredUrl('page.html'), {
|
|
headers: { Origin: browserOrigin.origin },
|
|
});
|
|
expect(res.status).toBe(200);
|
|
expect(res.headers.get('document-isolation-policy')).toBe('isolate-and-credentialless');
|
|
expect(res.headers.get('access-control-allow-origin')).toBeNull();
|
|
expect(await res.text()).toBe('<html/>');
|
|
|
|
const foreign = await fetch(poweredUrl('page.html'), {
|
|
headers: { Origin: 'https://foreign.example' },
|
|
});
|
|
expect(foreign.status).toBe(403);
|
|
expect(foreign.headers.get('access-control-allow-origin')).toBeNull();
|
|
|
|
const preflight = await fetch(poweredUrl('page.html'), {
|
|
method: 'OPTIONS',
|
|
headers: {
|
|
Origin: browserOrigin.origin,
|
|
'Access-Control-Request-Method': 'GET',
|
|
},
|
|
});
|
|
expect(preflight.status).toBe(204);
|
|
expect(preflight.headers.get('access-control-allow-origin')).toBeNull();
|
|
});
|
|
|
|
it('injects the URL preview scroll bridge for powered previews when requested', async () => {
|
|
const bridged = await fetch(`${poweredUrl('page.html')}?odPreviewBridge=scroll`);
|
|
expect(bridged.status).toBe(200);
|
|
expect(bridged.headers.get('document-isolation-policy')).toBe('isolate-and-credentialless');
|
|
const html = await bridged.text();
|
|
expect(html).toContain('data-od-url-scroll-bridge');
|
|
expect(html).toContain("type: 'od:preview-content-size'");
|
|
expect(html).toContain('od:preview-content-size-request');
|
|
expect(html).toContain('lastContentSizeRequest.measurementId');
|
|
expect(html).toContain('lastContentSizeRequest.generation');
|
|
expect(html).toContain('documentEpoch: contentSizeDocumentEpoch');
|
|
expect(html).toContain("get('odPreviewEpoch')");
|
|
expect(html).toContain('scrollWidth: size && size.scrollWidth');
|
|
expect(html).toContain('clientWidth: size && size.clientWidth');
|
|
});
|
|
|
|
it('injects preview observability for powered previews when requested', async () => {
|
|
const bridged = await fetch(`${poweredUrl('page.html')}?odPreviewBridge=observability`);
|
|
expect(bridged.status).toBe(200);
|
|
expect(bridged.headers.get('document-isolation-policy')).toBe('isolate-and-credentialless');
|
|
const html = await bridged.text();
|
|
expect(html).toContain('data-od-preview-observability');
|
|
expect(html).toContain("send('runtime_error'");
|
|
expect(html).toContain("send('white_screen'");
|
|
});
|
|
|
|
it('does not let the powered preview origin call normal daemon APIs', async () => {
|
|
const origin = poweredOrigin();
|
|
const poweredReferer = `${origin}/api/projects/${projectId}/powered/page.html`;
|
|
|
|
const poweredFile = await fetch(`${origin}/api/projects/${projectId}/powered/page.html`, {
|
|
headers: {
|
|
Referer: poweredReferer,
|
|
'Sec-Fetch-Site': 'same-origin',
|
|
},
|
|
});
|
|
expect(poweredFile.status).toBe(200);
|
|
expect(await poweredFile.text()).toBe('<html/>');
|
|
|
|
const api = await fetch(`${origin}/api/projects`, {
|
|
headers: {
|
|
Referer: poweredReferer,
|
|
'Sec-Fetch-Site': 'same-origin',
|
|
},
|
|
});
|
|
expect(api.status).toBe(403);
|
|
expect(await api.json()).toEqual({
|
|
error: 'Powered preview origin cannot access this API route',
|
|
});
|
|
});
|
|
|
|
// Node fetch replaces a supplied Host header. Use the real HTTP transport
|
|
// to exercise the same request host that the browser's project URL sends.
|
|
function projectOriginRequest(
|
|
routePath: string,
|
|
options: { method?: string; headers: http.OutgoingHttpHeaders },
|
|
): Promise<{ status: number; headers: http.IncomingHttpHeaders; body: string }> {
|
|
return new Promise((resolve, reject) => {
|
|
const request = http.request(new URL(routePath, baseUrl), options, (response) => {
|
|
let body = '';
|
|
response.setEncoding('utf8');
|
|
response.on('data', (chunk: string) => { body += chunk; });
|
|
response.on('end', () => resolve({
|
|
status: response.statusCode ?? 0,
|
|
headers: response.headers,
|
|
body,
|
|
}));
|
|
});
|
|
request.on('error', reject);
|
|
request.end();
|
|
});
|
|
}
|
|
|
|
it('serves project-scoped powered files on their matching browser origin', async () => {
|
|
const host = `od-${createHash('sha256').update(projectId).digest('hex').slice(0, 32)}.localhost:${new URL(baseUrl).port}`;
|
|
const response = await projectOriginRequest(`/api/projects/${projectId}/powered/page.html`, {
|
|
headers: { Host: host, Origin: `http://${host}`, 'Sec-Fetch-Site': 'same-origin' },
|
|
});
|
|
expect(response.status).toBe(200);
|
|
expect(response.headers['document-isolation-policy']).toBe('isolate-and-credentialless');
|
|
expect(response.headers['access-control-allow-origin']).toBeUndefined();
|
|
expect(response.body).toBe('<html/>');
|
|
const capability = await fetch(`${baseUrl}/api/preview/isolation`);
|
|
expect(await capability.json()).toMatchObject({ projectOrigins: true });
|
|
});
|
|
|
|
it.each([
|
|
{ method: 'GET', path: '/api/projects' },
|
|
{ method: 'GET', path: '/api/preview/isolation' },
|
|
{ method: 'GET', path: '/api/library/clipper-probe' },
|
|
{ method: 'GET', path: '/api/live-artifacts/missing/preview' },
|
|
{ method: 'GET', path: '/api/projects/another-project/powered/page.html' },
|
|
{ method: 'GET', path: '/api/projects/proj-raw-range-test/raw/page.html' },
|
|
{ method: 'POST', path: '/api/projects/proj-raw-range-test/powered/page.html' },
|
|
{ method: 'GET', path: '/artifacts/private.png' },
|
|
{ method: 'GET', path: '/frames/index.html' },
|
|
{ method: 'GET', path: '/' },
|
|
])('restricts project-scoped powered origins before serving $method $path', async ({ method, path: routePath }) => {
|
|
const host = `od-${createHash('sha256').update(projectId).digest('hex').slice(0, 32)}.localhost:${new URL(baseUrl).port}`;
|
|
const response = await projectOriginRequest(routePath, {
|
|
method,
|
|
headers: { Host: host, 'Sec-Fetch-Site': 'same-origin' },
|
|
});
|
|
expect(response.status).toBe(403);
|
|
});
|
|
|
|
it('rejects foreign project-scoped powered subresources and API reads through the normal host', async () => {
|
|
const host = `od-${createHash('sha256').update(projectId).digest('hex').slice(0, 32)}.localhost:${new URL(baseUrl).port}`;
|
|
const otherHost = `od-${createHash('sha256').update('another-project').digest('hex').slice(0, 32)}.localhost:${new URL(baseUrl).port}`;
|
|
const foreignAsset = await projectOriginRequest(`/api/projects/${projectId}/powered/page.html`, {
|
|
headers: {
|
|
Host: host,
|
|
Referer: `http://${otherHost}/api/projects/another-project/powered/page.html`,
|
|
'Sec-Fetch-Site': 'cross-site',
|
|
'Sec-Fetch-Dest': 'script',
|
|
},
|
|
});
|
|
expect(foreignAsset.status).toBe(403);
|
|
const api = await projectOriginRequest('/api/projects', {
|
|
headers: {
|
|
Referer: `http://${host}/api/projects/${projectId}/powered/page.html`,
|
|
'Sec-Fetch-Site': 'cross-site',
|
|
},
|
|
});
|
|
expect(api.status).toBe(403);
|
|
});
|
|
|
|
it('injects all URL preview bridges together', async () => {
|
|
const bridged = await fetch(`${rawUrl('body.html')}?odPreviewBridge=scroll&odPreviewBridge=selection&odPreviewBridge=snapshot&odPreviewBridge=observability`);
|
|
expect(bridged.status).toBe(200);
|
|
const html = await bridged.text();
|
|
expect(html).toContain('data-od-url-scroll-bridge');
|
|
expect(html).toContain('data-od-url-selection-bridge');
|
|
expect(html).toContain('data-od-url-snapshot-bridge');
|
|
expect(html).toContain('data-od-preview-observability');
|
|
expect(html.indexOf('data-od-preview-observability')).toBeLessThan(html.indexOf('<body>'));
|
|
expect(html.indexOf('data-od-url-scroll-bridge')).toBeLessThan(html.indexOf('</body>'));
|
|
expect(html.indexOf('data-od-url-selection-bridge')).toBeLessThan(html.indexOf('</body>'));
|
|
expect(html.indexOf('data-od-url-snapshot-bridge')).toBeLessThan(html.indexOf('</body>'));
|
|
});
|
|
|
|
it('does not inject the URL preview scroll bridge twice', async () => {
|
|
const bridged = await fetch(`${rawUrl('bridged.html')}?odPreviewBridge=scroll`);
|
|
expect(bridged.status).toBe(200);
|
|
const html = await bridged.text();
|
|
expect(html.match(/data-od-url-scroll-bridge/g)?.length).toBe(1);
|
|
});
|
|
|
|
it('does not inject the URL preview selection bridge twice', async () => {
|
|
const bridged = await fetch(`${rawUrl('selection-bridged.html')}?odPreviewBridge=selection`);
|
|
expect(bridged.status).toBe(200);
|
|
const html = await bridged.text();
|
|
expect(html.match(/data-od-url-selection-bridge/g)?.length).toBe(1);
|
|
});
|
|
|
|
it('does not inject the URL preview snapshot bridge twice', async () => {
|
|
const bridged = await fetch(`${rawUrl('snapshot-bridged.html')}?odPreviewBridge=snapshot`);
|
|
expect(bridged.status).toBe(200);
|
|
const html = await bridged.text();
|
|
expect(html.match(/data-od-url-snapshot-bridge/g)?.length).toBe(1);
|
|
});
|
|
|
|
it('does not inject the URL preview observability bridge twice', async () => {
|
|
const bridged = await fetch(`${rawUrl('observability-bridged.html')}?odPreviewBridge=observability`);
|
|
expect(bridged.status).toBe(200);
|
|
const html = await bridged.text();
|
|
expect(html.match(/data-od-preview-observability/g)?.length).toBe(1);
|
|
});
|
|
|
|
it('returns 404 for a missing file', async () => {
|
|
const res = await fetch(rawUrl('missing.mp4'));
|
|
expect(res.status).toBe(404);
|
|
});
|
|
});
|