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(); }); });