Files
site-builder/craft/src/utils/scope-css.test.ts
T
shadowdaoandClaude Opus 5 916a568e9f fix(site-builder): address Task 25 review findings on <style> scoping
Four issues from adversarial review of the block-scoped <style> feature:

1. (Critical) transformBlock() recursed once per @media/@supports/@container
   nesting level with no cap -- ~7000 nested rules blew the call stack, and
   nothing between a Custom HTML block's toHtml() and the publish pipeline
   catches exceptions, so this took down the whole page's publish and
   crashed the live editor on every keystroke. Added MAX_NESTING_DEPTH=20
   (pass the body through unscoped beyond it) and wrapped scopeCss() so it
   never throws on any input, matching repairOrphanNodes's existing
   contract. Caught and fixed a variable-shadowing bug in my own first pass
   at this: the new depth parameter was silently shadowed by a pre-existing
   `let depth` used for brace-matching in the same block, which would have
   defeated the cap with no type error.

2. (Important) FORCE_BODY: true was unconditional, but it isn't a no-op for
   style-free input: it also changes how the parser preserves whitespace
   after a LEADING html comment, which this repo's own fixture starts with.
   Verified via a raw byte-diff against HtmlBlock.tsx@6a9b227 (extracted
   verbatim, run standalone against real dompurify+jsdom) that the fixture
   gained bytes. Fixed by applying FORCE_BODY only when the input has a
   real (non-comment) <style> tag to rescue -- confirmed empirically that
   this is a true no-op for every other input. Pinned the old output as a
   checked-in regression fixture and added a raw toBe() diff test.

3. (Important) scopeStyleBlocks() wasn't idempotent -- pasting previously
   published/exported output into a fresh block nested a second wrapper
   and re-prefixed every selector. Added isAlreadyScoped(), which detects
   a lone root wrapper whose <style> content is already a no-op under
   scopeCss for that wrapper's own class (reusing scopeCss's own
   idempotency guarantee) and leaves it untouched.

4. (Minor) Documented, not fixed: the 32-bit scope-id hash is
   brute-forceable (CSS-only impact, same trust tier as other accepted
   risks here), and DOMPurify's SAFE_FOR_XML silently drops an entire
   <style> block when its content merely looks tag-like (e.g.
   content: "<Read More>").

1155/1155 tests passing (was 1141), tsc clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 17:36:12 -07:00

352 lines
15 KiB
TypeScript

