Something went wrong. Try again.
[READ-ONLY] Mirror of https://github.com/improsocial/impro
An extensible Bluesky client for web impro.social
Something went wrong. Try again.
JavaScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543import { 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); });});