WebView2 runs document-start scripts before the document has an element, so
injectCSS threw ("reading 'appendChild'") for every CSS feature that was on at
start, e.g. hiding promoted posts, until the next flags delivery restarted it.
Such styles now wait (one MutationObserver for all of them), are attached as
soon as the document has a root, and get their CSP check then; a style cleared
while waiting is never attached.
161 lines
6.4 KiB
TypeScript
161 lines
6.4 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import { createCorePluginApi, isTypingTarget, type PluginApiSite } from '../src/pluginApi.ts';
|
|
|
|
type Listener = (event: unknown) => void;
|
|
|
|
class FakeTarget {
|
|
listeners = new Map<string, Set<Listener>>();
|
|
addEventListener(type: string, fn: Listener) {
|
|
if (!this.listeners.has(type)) this.listeners.set(type, new Set());
|
|
this.listeners.get(type)!.add(fn);
|
|
}
|
|
removeEventListener(type: string, fn: Listener) { this.listeners.get(type)?.delete(fn); }
|
|
emit(type: string, event: unknown = {}) { for (const fn of [...(this.listeners.get(type) ?? [])]) fn(event); }
|
|
count(type: string) { return this.listeners.get(type)?.size ?? 0; }
|
|
}
|
|
|
|
class FakeStorage {
|
|
map = new Map<string, string>();
|
|
get length() { return this.map.size; }
|
|
key(i: number) { return [...this.map.keys()][i] ?? null; }
|
|
getItem(k: string) { return this.map.get(k) ?? null; }
|
|
setItem(k: string, v: string) { this.map.set(k, v); }
|
|
removeItem(k: string) { this.map.delete(k); }
|
|
}
|
|
|
|
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 fakeEnv() {
|
|
const styles: { dataset: Record<string, string>; textContent: string; isConnected: boolean; remove(): void }[] = [];
|
|
const doc = new FakeTarget() as FakeTarget & Record<string, unknown>;
|
|
Object.assign(doc, {
|
|
activeElement: null,
|
|
documentElement: {},
|
|
head: { appendChild(node: { isConnected: boolean }) { node.isConnected = true; } },
|
|
querySelectorAll: () => [],
|
|
createElement: () => {
|
|
const node = { dataset: {} as Record<string, string>, textContent: '', isConnected: false, remove() { node.isConnected = false; } };
|
|
styles.push(node);
|
|
return node;
|
|
},
|
|
});
|
|
const win = new FakeTarget() as FakeTarget & Record<string, unknown>;
|
|
const storage = new FakeStorage();
|
|
Object.assign(win, {
|
|
document: doc,
|
|
location: { href: 'https://site.test/home' },
|
|
localStorage: storage,
|
|
requestAnimationFrame: (fn: () => void) => { fn(); return 1; },
|
|
cancelAnimationFrame: () => {},
|
|
MutationObserver: class { observe() {} disconnect() {} },
|
|
});
|
|
const api = createCorePluginApi(win as unknown as Window & typeof globalThis, SITE);
|
|
return { api, win, doc, storage, styles };
|
|
}
|
|
|
|
test('storage is namespaced under the site prefix and never throws', () => {
|
|
const { api, storage } = fakeEnv();
|
|
const notes = api.storage('notes');
|
|
assert.equal(notes.set('a', { text: 'hi' }), true);
|
|
assert.equal(storage.getItem('demo-plugin:notes:a'), '{"text":"hi"}');
|
|
storage.setItem('demo-plugin:notes:broken', '{not json');
|
|
assert.deepEqual(notes.get('broken', 'fallback'), 'fallback');
|
|
assert.deepEqual(notes.keys().sort(), ['a', 'broken']);
|
|
assert.throws(() => api.storage('../escape'), /Demo\.storage: invalid namespace/);
|
|
});
|
|
|
|
test('injectCSS marks its style with the site data key and replaces in place', () => {
|
|
const { api, styles } = fakeEnv();
|
|
api.injectCSS('one', 'a{}');
|
|
api.injectCSS('one', 'b{}');
|
|
assert.equal(styles.length, 1);
|
|
assert.equal(styles[0]!.dataset.demoPlugin, 'one');
|
|
assert.equal(styles[0]!.textContent, 'b{}');
|
|
api.clearCSS('one');
|
|
assert.equal(styles[0]!.isConnected, false);
|
|
});
|
|
|
|
test('injectCSS at document start waits for the document root instead of throwing', () => {
|
|
const observers: { fire(): void; disconnected: boolean }[] = [];
|
|
const { api, doc, win, styles } = fakeEnv();
|
|
Object.assign(win, {
|
|
MutationObserver: class {
|
|
disconnected = false;
|
|
callback: () => void;
|
|
constructor(callback: () => void) { this.callback = callback; observers.push(this); }
|
|
observe() {}
|
|
disconnect() { this.disconnected = true; }
|
|
fire() { this.callback(); }
|
|
},
|
|
});
|
|
const head = doc.head;
|
|
Object.assign(doc, { head: null, documentElement: null });
|
|
assert.doesNotThrow(() => api.injectCSS('early', 'a{}'));
|
|
api.injectCSS('early', 'b{}');
|
|
api.injectCSS('gone', 'c{}');
|
|
api.clearCSS('gone');
|
|
assert.equal(styles.filter(node => node.isConnected).length, 0);
|
|
assert.equal(observers.length, 1, 'one observer for every waiting style');
|
|
Object.assign(doc, { head, documentElement: {} });
|
|
observers[0]!.fire();
|
|
const connected = styles.filter(node => node.isConnected);
|
|
assert.equal(connected.length, 1, 'a cleared style is never attached');
|
|
assert.equal(connected[0]!.dataset.demoPlugin, 'early');
|
|
assert.equal(connected[0]!.textContent, 'b{}', 'the latest text wins');
|
|
assert.equal(observers[0]!.disconnected, true);
|
|
});
|
|
|
|
test('onKey skips typing, modifiers and the second key of site chords', () => {
|
|
const { api, doc } = fakeEnv();
|
|
const keys: string[] = [];
|
|
const off = api.onKey(event => keys.push(event.key));
|
|
const press = (key: string, extra: Record<string, unknown> = {}) => doc.emit('keydown', { key, target: null, ...extra });
|
|
press('j');
|
|
press('g'); press('h');
|
|
press('k', { ctrlKey: true });
|
|
press('l', { target: { nodeName: 'INPUT', closest: () => null } });
|
|
press('m');
|
|
assert.deepEqual(keys, ['j', 'm']);
|
|
off();
|
|
assert.equal(doc.count('keydown'), 0);
|
|
});
|
|
|
|
test('route listeners share one poll and stop with the last listener', () => {
|
|
const { api, win } = fakeEnv();
|
|
const offA = api.onRouteChange(() => {});
|
|
const offB = api.onRouteChange(() => {});
|
|
assert.equal(win.count('popstate'), 1);
|
|
offA();
|
|
assert.equal(win.count('popstate'), 1);
|
|
offB();
|
|
assert.equal(win.count('popstate'), 0);
|
|
});
|
|
|
|
test('scope disposes in reverse order and survives failing disposers', () => {
|
|
const { api } = fakeEnv();
|
|
const order: string[] = [];
|
|
const scope = api.scope();
|
|
const original = console.error;
|
|
console.error = () => {};
|
|
try {
|
|
scope.defer(() => order.push('first'));
|
|
scope.defer(() => { throw new Error('boom'); });
|
|
scope.defer(() => order.push('last'));
|
|
scope.dispose();
|
|
scope.dispose();
|
|
} finally {
|
|
console.error = original;
|
|
}
|
|
assert.deepEqual(order, ['last', 'first']);
|
|
});
|
|
|
|
test('isTypingTarget recognizes inputs and editable content', () => {
|
|
assert.equal(isTypingTarget(null), false);
|
|
assert.ok(isTypingTarget({ nodeName: 'TEXTAREA', closest: () => null } as unknown as EventTarget));
|
|
assert.ok(isTypingTarget({ nodeName: 'DIV', isContentEditable: true, closest: () => null } as unknown as EventTarget));
|
|
assert.equal(isTypingTarget({ nodeName: 'DIV', closest: () => null } as unknown as EventTarget), false);
|
|
});
|