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>(); 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(); 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; textContent: string; isConnected: boolean; remove(): void }[] = []; const doc = new FakeTarget() as FakeTarget & Record; Object.assign(doc, { activeElement: null, documentElement: {}, head: { appendChild(node: { isConnected: boolean }) { node.isConnected = true; } }, querySelectorAll: () => [], createElement: () => { const node = { dataset: {} as Record, textContent: '', isConnected: false, remove() { node.isConnected = false; } }; styles.push(node); return node; }, }); const win = new FakeTarget() as FakeTarget & Record; 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 = {}) => 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); });