import { describe, it, beforeEach, afterEach } from "node:test"; import assert from "node:assert/strict"; import { PluginStylesLoader, 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("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.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); }); });