- styles.ts: adoptStyles(root, css) adopts a constructed stylesheet into a shadow root or document (not governed by a page's CSP, parsed once, stays with the root when its host is moved); <style> fallback where constructable sheets are missing or refused. - UI kit: the kit's shadow root and each dialog's `css` use it (dialog CSS goes with the dialog). - Plugin API: api.adoptCSS(root, css) and scope.adoptCSS (removed on dispose); injectCSS keeps its <style> and, when a CSP leaves it without a sheet, applies the same rules as an adopted sheet. - tests/styles.test.ts.
175 lines
7.2 KiB
TypeScript
175 lines
7.2 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import { adoptStyles } from '../src/styles.ts';
|
|
import { createCorePluginApi, type PluginApiSite } from '../src/pluginApi.ts';
|
|
|
|
/** A constructable sheet as far as the helper uses one. */
|
|
class FakeSheet {
|
|
static refuse = false;
|
|
css = '';
|
|
replaceSync(css: string) {
|
|
if (FakeSheet.refuse) throw new Error('refused');
|
|
this.css = css;
|
|
}
|
|
}
|
|
|
|
type FakeStyle = { tag: string; textContent: string; parent: FakeParent | null; sheet?: unknown; remove(): void };
|
|
type FakeParent = { children: FakeStyle[]; appendChild(node: FakeStyle): FakeStyle };
|
|
|
|
function parent(): FakeParent {
|
|
const node: FakeParent = {
|
|
children: [],
|
|
appendChild(child) { child.parent = node; node.children.push(child); return child; },
|
|
};
|
|
return node;
|
|
}
|
|
|
|
function fakeDocument(options: { constructable?: boolean; adopted?: boolean } = {}) {
|
|
const win: Record<string, unknown> = {};
|
|
if (options.constructable !== false) win.CSSStyleSheet = FakeSheet;
|
|
const head = parent();
|
|
const doc: Record<string, unknown> = {
|
|
nodeType: 9,
|
|
defaultView: win,
|
|
head,
|
|
documentElement: parent(),
|
|
createElement(tag: string): FakeStyle {
|
|
const node: FakeStyle = { tag, textContent: '', parent: null, remove() { if (node.parent) node.parent.children = node.parent.children.filter(c => c !== node); node.parent = null; } };
|
|
return node;
|
|
},
|
|
};
|
|
if (options.adopted !== false) doc.adoptedStyleSheets = [];
|
|
win.document = doc;
|
|
return { win, doc, head };
|
|
}
|
|
|
|
function fakeShadowRoot(doc: Record<string, unknown>, adopted = true) {
|
|
const root = parent() as FakeParent & Record<string, unknown>;
|
|
root.nodeType = 11;
|
|
root.ownerDocument = doc;
|
|
if (adopted) root.adoptedStyleSheets = [];
|
|
return root;
|
|
}
|
|
|
|
test('a shadow root adopts a constructed sheet: no <style> element, update and remove work', () => {
|
|
FakeSheet.refuse = false;
|
|
const { doc } = fakeDocument();
|
|
const root = fakeShadowRoot(doc);
|
|
const earlier = new FakeSheet();
|
|
root.adoptedStyleSheets = [earlier];
|
|
const styles = adoptStyles(root as unknown as ShadowRoot, '.a { color: red; }');
|
|
assert.equal(styles.adopted, true);
|
|
assert.equal(root.children.length, 0, 'no <style> element the page CSP could refuse');
|
|
const adopted = root.adoptedStyleSheets as FakeSheet[];
|
|
assert.equal(adopted.length, 2);
|
|
assert.equal(adopted[0], earlier, 'sheets already adopted stay first');
|
|
assert.equal(adopted[1].css, '.a { color: red; }');
|
|
assert.equal(styles.sheet, adopted[1] as unknown as CSSStyleSheet);
|
|
styles.update('.b {}');
|
|
assert.equal(adopted[1].css, '.b {}');
|
|
styles.remove();
|
|
assert.deepEqual(root.adoptedStyleSheets, [earlier]);
|
|
styles.remove();
|
|
assert.deepEqual(root.adoptedStyleSheets, [earlier], 'remove is idempotent');
|
|
});
|
|
|
|
test('without constructable sheets, or when the engine refuses one, a <style> element is the fallback', () => {
|
|
FakeSheet.refuse = false;
|
|
const noCtor = fakeDocument({ constructable: false });
|
|
const root = fakeShadowRoot(noCtor.doc);
|
|
const styles = adoptStyles(root as unknown as ShadowRoot, '.x {}');
|
|
assert.equal(styles.adopted, false);
|
|
assert.equal(root.children.length, 1);
|
|
assert.equal(root.children[0].textContent, '.x {}');
|
|
styles.update('.y {}');
|
|
assert.equal(root.children[0].textContent, '.y {}');
|
|
styles.remove();
|
|
assert.equal(root.children.length, 0);
|
|
|
|
const noAdopt = fakeDocument();
|
|
const legacy = fakeShadowRoot(noAdopt.doc, false);
|
|
assert.equal(adoptStyles(legacy as unknown as ShadowRoot, '.z {}').adopted, false);
|
|
assert.equal(legacy.children.length, 1);
|
|
|
|
FakeSheet.refuse = true;
|
|
const refused = fakeDocument();
|
|
const root2 = fakeShadowRoot(refused.doc);
|
|
assert.equal(adoptStyles(root2 as unknown as ShadowRoot, '.w {}').adopted, false);
|
|
assert.equal(root2.children.length, 1);
|
|
FakeSheet.refuse = false;
|
|
});
|
|
|
|
test('a document root: adopted sheet, or a <style> in <head> as the fallback', () => {
|
|
FakeSheet.refuse = false;
|
|
const { doc, head } = fakeDocument();
|
|
const styles = adoptStyles(doc as unknown as Document, '.d {}');
|
|
assert.equal(styles.adopted, true);
|
|
assert.equal((doc.adoptedStyleSheets as FakeSheet[]).length, 1);
|
|
assert.equal(head.children.length, 0);
|
|
const plain = fakeDocument({ adopted: false });
|
|
adoptStyles(plain.doc as unknown as Document, '.e {}');
|
|
assert.equal(plain.head.children.length, 1);
|
|
});
|
|
|
|
const SITE: PluginApiSite = {
|
|
name: 'Demo', product: 'wayBetter for Test', storagePrefix: 'demo-plugin:', styleDataKey: 'demoPlugin',
|
|
toastId: 'demo-toast', toastBackground: '#111', itemSelector: 'article.message', chordKeys: ['g'],
|
|
};
|
|
|
|
function pluginEnv(cspBlocksInline: boolean) {
|
|
FakeSheet.refuse = false;
|
|
const { win, doc, head } = fakeDocument();
|
|
const createElement = doc.createElement as (tag: string) => FakeStyle;
|
|
Object.assign(doc, {
|
|
addEventListener() {}, removeEventListener() {}, activeElement: null, querySelectorAll: () => [],
|
|
createElement(tag: string) {
|
|
const node = createElement(tag) as FakeStyle & { dataset: Record<string, string>; isConnected: boolean };
|
|
node.dataset = {};
|
|
// A real <style>: a sheet once connected, none when the CSP refuses inline styles.
|
|
Object.defineProperty(node, 'sheet', { get: () => (node.parent && !cspBlocksInline ? {} : null) });
|
|
Object.defineProperty(node, 'isConnected', { get: () => !!node.parent });
|
|
return node;
|
|
},
|
|
});
|
|
Object.assign(win, {
|
|
location: { href: 'https://site.test/home' }, localStorage: null,
|
|
addEventListener() {}, removeEventListener() {},
|
|
requestAnimationFrame: (fn: () => void) => { fn(); return 1; }, cancelAnimationFrame: () => {},
|
|
MutationObserver: class { observe() {} disconnect() {} },
|
|
});
|
|
const api = createCorePluginApi(win as unknown as Window & typeof globalThis, SITE);
|
|
return { api, doc, head };
|
|
}
|
|
|
|
test('injectCSS: an inline <style> the page CSP refuses gets its rules as an adopted sheet instead', () => {
|
|
const { api, doc, head } = pluginEnv(true);
|
|
api.injectCSS('demo', '.a {}');
|
|
assert.equal(head.children.length, 1, 'the <style> element is still there (the CSP may change)');
|
|
const adopted = doc.adoptedStyleSheets as FakeSheet[];
|
|
assert.equal(adopted.length, 1);
|
|
assert.equal(adopted[0].css, '.a {}');
|
|
api.injectCSS('demo', '.b {}');
|
|
assert.equal(adopted.length, 1, 'updated in place, not stacked');
|
|
assert.equal((doc.adoptedStyleSheets as FakeSheet[])[0].css, '.b {}');
|
|
api.clearCSS('demo');
|
|
assert.equal((doc.adoptedStyleSheets as FakeSheet[]).length, 0);
|
|
assert.equal(head.children.length, 0);
|
|
});
|
|
|
|
test('injectCSS: an allowed <style> needs no adopted copy', () => {
|
|
const { api, doc, head } = pluginEnv(false);
|
|
api.injectCSS('demo', '.a {}');
|
|
assert.equal(head.children.length, 1);
|
|
assert.equal((doc.adoptedStyleSheets as FakeSheet[]).length, 0);
|
|
});
|
|
|
|
test('scope.adoptCSS takes the sheet off the root on dispose', () => {
|
|
const { api, doc } = pluginEnv(false);
|
|
const root = fakeShadowRoot(doc);
|
|
const scope = api.scope();
|
|
const styles = scope.adoptCSS(root as unknown as ShadowRoot, '.card {}');
|
|
assert.equal(styles.adopted, true);
|
|
assert.equal((root.adoptedStyleSheets as FakeSheet[]).length, 1);
|
|
scope.dispose();
|
|
assert.equal((root.adoptedStyleSheets as FakeSheet[]).length, 0);
|
|
});
|