import { describe, test, expect } from 'vitest';
import { scopeCss } from './scope-css';
const SCOPE = '.whp-html-1a2b3c4d';
describe('scopeCss -- basic selector scoping', () => {
test('a single simple selector gets prefixed', () => {
expect(scopeCss('h1 { color: red; }', SCOPE)).toBe(`${SCOPE} h1 { color: red; }`);
});
test('multiple rules each get prefixed independently', () => {
const input = 'h1 { color: red; } p { color: blue; }';
const out = scopeCss(input, SCOPE);
expect(out).toContain(`${SCOPE} h1 { color: red; }`);
expect(out).toContain(`${SCOPE} p { color: blue; }`);
});
test('a compound descendant selector is prefixed as a whole, not per-token', () => {
expect(scopeCss('div.card > h2 { color: red; }', SCOPE)).toBe(`${SCOPE} div.card > h2 { color: red; }`);
});
test('pseudo-classes/elements survive attached to their element', () => {
expect(scopeCss('a:hover { color: red; }', SCOPE)).toBe(`${SCOPE} a:hover { color: red; }`);
expect(scopeCss('p::before { content: "x"; }', SCOPE)).toBe(`${SCOPE} p::before { content: "x"; }`);
});
test('the universal selector is prefixed', () => {
expect(scopeCss('* { box-sizing: border-box; }', SCOPE)).toBe(`${SCOPE} * { box-sizing: border-box; }`);
});
});
describe('scopeCss -- comma-separated selector lists (every selector must be scoped)', () => {
test('h1, h2 > p scopes BOTH selectors, not just the first', () => {
const out = scopeCss('h1, h2 > p { margin: 0; }', SCOPE);
expect(out).toBe(`${SCOPE} h1, ${SCOPE} h2 > p { margin: 0; }`);
});
test('a long comma list scopes every entry', () => {
const out = scopeCss('h1, h2, h3, h4 { font-weight: bold; }', SCOPE);
expect(out).toBe(`${SCOPE} h1, ${SCOPE} h2, ${SCOPE} h3, ${SCOPE} h4 { font-weight: bold; }`);
});
test('a comma inside :not(...) is not treated as a selector-list separator', () => {
const out = scopeCss('div:not(h1, h2) { color: red; }', SCOPE);
expect(out).toBe(`${SCOPE} div:not(h1, h2) { color: red; }`);
});
});
describe('scopeCss -- @media / @supports / @container recurse into the body', () => {
test('@media keeps its condition prelude intact and scopes the selector inside', () => {
const input = '@media (min-width: 600px) { h1 { color: red; } }';
const out = scopeCss(input, SCOPE);
expect(out).toBe(`@media (min-width: 600px) { ${SCOPE} h1 { color: red; } }`);
});
test('@supports keeps its condition prelude intact and scopes the selector inside', () => {
const input = '@supports (display: grid) { .grid { display: grid; } }';
const out = scopeCss(input, SCOPE);
expect(out).toBe(`@supports (display: grid) { ${SCOPE} .grid { display: grid; } }`);
});
test('@container keeps its condition prelude intact and scopes the selector inside', () => {
const input = '@container (min-width: 400px) { .card { padding: 8px; } }';
const out = scopeCss(input, SCOPE);
expect(out).toBe(`@container (min-width: 400px) { ${SCOPE} .card { padding: 8px; } }`);
});
test('multiple rules inside one @media block are each scoped', () => {
const input = '@media (min-width: 600px) { h1 { color: red; } p { color: blue; } }';
const out = scopeCss(input, SCOPE);
expect(out).toBe(`@media (min-width: 600px) { ${SCOPE} h1 { color: red; } ${SCOPE} p { color: blue; } }`);
});
test('a comma-separated selector list inside @media is fully scoped', () => {
const input = '@media (min-width: 600px) { h1, h2 { color: red; } }';
const out = scopeCss(input, SCOPE);
expect(out).toBe(`@media (min-width: 600px) { ${SCOPE} h1, ${SCOPE} h2 { color: red; } }`);
});
});
describe('scopeCss -- @keyframes body is left untouched', () => {
test('keyframe selectors (from/to/percentages) are not scoped', () => {
const input = '@keyframes spin { from { opacity: 0; } 50% { opacity: 0.5; } to { opacity: 1; } }';
expect(scopeCss(input, SCOPE)).toBe(input);
});
test('vendor-prefixed @-webkit-keyframes body is also left untouched', () => {
const input = '@-webkit-keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }';
expect(scopeCss(input, SCOPE)).toBe(input);
});
test('a normal rule after a @keyframes block is still scoped (parser resyncs correctly)', () => {
const input = '@keyframes spin { from { opacity: 0; } to { opacity: 1; } } h1 { color: red; }';
const out = scopeCss(input, SCOPE);
expect(out).toBe(`@keyframes spin { from { opacity: 0; } to { opacity: 1; } } ${SCOPE} h1 { color: red; }`);
});
});
describe('scopeCss -- @font-face is left entirely alone (no selector to scope)', () => {
test('@font-face block passes through byte-identical', () => {
const input = "@font-face { font-family: 'Custom'; src: url(custom.woff2) format('woff2'); }";
expect(scopeCss(input, SCOPE)).toBe(input);
});
});
describe('scopeCss -- @import is stripped', () => {
test('a bare @import statement is removed', () => {
const out = scopeCss('@import url("https://evil.example/x.css");', SCOPE);
expect(out).not.toContain('@import');
expect(out).not.toContain('evil.example');
});
test('@import surrounded by real rules: only the import is removed, the rules survive scoped', () => {
const input = '@import url("x.css"); h1 { color: red; } p { color: blue; }';
const out = scopeCss(input, SCOPE);
expect(out).not.toContain('@import');
expect(out).toContain(`${SCOPE} h1 { color: red; }`);
expect(out).toContain(`${SCOPE} p { color: blue; }`);
});
test('@import with a semicolon inside its quoted url is still recognized as ONE statement', () => {
// The url itself doesn't contain a semicolon in practice, but this
// proves the statement-terminator scan is string-aware in general: a
// quoted string's contents (whatever they are) never end the statement
// early.
const input = '@import url("foo.css?x=1;y=2"); h1 { color: red; }';
const out = scopeCss(input, SCOPE);
expect(out).not.toContain('@import');
expect(out).not.toContain('foo.css');
expect(out).toContain(`${SCOPE} h1 { color: red; }`);
});
});
describe('scopeCss -- :root / html / body map to the scope root itself', () => {
test(':root custom properties target the wrapper, not a nonexistent descendant', () => {
expect(scopeCss(':root { --brand: red; }', SCOPE)).toBe(`${SCOPE} { --brand: red; }`);
});
test('html selector maps to the scope root', () => {
expect(scopeCss('html { background: #fff; }', SCOPE)).toBe(`${SCOPE} { background: #fff; }`);
});
test('body selector maps to the scope root', () => {
expect(scopeCss('body { margin: 0; }', SCOPE)).toBe(`${SCOPE} { margin: 0; }`);
});
test('case-insensitive: HTML and BODY also map to the scope root', () => {
expect(scopeCss('HTML { color: red; }', SCOPE)).toBe(`${SCOPE} { color: red; }`);
expect(scopeCss('BODY { color: red; }', SCOPE)).toBe(`${SCOPE} { color: red; }`);
});
test(':root mixed into a comma list scopes the other entries normally', () => {
const out = scopeCss(':root, h1 { color: red; }', SCOPE);
expect(out).toBe(`${SCOPE}, ${SCOPE} h1 { color: red; }`);
});
});
describe('scopeCss -- comments and strings are not treated as syntax', () => {
test('a brace inside a comment does not confuse block matching', () => {
const input = 'h1 { color: red; /* comment with a { brace */ }';
const out = scopeCss(input, SCOPE);
expect(out).toBe(`${SCOPE} ${input}`);
});
test('a comma inside a comment does not split a selector list', () => {
const input = 'h1 /* a, b */ , p { color: red; }';
const out = scopeCss(input, SCOPE);
expect(out).toBe(`${SCOPE} h1 /* a, b */, ${SCOPE} p { color: red; }`);
});
test('an @ inside a comment does not trigger at-rule handling', () => {
// The comment sits in front of the selector text, so it stays part of
// what gets prefixed (a CSS comment is insignificant whitespace to the
// parser -- `.scope /* c */ h1` is equivalent to `.scope h1`). What
// this test really guards: the leading "@import" text INSIDE the
// comment must not make the classifier treat this as an @import
// statement and strip the whole rule.
const input = '/* @import fake */ h1 { color: red; }';
const out = scopeCss(input, SCOPE);
expect(out).toBe(`${SCOPE} /* @import fake */ h1 { color: red; }`);
expect(out).toContain('color: red');
});
test('a brace inside a quoted content string does not confuse block matching', () => {
const input = 'p::before { content: "{ not a brace }"; }';
expect(scopeCss(input, SCOPE)).toBe(`${SCOPE} ${input}`);
});
test('a comma inside a quoted string does not split a selector list', () => {
const input = 'h1[data-x="a,b"], p { color: red; }';
const out = scopeCss(input, SCOPE);
expect(out).toBe(`${SCOPE} h1[data-x="a,b"], ${SCOPE} p { color: red; }`);
});
test('a semicolon inside a quoted string does not end an @import early', () => {
const input = 'h1::before { content: "a;b"; } p { color: red; }';
const out = scopeCss(input, SCOPE);
expect(out).toBe(`${SCOPE} h1::before { content: "a;b"; } ${SCOPE} p { color: red; }`);
});
test('an unterminated comment consumes to end of string without throwing', () => {
expect(() => scopeCss('h1 { color: red; } /* unterminated', SCOPE)).not.toThrow();
});
});
describe('scopeCss -- idempotency (running twice must not double-prefix)', () => {
test('a plain selector is not re-prefixed on a second pass', () => {
const once = scopeCss('h1 { color: red; }', SCOPE);
const twice = scopeCss(once, SCOPE);
expect(twice).toBe(once);
expect(twice.match(new RegExp(SCOPE.replace('.', '\\.'), 'g'))?.length).toBe(1);
});
test(':root-mapped rule is not re-prefixed on a second pass', () => {
const once = scopeCss(':root { --brand: red; }', SCOPE);
const twice = scopeCss(once, SCOPE);
expect(twice).toBe(once);
});
test('a comma list is not re-prefixed on a second pass', () => {
const once = scopeCss('h1, h2 > p { margin: 0; }', SCOPE);
const twice = scopeCss(once, SCOPE);
expect(twice).toBe(once);
});
test('a rule inside @media is not re-prefixed on a second pass', () => {
const once = scopeCss('@media (min-width: 600px) { h1 { color: red; } }', SCOPE);
const twice = scopeCss(once, SCOPE);
expect(twice).toBe(once);
});
});
describe('scopeCss -- misc/edge cases', () => {
test('empty input returns empty string', () => {
expect(scopeCss('', SCOPE)).toBe('');
});
test('whitespace-only input round-trips without throwing', () => {
expect(() => scopeCss(' \n ', SCOPE)).not.toThrow();
});
test('a relative selector starting with a combinator is scoped as a descendant of the wrapper', () => {
// ">h1" is unusual outside CSS nesting but should not crash the scanner.
const out = scopeCss('> h1 { color: red; }', SCOPE);
expect(out).toBe(`${SCOPE} > h1 { color: red; }`);
});
test('an unknown braced at-rule (e.g. @page) is left untouched', () => {
const input = '@page { margin: 1in; }';
expect(scopeCss(input, SCOPE)).toBe(input);
});
});
/** Wraps `inner` in `depth` levels of nested `@media`, each with a trivial
* always-true-shaped condition. Used to probe/prove the recursion depth cap. */
function nestMedia(inner: string, depth: number): string {
let css = inner;
for (let i = 0; i < depth; i++) css = `@media (min-width: 1px) {${css}}`;
return css;
}
describe('scopeCss -- review finding: bounded recursion depth (was: unbounded, crashed on ~7000 nested @media)', () => {
test('nesting comfortably under the cap: the innermost selector IS scoped', () => {
const input = nestMedia('h1{color:red}', 5);
const out = scopeCss(input, SCOPE);
expect(out).toContain(`${SCOPE} h1{color:red}`);
});
test('nesting far past the cap does not throw, and stops scoping beyond the cap (unscoped fallback, not a crash)', () => {
const input = nestMedia('h1{color:red}', 1000);
expect(() => scopeCss(input, SCOPE)).not.toThrow();
const out = scopeCss(input, SCOPE);
// The innermost rule sits far beyond MAX_NESTING_DEPTH -- it must come
// through UNSCOPED (the documented fallback), not silently dropped and
// not scoped from some unexpected point.
expect(out).not.toContain(SCOPE);
expect(out).toContain('h1{color:red}');
});
test('the exact review repro: ~7000 nested @media, ~190KB-shaped input, does not throw', () => {
const input = nestMedia('h1{color:red}', 7000);
expect(() => scopeCss(input, SCOPE)).not.toThrow();
// Structural integrity: every opened @media brace is still closed --
// the cap changes WHAT gets scoped, never the brace structure/count.
const out = scopeCss(input, SCOPE);
const opens = (out.match(/\{/g) || []).length;
const closes = (out.match(/\}/g) || []).length;
expect(opens).toBe(closes);
expect(opens).toBe(7001); // 7000 @media wrapper braces + the innermost rule's own brace pair
});
});
describe('scopeCss -- review finding: never throws, on any input (property test over malformed/adversarial strings)', () => {
// Deterministic pseudo-random generator (mulberry32) -- NOT Math.random.
// A property test that can flake between CI runs is worse than no
// property test: a failure must be reproducible from the fixed seed
// below, every time, so it can actually be debugged.
function mulberry32(seed: number): () => number {
let a = seed;
return () => {
a |= 0;
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
const ALPHABET = ['{', '}', '(', ')', ';', ',', '"', "'", '@', '/', '*', ':', 'a', 'h1', ' ', '\n', '\\', '<', '>'];
function randomGarbageCss(rand: () => number, length: number): string {
let out = '';
while (out.length < length) {
out += ALPHABET[Math.floor(rand() * ALPHABET.length)];
}
return out;
}
test('1000 random malformed CSS strings (unbalanced braces, dangling quotes/comments, stray @/,/:) never throw', () => {
const rand = mulberry32(42);
for (let i = 0; i < 1000; i++) {
const garbage = randomGarbageCss(rand, 1 + Math.floor(rand() * 200));
expect(() => scopeCss(garbage, SCOPE)).not.toThrow();
}
});
test('specific known-nasty malformed inputs never throw', () => {
const nasty = [
'{{{{{{{{{{',
'}}}}}}}}}}',
'{'.repeat(5000),
'/*'.repeat(2000),
'"'.repeat(2000),
'@media'.repeat(2000),
'h1'.repeat(50000), // pathologically long single token, no braces at all
'',
' ',
'',
'@media (min-width: 1px) {'.repeat(3000), // unbalanced: opens with no closes
];
for (const input of nasty) {
expect(() => scopeCss(input, SCOPE)).not.toThrow();
}
});
test('scopeCss never throws even if given a pathologically deep input AND a pathological scope selector', () => {
const input = nestMedia('h1, h2, h3 { color: red; }', 500);
const weirdScope = '.' + 'x'.repeat(10000);
expect(() => scopeCss(input, weirdScope)).not.toThrow();
});
});