import { describe, it, beforeEach, afterEach } from "node:test"; import assert from "node:assert/strict"; import { PluginStylesLoader, assertSafeCssValue, assertSafeInlineStyleValue, validatePluginCss, } from "/js/plugins/pluginStylesLoader.js"; // JSDOM doesn't support constructible stylesheets or adoptedStyleSheets, so we // install minimal fakes that mirror just the surface the loader touches: // `new CSSStyleSheet()`, `replaceSync`, iterating `cssRules`, the at-rule // classes used in `instanceof` checks, and `document.adoptedStyleSheets`. class FakeStyle { constructor(props) { this._props = props; } *[Symbol.iterator]() { for (const prop of Object.keys(this._props)) yield prop; } getPropertyValue(prop) { return this._props[prop] ?? ""; } } class FakeRule { constructor({ style = null, cssRules = [] } = {}) { this.style = style; this.cssRules = cssRules; } } class FakeCSSImportRule extends FakeRule {} class FakeCSSFontFaceRule extends FakeRule {} class FakeCSSNamespaceRule extends FakeRule {} function parseFakeCss(text) { const rules = []; let index = 0; while (index < text.length) { while (index < text.length && /\s/.test(text[index])) index++; if (index >= text.length) break; if (text.startsWith("@import", index)) { const end = text.indexOf(";", index); rules.push(new FakeCSSImportRule()); index = end + 1; } else if (text.startsWith("@font-face", index)) { const end = text.indexOf("}", index); rules.push(new FakeCSSFontFaceRule()); index = end + 1; } else if (text.startsWith("@namespace", index)) { const end = text.indexOf(";", index); rules.push(new FakeCSSNamespaceRule()); index = end + 1; } else { const open = text.indexOf("{", index); const close = text.indexOf("}", open); const body = text.slice(open + 1, close); const props = {}; for (const declaration of body.split(";")) { const colon = declaration.indexOf(":"); if (colon < 0) continue; const prop = declaration.slice(0, colon).trim(); const value = declaration.slice(colon + 1).trim(); if (prop) props[prop] = value; } rules.push(new FakeRule({ style: new FakeStyle(props) })); index = close + 1; } } return rules; } class FakeCSSStyleSheet { constructor() { this.cssRules = []; } replaceSync(text) { this.cssRules = parseFakeCss(text); } } function stubCssEnv() { const originals = { CSSStyleSheet: globalThis.CSSStyleSheet, CSSImportRule: globalThis.CSSImportRule, CSSFontFaceRule: globalThis.CSSFontFaceRule, CSSNamespaceRule: globalThis.CSSNamespaceRule, }; globalThis.CSSStyleSheet = FakeCSSStyleSheet; globalThis.CSSImportRule = FakeCSSImportRule; globalThis.CSSFontFaceRule = FakeCSSFontFaceRule; globalThis.CSSNamespaceRule = FakeCSSNamespaceRule; const originalAdopted = Object.getOwnPropertyDescriptor( globalThis.document, "adoptedStyleSheets", ); let adopted = []; Object.defineProperty(globalThis.document, "adoptedStyleSheets", { configurable: true, get() { return adopted; }, set(value) { adopted = value; }, }); return { get adoptedStyleSheets() { return adopted; }, restore() { globalThis.CSSStyleSheet = originals.CSSStyleSheet; globalThis.CSSImportRule = originals.CSSImportRule; globalThis.CSSFontFaceRule = originals.CSSFontFaceRule; globalThis.CSSNamespaceRule = originals.CSSNamespaceRule; if (originalAdopted) { Object.defineProperty( globalThis.document, "adoptedStyleSheets", originalAdopted, ); } else { delete globalThis.document.adoptedStyleSheets; } }, }; } function expectThrow(fn, messageFragment) { let caught = null; try { fn(); } catch (error) { caught = error; } assert(caught, `expected ${fn} to throw`); assert( caught.message.includes(messageFragment), `expected error "${caught.message}" to include "${messageFragment}"`, ); } describe("assertSafeCssValue", () => { it("accepts values without resource functions", () => { assertSafeCssValue("color", "red"); assertSafeCssValue("height", "0.68333em"); assertSafeCssValue("transform", "translate(1px, 2px)"); }); for (const fn of [ "url", "image-set", "-webkit-image-set", "image", "cross-fade", "element", ]) { it(`rejects ${fn}() in a value`, () => { expectThrow( () => assertSafeCssValue("background", `${fn}("https://evil.test/x")`), "disallowed url() in background", ); }); } it("rejects a resource function inside a custom property value", () => { expectThrow( () => assertSafeCssValue("--bg", "url(https://evil.test/x)"), "disallowed url() in --bg", ); }); it("accepts backslashes (author CSS is post-parse serialized)", () => { assertSafeCssValue("content", '"\\A"'); }); }); describe("assertSafeInlineStyleValue", () => { it("accepts plain values", () => { assertSafeInlineStyleValue("color", "red"); assertSafeInlineStyleValue("height", "0.68333em"); }); it("rejects any backslash to block CSS-escape identifier smuggling", () => { expectThrow( () => assertSafeInlineStyleValue("background", "\\75rl(https://evil.test/x)"), "disallowed escape in background", ); expectThrow( () => assertSafeInlineStyleValue("content", '"\\A"'), "disallowed escape in content", ); }); it("still rejects raw resource functions", () => { expectThrow( () => assertSafeInlineStyleValue("background", "url(https://evil.test/x)"), "disallowed url() in background", ); }); }); describe("validatePluginCss", () => { let env; beforeEach(() => { env = stubCssEnv(); }); afterEach(() => env.restore()); it("returns a stylesheet for valid CSS", () => { const sheet = validatePluginCss(".plugin { color: red; }"); assert(sheet instanceof FakeCSSStyleSheet); assert.deepEqual(sheet.cssRules.length, 1); }); it("rejects @import rules", () => { expectThrow( () => validatePluginCss('@import url("evil.css");'), "@import not allowed", ); }); it("rejects @font-face rules", () => { expectThrow( () => validatePluginCss("@font-face { font-family: x; src: local('x'); }"), "@font-face not allowed", ); }); it("rejects @namespace rules", () => { expectThrow( () => validatePluginCss('@namespace svg url("http://example.test");'), "@namespace not allowed", ); }); it("rejects url() in declarations", () => { expectThrow( () => validatePluginCss( '.plugin { background: url("https://evil.test/x.png"); }', ), "disallowed url() in background", ); }); it("rejects image-set() in declarations", () => { expectThrow( () => validatePluginCss( '.plugin { background: image-set("a.png" 1x, "b.png" 2x); }', ), "disallowed url() in background", ); }); }); describe("PluginStylesLoader.mount", () => { let env; beforeEach(() => { env = stubCssEnv(); }); afterEach(() => env.restore()); it("adopts a sheet for the plugin", () => { const loader = new PluginStylesLoader(); loader.mount("plugin-a", ".a { color: red; }"); assert.deepEqual(env.adoptedStyleSheets.length, 1); }); it("appends sheets for multiple plugins without dropping prior ones", () => { const loader = new PluginStylesLoader(); loader.mount("plugin-a", ".a { color: red; }"); loader.mount("plugin-b", ".b { color: blue; }"); assert.deepEqual(env.adoptedStyleSheets.length, 2); }); it("replaces a prior sheet when the same plugin mounts twice", () => { const loader = new PluginStylesLoader(); loader.mount("plugin-a", ".a { color: red; }"); const firstSheet = env.adoptedStyleSheets[0]; loader.mount("plugin-a", ".a { color: green; }"); assert.deepEqual(env.adoptedStyleSheets.length, 1); assert(env.adoptedStyleSheets[0] !== firstSheet); }); it("throws and does not adopt when CSS is invalid", () => { const loader = new PluginStylesLoader(); expectThrow( () => loader.mount("plugin-a", '@import url("x.css");'), "@import not allowed", ); assert.deepEqual(env.adoptedStyleSheets.length, 0); }); }); describe("PluginStylesLoader.unmount", () => { let env; beforeEach(() => { env = stubCssEnv(); }); afterEach(() => env.restore()); it("removes only the named plugin's sheet", () => { const loader = new PluginStylesLoader(); loader.mount("plugin-a", ".a { color: red; }"); loader.mount("plugin-b", ".b { color: blue; }"); const sheetB = env.adoptedStyleSheets[1]; loader.unmount("plugin-a"); assert.deepEqual(env.adoptedStyleSheets.length, 1); assert(env.adoptedStyleSheets[0] === sheetB); }); it("is a no-op for unknown plugin ids", () => { const loader = new PluginStylesLoader(); loader.mount("plugin-a", ".a { color: red; }"); loader.unmount("plugin-missing"); assert.deepEqual(env.adoptedStyleSheets.length, 1); }); it("allows remounting a plugin after unmount", () => { const loader = new PluginStylesLoader(); loader.mount("plugin-a", ".a { color: red; }"); loader.unmount("plugin-a"); assert.deepEqual(env.adoptedStyleSheets.length, 0); loader.mount("plugin-a", ".a { color: green; }"); assert.deepEqual(env.adoptedStyleSheets.length, 1); }); }); describe("PluginStylesLoader.mountSnippet", () => { let env; beforeEach(() => { env = stubCssEnv(); }); afterEach(() => env.restore()); it("adopts a sheet alongside the manifest sheet", () => { const loader = new PluginStylesLoader(); loader.mount("plugin-a", ".a { color: red; }"); loader.mountSnippet("plugin-a", 1, ".snip { color: blue; }"); assert.deepEqual(env.adoptedStyleSheets.length, 2); }); it("adopts independent sheets for multiple snippet ids", () => { const loader = new PluginStylesLoader(); loader.mountSnippet("plugin-a", 1, ".one { color: red; }"); loader.mountSnippet("plugin-a", 2, ".two { color: blue; }"); assert.deepEqual(env.adoptedStyleSheets.length, 2); assert(env.adoptedStyleSheets[0] !== env.adoptedStyleSheets[1]); }); it("replaces the prior sheet when the same snippet id mounts twice", () => { const loader = new PluginStylesLoader(); loader.mountSnippet("plugin-a", 1, ".a { color: red; }"); const firstSheet = env.adoptedStyleSheets[0]; loader.mountSnippet("plugin-a", 1, ".a { color: green; }"); assert.deepEqual(env.adoptedStyleSheets.length, 1); assert(env.adoptedStyleSheets[0] !== firstSheet); }); it("throws and does not adopt when CSS is invalid", () => { const loader = new PluginStylesLoader(); expectThrow( () => loader.mountSnippet("plugin-a", 1, '@import url("x.css");'), "@import not allowed", ); assert.deepEqual(env.adoptedStyleSheets.length, 0); }); }); describe("PluginStylesLoader.unmountSnippet", () => { let env; beforeEach(() => { env = stubCssEnv(); }); afterEach(() => env.restore()); it("removes only the named snippet", () => { const loader = new PluginStylesLoader(); loader.mountSnippet("plugin-a", 1, ".one { color: red; }"); loader.mountSnippet("plugin-a", 2, ".two { color: blue; }"); const sheetTwo = env.adoptedStyleSheets[1]; loader.unmountSnippet("plugin-a", 1); assert.deepEqual(env.adoptedStyleSheets.length, 1); assert(env.adoptedStyleSheets[0] === sheetTwo); }); it("leaves the manifest sheet untouched", () => { const loader = new PluginStylesLoader(); loader.mount("plugin-a", ".a { color: red; }"); const manifestSheet = env.adoptedStyleSheets[0]; loader.mountSnippet("plugin-a", 1, ".snip { color: blue; }"); loader.unmountSnippet("plugin-a", 1); assert.deepEqual(env.adoptedStyleSheets.length, 1); assert(env.adoptedStyleSheets[0] === manifestSheet); }); it("is a no-op for unknown plugin or snippet ids", () => { const loader = new PluginStylesLoader(); loader.mountSnippet("plugin-a", 1, ".a { color: red; }"); loader.unmountSnippet("plugin-missing", 1); loader.unmountSnippet("plugin-a", 999); assert.deepEqual(env.adoptedStyleSheets.length, 1); }); }); describe("PluginStylesLoader.mountFonts", () => { let env; let createdUrls; let revokedUrls; let originalCreate; let originalRevoke; beforeEach(() => { env = stubCssEnv(); createdUrls = []; revokedUrls = []; originalCreate = URL.createObjectURL; originalRevoke = URL.revokeObjectURL; let counter = 0; URL.createObjectURL = () => { const url = `blob:mock/${++counter}`; createdUrls.push(url); return url; }; URL.revokeObjectURL = (url) => { revokedUrls.push(url); }; }); afterEach(() => { URL.createObjectURL = originalCreate; URL.revokeObjectURL = originalRevoke; env.restore(); }); const desc = (overrides = {}) => ({ family: "MyFont", weight: "400", style: "normal", file: "fonts/myfont.woff2", blob: { size: 0, type: "font/woff2" }, ...overrides, }); it("adopts a font sheet and creates one object URL per descriptor", () => { const loader = new PluginStylesLoader(); loader.mountFonts("plugin-a", [ desc(), desc({ weight: "700", file: "fonts/myfont-bold.woff2" }), ]); assert.deepEqual(env.adoptedStyleSheets.length, 1); assert.deepEqual(createdUrls.length, 2); }); it("is a no-op for an empty or missing descriptor list", () => { const loader = new PluginStylesLoader(); loader.mountFonts("plugin-a", []); loader.mountFonts("plugin-a", null); assert.deepEqual(env.adoptedStyleSheets.length, 0); assert.deepEqual(createdUrls.length, 0); }); it("unmount removes the font sheet and revokes every object URL", () => { const loader = new PluginStylesLoader(); loader.mount("plugin-a", ".a { color: red; }"); loader.mountFonts("plugin-a", [desc(), desc({ file: "fonts/b.woff2" })]); assert.deepEqual(env.adoptedStyleSheets.length, 2); loader.unmount("plugin-a"); assert.deepEqual(env.adoptedStyleSheets.length, 0); assert.deepEqual(revokedUrls.sort(), createdUrls.sort()); }); it("defaults weight to 400 and style to normal when omitted", () => { const loader = new PluginStylesLoader(); loader.mountFonts("plugin-a", [ { family: "MyFont", file: "fonts/f.woff2", blob: { size: 0 } }, ]); assert.deepEqual(env.adoptedStyleSheets.length, 1); }); it("throws (without leaking object URLs) on invalid weight/style", () => { const loader = new PluginStylesLoader(); for (const bad of [ { weight: "1200" }, { weight: "500 200" }, { style: "backwards" }, ]) { let caught = null; try { loader.mountFonts("plugin-a", [desc(bad)]); } catch (error) { caught = error; } assert(caught, `expected mountFonts(${JSON.stringify(bad)}) to throw`); } assert.deepEqual(createdUrls.length, 0); assert.deepEqual(env.adoptedStyleSheets.length, 0); }); it("remounting fonts revokes the prior URLs", () => { const loader = new PluginStylesLoader(); loader.mountFonts("plugin-a", [desc()]); const first = [...createdUrls]; loader.mountFonts("plugin-a", [desc({ file: "fonts/b.woff2" })]); assert.deepEqual(revokedUrls, first); assert.deepEqual(env.adoptedStyleSheets.length, 1); }); }); describe("PluginStylesLoader.unmount with snippets", () => { let env; beforeEach(() => { env = stubCssEnv(); }); afterEach(() => env.restore()); it("removes the manifest sheet and all snippets for that plugin", () => { const loader = new PluginStylesLoader(); loader.mount("plugin-a", ".a { color: red; }"); loader.mountSnippet("plugin-a", 1, ".one { color: blue; }"); loader.mountSnippet("plugin-a", 2, ".two { color: green; }"); loader.mount("plugin-b", ".b { color: yellow; }"); loader.mountSnippet("plugin-b", 1, ".b-snip { color: pink; }"); loader.unmount("plugin-a"); assert.deepEqual(env.adoptedStyleSheets.length, 2); }); it("allows remounting snippets for a plugin after unmount", () => { const loader = new PluginStylesLoader(); loader.mountSnippet("plugin-a", 1, ".a { color: red; }"); loader.unmount("plugin-a"); assert.deepEqual(env.adoptedStyleSheets.length, 0); loader.mountSnippet("plugin-a", 1, ".a { color: green; }"); assert.deepEqual(env.adoptedStyleSheets.length, 1); }); });