import assert from 'node:assert/strict'; import test from 'node:test'; import { createPluginApi, expandedLinkText, isTypingTarget, nodeText } from '../src/inject/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(); failWrites = false; 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) { if (this.failWrites) throw new Error('quota'); this.map.set(k, v); } removeItem(k: string) { this.map.delete(k); } } function fakeEnv() { const observers: { callback: () => void; connected: boolean }[] = []; let matches: object[] = []; const styles: { dataset: Record; textContent: string; isConnected: boolean; remove(): void }[] = []; const doc = new FakeTarget() as FakeTarget & Record; Object.assign(doc, { activeElement: null, documentElement: {}, // Page-wide observers share the DOM hub, which watches . body: {}, head: { appendChild(node: { isConnected: boolean }) { node.isConnected = true; } }, querySelectorAll: () => matches, 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://x.com/home' }, localStorage: storage, requestAnimationFrame: (fn: () => void) => { fn(); return 1; }, cancelAnimationFrame: () => {}, MutationObserver: class { entry: { callback: () => void; connected: boolean }; constructor(callback: () => void) { this.entry = { callback, connected: false }; observers.push(this.entry); } observe() { this.entry.connected = true; } disconnect() { this.entry.connected = false; } }, }); const api = createPluginApi(win as unknown as Window & typeof globalThis); return { api, win, doc, storage, observers, styles, setMatches: (list: object[]) => { matches = list; } }; } test('storage is namespaced JSON, tolerates corrupt values and failing writes', () => { const { api, storage } = fakeEnv(); const notes = api.storage('user-notes'); const other = api.storage('other'); assert.equal(notes.set('jack', { text: 'hi' }), true); assert.equal(storage.getItem('bt-plugin:user-notes:jack'), '{"text":"hi"}'); assert.deepEqual(notes.get('jack', null), { text: 'hi' }); assert.equal(other.get('jack', 'none'), 'none', 'namespaces do not share keys'); other.set('x', 1); assert.deepEqual(notes.keys(), ['jack']); storage.setItem('bt-plugin:user-notes:broken', '{not json'); assert.equal(notes.get('broken', 'fallback'), 'fallback'); notes.remove('jack'); assert.equal(notes.get('jack', null), null); storage.failWrites = true; assert.equal(notes.set('y', 1), false); for (const bad of ['', '../x', 'a:b', 'x'.repeat(65), '-lead']) assert.throws(() => api.storage(bad), /invalid namespace/); }); test('onRouteChange reports href changes and fully detaches on dispose', () => { const { api, win } = fakeEnv(); const seen: string[] = []; const stopA = api.onRouteChange((href, previous) => seen.push(`${previous} -> ${href}`)); const stopB = api.onRouteChange(() => { throw new Error('broken listener'); }); assert.equal(win.count('popstate'), 1, 'one shared watcher'); (win.location as { href: string }).href = 'https://x.com/jack'; const original = console.error; console.error = () => {}; try { win.emit('popstate'); } finally { console.error = original; } win.emit('popstate'); assert.deepEqual(seen, ['https://x.com/home -> https://x.com/jack'], 'a throwing listener does not block others; no duplicate'); stopA(); assert.equal(win.count('popstate'), 1); stopB(); assert.equal(win.count('popstate'), 0, 'last dispose removes the watcher'); }); test('observe calls back once per element and disconnects on dispose', () => { const { api, observers, setMatches } = fakeEnv(); const a = {}; const b = {}; const calls: object[] = []; setMatches([a]); const stop = api.observe('article', el => calls.push(el)); assert.deepEqual(calls, [a], 'existing elements are handled immediately'); setMatches([a, b]); observers[0]!.callback(); assert.deepEqual(calls, [a, b], 'only new elements on later mutations'); stop(); assert.equal(observers[0]!.connected, false); }); test('scope disposes everything in reverse order and keeps going after a failing disposer', () => { const { api, doc, styles, win } = fakeEnv(); const scope = api.scope(); const order: string[] = []; scope.css('demo', 'body{}'); assert.equal(styles[0]!.isConnected, true); const handler = () => {}; scope.on(doc as unknown as EventTarget, 'click', handler); scope.onRouteChange(() => {}); scope.onKey(() => {}); const node = { removed: false, remove() { this.removed = true; } }; scope.add(node as unknown as Node); scope.defer(() => order.push('first')); scope.defer(() => { throw new Error('boom'); }); scope.defer(() => order.push('last')); const original = console.error; console.error = () => {}; try { scope.dispose(); } finally { console.error = original; } assert.deepEqual(order, ['last', 'first']); assert.equal(styles[0]!.isConnected, false, 'css removed'); assert.equal(doc.count('click'), 0); assert.equal(doc.count('keydown'), 0); assert.equal(win.count('popstate'), 0); assert.equal(node.removed, true); assert.doesNotThrow(() => scope.dispose(), 'second dispose is a no-op'); }); test('onKey skips typing, modifiers and the second key of X "g" chords', () => { const { api, doc } = fakeEnv(); const keys: string[] = []; const stop = api.onKey(event => keys.push(event.key)); const press = (key: string, extra: Record = {}) => doc.emit('keydown', { key, target: null, ...extra }); press('c'); press('c', { ctrlKey: true }); press('c', { defaultPrevented: true }); press('g'); press('h'); press('v'); press('c', { target: { nodeName: 'TEXTAREA', closest: () => null } }); assert.deepEqual(keys, ['c', 'v']); stop(); press('c'); assert.deepEqual(keys, ['c', 'v']); }); test('isTypingTarget recognises inputs and contenteditable', () => { assert.equal(isTypingTarget(null), false); assert.equal(isTypingTarget({ nodeName: 'INPUT', closest: () => null } as unknown as EventTarget), true); assert.equal(isTypingTarget({ nodeName: 'DIV', isContentEditable: true, closest: () => null } as unknown as EventTarget), true); assert.equal(isTypingTarget({ nodeName: 'DIV', closest: () => ({}) } as unknown as EventTarget), true); assert.equal(isTypingTarget({ nodeName: 'ARTICLE', closest: () => null } as unknown as EventTarget), false); }); function text(value: string) { return { nodeType: 3, nodeValue: value } as unknown as Node; } function el(tag: string, attrs: Record, children: Node[] = []) { const node = { nodeType: 1, nodeName: tag, childNodes: children, getAttribute: (name: string) => attrs[name] ?? null, get textContent(): string { return children.map(child => (child as { nodeValue?: string }).nodeValue ?? (child as { textContent?: string }).textContent ?? '').join(''); }, }; return node as unknown as Element; } test('nodeText keeps emoji, line breaks and the destination of t.co links', () => { const link = el('A', { href: 'https://t.co/abc' }, [el('SPAN', {}, [text('https://')]), text('example.com/a'), el('SPAN', {}, [text('/long/path?x=1')]), el('SPAN', {}, [text('…')])]); assert.equal(expandedLinkText(link), 'https://example.com/a/long/path?x=1'); const mention = el('A', { href: '/jack' }, [text('@jack')]); const tweet = el('DIV', {}, [text('Hi '), mention, text(' '), el('IMG', { alt: '\u{1F600}' }), el('BR', {}), text('see '), link]); assert.equal(nodeText(tweet), 'Hi @jack \u{1F600}\nsee https://example.com/a/long/path?x=1'); assert.equal(expandedLinkText(el('A', { href: 'https://t.co/x' }, [text('not a url')])), 'not a url'); });