waybetter-for-x/apps/desktop/tests/pluginApi.test.ts
2026-10-01 06:01:13 +07:00

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');
});