187 lines
8.6 KiB
TypeScript
187 lines
8.6 KiB
TypeScript
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<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>();
|
|
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<string, string>; textContent: string; isConnected: boolean; remove(): void }[] = [];
|
|
const doc = new FakeTarget() as FakeTarget & Record<string, unknown>;
|
|
Object.assign(doc, {
|
|
activeElement: null,
|
|
documentElement: {},
|
|
// Page-wide observers share the DOM hub, which watches <body>.
|
|
body: {},
|
|
head: { appendChild(node: { isConnected: boolean }) { node.isConnected = true; } },
|
|
querySelectorAll: () => matches,
|
|
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://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<string, unknown> = {}) => 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<string, string>, 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');
|
|
});
|