mirror of
https://github.com/nexu-io/open-design.git
synced 2026-09-20 06:15:06 +08:00
7f65c795d9
* refactor: share one structural HTML injection-point locator #7410's root cause was two copies of the same lookup drifting apart: the srcDoc path anchored on the real `</body>` while the daemon copy still matched the first one in the text. This puts the locator in `packages/contracts` and points every injector at it — daemon preview bridges and containment base, web srcDoc and export paths, desktop PDF/artifact/deck capture, and the brand build. Rebuilt on `main` rather than rebased onto it. This branch was cut before #7421 and carried its 28 commits, so a rebase replayed them against a main that already had them and conflicted on the first one; replaying 17 commits through that would have been the easy way to silently drop one of the fixes. The net change is the same 26 files, and only four of them had also moved on main. The shared module is the daemon implementation as merged, not a reconstruction. Verified as equivalence before the daemon's copy was deleted: against `main`'s `apps/daemon/src/routes/project/index.ts` on identical inputs, 2,544 repository HTML files × 7 lookups and 15,000 generated adversarial documents × 6 lookups produce byte-identical answers — 0 divergences. The generated corpus is built from every hazard #7421's review rounds turned up, which is the comparison that would have caught the drift this PR exists to prevent. Three call sites finished their migration on the way: `daemonSanitizeTitleInDoc`, `assembleExample` and the collab-sync title read all located `<title>` structurally and then searched for its close as text, so `</title >` and `</title\n>` were missed and a `</title` inside the open tag's own attributes could match first. All three go through `findRealElementRange` now. `findRealElementRange` itself built its shadow with `toLowerCase()`, which is not length-preserving; it uses `asciiLower` like the rest of the module. `pnpm guard` gains an "HTML structural boundaries" check that fails any new hand-rolled boundary lookup outside the shared module — the safeguard #7421's rationale comment once claimed and did not have. Validation: 100 daemon route/containment specs (#7421's full set, now running against the shared locator), 45 contracts specs, 817 web runtime specs including the jsdom differential oracle, guard exit 0 with the new check reporting, daemon and contracts typecheck clean. * fix: finish the title migration in srcdoc and the prefetch reader Two from review, both the same leftover: the open tag was located structurally and then the close was searched for as text. I had claimed this migration finished in three call sites and it was five — a sweep of the repository for `indexOf('</…')` found exactly these two remaining, and nothing else (the other hits are `<artifact>` / `<PANELIST>` marker streams, which are not HTML). `sanitizeTitleInDoc` used `indexOf('</title>')`, which accepts one spelling. A document titled with `</title >` fell through to the next `</title>` in the source — here one inside an authored script string — and the rewrite spanned everything between, splicing into author content exactly as #7410 did. `readRealTitleText` used `indexOf('</title')` with no boundary check, so `</title-page>` ended the title. Both go through `findRealElementRange` now, and with it the `toLowerCase()` shadow both were building, which is not length-preserving. Two specs. The `</title >` one is red on the previous commit. The `</title-page>` one is green on both — `indexOf('</title>')` happened to skip it too, since it demands the full spelling — so it is regression cover rather than a red spec, and it is worth having because the new code reaches that case through a different rule. Writing them cost three wrong assertions before one held, all mine rather than the code's: the title text is rewritten by `sanitizePreviewTitle`, so the raw authored bytes are not observable in the output, and `b</title>` — my second attempt at something that would be — is a substring of the sanitized `a-title-page-b</title>`. The spec asserts the sanitized title itself now. Validation: 819 web runtime specs, 100 daemon route/containment specs, `pnpm guard` exit 0 with the structural-boundary check reporting, web and daemon typecheck clean.
163 lines
8.0 KiB
TypeScript
163 lines
8.0 KiB
TypeScript
import { JSDOM } from 'jsdom';
|
|
import { describe, expect, it } from 'vitest';
|
|
|
|
import { findRealTagOffset, HTML_TAG_PATTERNS } from '@open-design/contracts/runtime/html-injection-points';
|
|
|
|
// --------------------------------------------------------------------------
|
|
// Differential oracle for the shared injection-point scanner.
|
|
//
|
|
// The scanner is a tokenizer-level approximation, chosen over a full parse
|
|
// because it runs on every previewed asset (~13x faster on a 1MB document) and
|
|
// because contracts ships into the browser bundle. What it must never be is
|
|
// *wrong* where the spec is right — so this pins it against a real HTML5
|
|
// parser. jsdom is built on parse5, the html5lib-tested implementation, and is
|
|
// already a dependency here; contracts stays dependency-free by hosting the
|
|
// oracle in the package that already pays for a parser.
|
|
//
|
|
// The property asserted is the outcome, not the offset: inserting at the
|
|
// scanner's offset must leave the parse tree with one extra element inside
|
|
// <body> and nothing else moved.
|
|
//
|
|
// Outcome rather than offset equality is deliberate. Where a source token
|
|
// closes an element is not the same question as where an injection may go: in
|
|
// `…</body></html><p>tail</p></body></html>` the spec reopens the body, so a
|
|
// parser attributes the close to the *second* `</body>` while inserting before
|
|
// the first is equally correct. Asserting offsets would fail that document for
|
|
// no reason.
|
|
// --------------------------------------------------------------------------
|
|
|
|
const BRIDGE_ATTR = 'data-od-test-bridge';
|
|
const BRIDGE = `<script ${BRIDGE_ATTR}>var s = "</div>";<\/script>`;
|
|
|
|
/** Comments are inert; dropping them keeps the comparison about structure. */
|
|
function stripComments(document: Document): string {
|
|
const walker = document.createTreeWalker(document.documentElement, 128 /* SHOW_COMMENT */);
|
|
const comments: ChildNode[] = [];
|
|
for (let node = walker.nextNode(); node; node = walker.nextNode()) comments.push(node as ChildNode);
|
|
comments.forEach((node) => node.remove());
|
|
return document.documentElement.outerHTML;
|
|
}
|
|
|
|
function structure(html: string): string {
|
|
return stripComments(new JSDOM(html).window.document);
|
|
}
|
|
|
|
function injectAndCompare(html: string): { parent: string | null; unchanged: boolean } {
|
|
const before = structure(html);
|
|
const at = findRealTagOffset(html, HTML_TAG_PATTERNS.bodyClose);
|
|
const injected = at >= 0 ? html.slice(0, at) + BRIDGE + html.slice(at) : html + BRIDGE;
|
|
|
|
const { document } = new JSDOM(injected).window;
|
|
const el = document.querySelector(`[${BRIDGE_ATTR}]`);
|
|
if (!el) return { parent: null, unchanged: false };
|
|
const parent = el.parentElement?.tagName ?? null;
|
|
el.remove();
|
|
|
|
return { parent, unchanged: stripComments(document) === before };
|
|
}
|
|
|
|
/** Documents where a boundary tag also appears as ordinary content. */
|
|
const HAZARDS: Record<string, string> = {
|
|
'script builds a document string':
|
|
'<!doctype html><html><head></head><body><script>var t=`<body>x</body>`;<\/script><p>hi</p></body></html>',
|
|
'markup stored on a data attribute':
|
|
'<!doctype html><html><head></head><body><div data-t="<body>x</body>"></div><p>hi</p></body></html>',
|
|
'boundary inside a comment':
|
|
'<!doctype html><html><head></head><body><!-- </body> --><p>hi</p></body></html>',
|
|
'boundary inside a textarea':
|
|
'<!doctype html><html><head></head><body><textarea></body></textarea><p>hi</p></body></html>',
|
|
'boundary inside a style block':
|
|
'<!doctype html><html><head><style>/* </body> */</style></head><body><p>hi</p></body></html>',
|
|
'boundary inside a title':
|
|
'<!doctype html><html><head><title>a </body> b</title></head><body><p>hi</p></body></html>',
|
|
'boundary inside a template':
|
|
'<!doctype html><html><head></head><body><template></body></template><p>hi</p></body></html>',
|
|
'boundary inside noscript':
|
|
'<!doctype html><html><head></head><body><noscript></body></noscript><p>hi</p></body></html>',
|
|
'boundary inside xmp':
|
|
'<!doctype html><html><head></head><body><xmp></body></xmp><p>hi</p></body></html>',
|
|
'quoted angle bracket in an attribute':
|
|
'<!doctype html><html><head></head><body><div title="a>b" data-t="</body>"></div><p>hi</p></body></html>',
|
|
'quote inside an unquoted attribute':
|
|
"<!doctype html><html><head></head><body><div data-x=a'b><p>hi</p></div></body></html>",
|
|
'legacy doctype with a quoted string':
|
|
'<!DOCTYPE html SYSTEM "about:legacy-compat"><html><head></head><body><p>hi</p></body></html>',
|
|
'less-than in prose':
|
|
'<!doctype html><html><head></head><body><p>a < b and 3<4</p></body></html>',
|
|
'escaped close tag in a script':
|
|
'<!doctype html><html><head></head><body><script>var s="<\\/script>";<\/script><p>hi</p></body></html>',
|
|
'body carrying the boundary as an attribute':
|
|
'<!doctype html><html><head></head><body data-t="</body>"><p>hi</p></body></html>',
|
|
'no explicit body close':
|
|
'<!doctype html><html><head></head><body><p>hi</p>',
|
|
'bare fragment':
|
|
'<p>hi</p><script>var t=`</body>`;<\/script>',
|
|
'no head at all':
|
|
'<!doctype html><html><body><script>var t=`<head></head>`;<\/script><p>hi</p></body></html>',
|
|
'uppercase close tag':
|
|
'<!doctype html><html><head></head><body><p>hi</p></BODY></html>',
|
|
'whitespace before the close bracket':
|
|
'<!doctype html><html><head></head><body><p>hi</p></body ></html>',
|
|
'content after the document closes':
|
|
'<html><head></head><body><p>hi</p></body></html><p>tail</p></body></html>',
|
|
'script appended past the document close':
|
|
'<!doctype html><html><head></head><body><p>hi</p></body></html><script>window.__tail=1;<\/script>',
|
|
'doubled close tags':
|
|
'<!doctype html><html><head></head><body data-t="</body>"><div><p>hi</p></div></body></html></body></html>',
|
|
};
|
|
|
|
describe('inserting at the scanner offset', () => {
|
|
it.each(Object.entries(HAZARDS))(
|
|
'adds one element inside <body> and moves nothing else — %s',
|
|
(_name, html) => {
|
|
expect(injectAndCompare(html)).toEqual({ parent: 'BODY', unchanged: true });
|
|
},
|
|
);
|
|
|
|
// Two jsdom parses per document makes this the one genuinely slow spec in the
|
|
// file; give it room rather than let a loaded CI box flake it out.
|
|
it('holds across generated documents', { timeout: 30_000 }, () => {
|
|
// A deterministic LCG keeps a failure reproducible; a random seed would
|
|
// report a different document on every run.
|
|
let seed = 20260826;
|
|
const random = (): number => (seed = (seed * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff;
|
|
const pick = <T>(values: readonly T[]): T => values[Math.floor(random() * values.length) % values.length]!;
|
|
|
|
const parts = [
|
|
'<script>var t=`<body>x</body>`;<\/script>',
|
|
'<script>var t=`<head><title>t</title></head>`;<\/script>',
|
|
'<div data-t="<body>x</body>"></div>',
|
|
'<div data-t="</head>" title="a>b"></div>',
|
|
'<!-- </body> </head> -->',
|
|
'<textarea></body></textarea>',
|
|
'<style>/* </body> <head> */</style>',
|
|
'<p>a < b and 3<4</p>',
|
|
"<div data-x=a'b>u</div>",
|
|
'<template></body></template>',
|
|
'<noscript></body></noscript>',
|
|
'<xmp></body></xmp>',
|
|
'<script>var s="<\\/script>";<\/script>',
|
|
'<div title="</body>"></div>',
|
|
'<p>plain</p>',
|
|
'<br/>',
|
|
];
|
|
const heads = ['<head></head>', '<head>', '<head lang="x" data-t="</head>">', '', '<head><title>a</title></head>'];
|
|
const doctypes = ['<!doctype html>', '<!DOCTYPE html SYSTEM "about:legacy-compat">', ''];
|
|
const bodies = ['<body>', '<body class="a">', '<body data-t="</body>">', ''];
|
|
|
|
const failures: string[] = [];
|
|
for (let i = 0; i < 150; i += 1) {
|
|
let inner = '';
|
|
for (let part = 0; part < 1 + Math.floor(random() * 4); part += 1) inner += pick(parts);
|
|
const head = pick(heads);
|
|
const body = pick(bodies);
|
|
const html = `${pick(doctypes)}<html>${head}${head && !head.includes('</head>') ? '</head>' : ''}`
|
|
+ `${body}${inner}${body ? '</body>' : ''}</html>`;
|
|
const result = injectAndCompare(html);
|
|
if (result.parent !== 'BODY' || !result.unchanged) failures.push(html);
|
|
}
|
|
|
|
expect(failures).toEqual([]);
|
|
});
|
|
});
|