import assert from 'node:assert/strict'; import test from 'node:test'; import { COMMAND_LIMITS, NO_OWNER, createCommandRegistry, matchesShortcut, parseShortcut } from '../src/commands.ts'; import { createCorePluginApi, type PluginApiSite } from '../src/pluginApi.ts'; const sync = { schedule: (fn: () => void) => fn() }; test('commands belong to the plugin being started and are listed as strings only', () => { const registry = createCommandRegistry(sync); let ran = 0; registry.withOwner('bt-focus-mode.plugin.js', () => { registry.register({ id: 'toggle', title: ' Focus Mode:\ntoggle ', keywords: ['distraction', 'hide', 42 as unknown as string], run: () => { ran += 1; } }); }); registry.register({ id: 'later', title: 'Registered later', run: () => {} }); assert.deepEqual(registry.list(), [ { id: 'bt-focus-mode.plugin.js:toggle', title: 'Focus Mode: toggle', keywords: 'distraction hide' }, { id: `${NO_OWNER}:later`, title: 'Registered later', keywords: '' }, ]); for (const info of registry.list()) assert.deepEqual(Object.keys(info).sort(), ['id', 'keywords', 'title']); assert.equal(registry.run('bt-focus-mode.plugin.js:toggle'), true); assert.equal(ran, 1); assert.equal(registry.run('bt-focus-mode.plugin.js:missing'), false); assert.equal(registry.run('toggle'), false, 'the owner is part of the id'); }); test('stopping a plugin removes its commands; others stay', () => { const registry = createCommandRegistry(sync); const seen: number[] = []; registry.onChange(list => seen.push(list.length)); registry.withOwner('a.plugin.js', () => { registry.register({ id: 'one', title: 'One', run() {} }); registry.register({ id: 'two', title: 'Two', run() {} }); }); registry.withOwner('b.plugin.js', () => registry.register({ id: 'one', title: 'B one', run() {} })); registry.removeOwner('a.plugin.js'); assert.deepEqual(registry.list().map(item => item.id), ['b.plugin.js:one']); assert.deepEqual(seen, [1, 2, 3, 1]); registry.removeOwner('nobody.plugin.js'); assert.equal(seen.length, 4, 'no change, no notification'); }); test('disposers, re-registration and caps', () => { const registry = createCommandRegistry(sync); const dispose = registry.withOwner('a.plugin.js', () => registry.register({ id: 'x', title: 'X', run() {} })); registry.withOwner('a.plugin.js', () => registry.register({ id: 'x', title: 'X again', run() {} })); assert.deepEqual(registry.list().map(item => item.title), ['X again'], 'same id replaces in place'); dispose(); assert.equal(registry.list().length, 1, 'a stale disposer does not remove the replacement'); registry.removeOwner('a.plugin.js'); registry.withOwner('a.plugin.js', () => { for (let i = 0; i < COMMAND_LIMITS.perOwner; i += 1) registry.register({ id: `c${i}`, title: `C${i}`, run() {} }); assert.throws(() => registry.register({ id: 'overflow', title: 'Overflow', run() {} }), /per plugin/); }); for (let owner = 0; registry.list().length < COMMAND_LIMITS.total; owner += 1) { registry.withOwner(`p${owner}.plugin.js`, () => { for (let i = 0; i < COMMAND_LIMITS.perOwner && registry.list().length < COMMAND_LIMITS.total; i += 1) registry.register({ id: `c${i}`, title: 'T', run() {} }); }); } assert.throws(() => registry.withOwner('z.plugin.js', () => registry.register({ id: 'one', title: 'One', run() {} })), /per page/); const fresh = createCommandRegistry(sync); for (const bad of [ { id: '', title: 'T', run() {} }, { id: 'has space', title: 'T', run() {} }, { id: 'x'.repeat(65), title: 'T', run() {} }, { id: 'ok', title: ' ', run() {} }, { id: 'ok', title: 'T', run: 'nope' }, { id: 'ok', title: 'T', shortcut: 'Hyper+Q', run() {} }, ]) assert.throws(() => fresh.register(bad as never)); fresh.register({ id: 'long', title: 't'.repeat(500), keywords: 'k'.repeat(500), run() {} }); const [long] = fresh.list(); assert.equal(long!.title.length, COMMAND_LIMITS.title); assert.equal(long!.keywords.length, COMMAND_LIMITS.keywords); assert.throws(() => fresh.withOwner('bad/owner', () => fresh.registerFor('bad/owner', { id: 'a', title: 'A', run() {} })), /owner/); }); test('a failing command reports and never throws into the caller', async () => { const errors: unknown[] = []; const registry = createCommandRegistry({ ...sync, report: error => errors.push(error) }); registry.register({ id: 'boom', title: 'Boom', run() { throw new Error('boom'); } }); registry.register({ id: 'async', title: 'Async', run: async () => { throw new Error('later'); } }); assert.equal(registry.run('_:boom'), true); assert.equal(registry.run('_:async'), true); await new Promise(resolve => setTimeout(resolve, 0)); assert.equal(errors.length, 2); }); test('shortcuts parse strictly and match physical keys; typing and repeats never run them', () => { assert.deepEqual(parseShortcut('Alt+Shift+F'), { key: 'f', ctrl: false, alt: true, shift: true, meta: false }); assert.equal(parseShortcut('Ctrl+'), null); assert.equal(parseShortcut('Ctrl+Shift'), null); assert.equal(parseShortcut('Ctrl+Enter')?.key, 'enter'); const ev = (over: Partial) => ({ key: '', code: '', ctrlKey: false, altKey: false, shiftKey: false, metaKey: false, ...over }); const altShiftF = parseShortcut('alt+shift+f')!; assert.ok(matchesShortcut(ev({ key: 'Ï', code: 'KeyF', altKey: true, shiftKey: true }), altShiftF), 'Alt changes the character on macOS'); assert.ok(!matchesShortcut(ev({ key: 'F', code: 'KeyF', shiftKey: true }), altShiftF)); const listeners = new Map void>(); const target = { addEventListener: (type: string, fn: EventListenerOrEventListenerObject) => listeners.set(type, fn as (event: Event) => void), removeEventListener: (type: string) => listeners.delete(type) }; let typing = false; let ran = 0; const registry = createCommandRegistry({ ...sync, target, isTyping: () => typing }); assert.equal(listeners.size, 0, 'no key listener without a shortcut'); const dispose = registry.register({ id: 'f', title: 'F', shortcut: 'Alt+Shift+F', run: () => { ran += 1; } }); const press = (over: Record) => { let prevented = false; listeners.get('keydown')!({ key: 'F', code: 'KeyF', ctrlKey: false, altKey: true, shiftKey: true, metaKey: false, repeat: false, defaultPrevented: false, preventDefault: () => { prevented = true; }, stopPropagation() {}, ...over } as unknown as Event); return prevented; }; assert.equal(press({}), true); press({ repeat: true }); typing = true; press({}); typing = false; assert.equal(ran, 1); dispose(); assert.equal(listeners.size, 0, 'listener removed with the last shortcut'); }); const SITE: PluginApiSite = { name: 'Demo', product: 'wayBetter for Test', storagePrefix: 'demo-plugin:', styleDataKey: 'demoPlugin', toastId: 'demo-toast', toastBackground: '#111', itemSelector: 'article', chordKeys: [], }; function fakeWindow() { const target = { addEventListener() {}, removeEventListener() {} }; const doc = { ...target, activeElement: null, documentElement: {}, head: { appendChild() {} }, querySelectorAll: () => [], createElement: () => ({ dataset: {}, remove() {} }) }; return { ...target, document: doc, location: { href: 'https://site.test/' }, localStorage: null, requestAnimationFrame: () => 1, cancelAnimationFrame() {}, MutationObserver: class { observe() {} disconnect() {} } } as unknown as Window & typeof globalThis; } test('api.commands and scope.commands: a scope keeps its plugin and removes its commands on dispose', async () => { const api = createCorePluginApi(fakeWindow(), SITE); assert.ok(Object.isFrozen(api.commands)); // As the host's start script does: construct + start() inside withOwner. const scope = api.commands.withOwner('bt-thread-reader.plugin.js', () => { const created = api.scope(); created.commands.register({ id: 'open', title: 'Thread Reader: open', run() {} }); return created; }); // Registered later (e.g. after an await) through the scope: still the same plugin. scope.commands.register({ id: 'settings', title: 'Thread Reader: settings', run() {} }); api.commands.register({ id: 'loose', title: 'Loose', run() {} }); assert.deepEqual(api.commands.list().map(item => item.id), ['bt-thread-reader.plugin.js:open', 'bt-thread-reader.plugin.js:settings', '_:loose']); const lists: string[][] = []; api.commands.onChange(list => lists.push(list.map(item => item.id))); scope.dispose(); await Promise.resolve(); assert.deepEqual(api.commands.list().map(item => item.id), ['_:loose']); assert.deepEqual(lists, [['_:loose']], 'one batched change for the whole dispose'); });