Something went wrong. Try again.
Mirror of https://github.com/improsocial/improAn extensible Bluesky client for web
Something went wrong. Try again.
JavaScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377import { 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); });});