146 lines
8.5 KiB
TypeScript
146 lines
8.5 KiB
TypeScript
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<KeyboardEvent>) => ({ 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<string, (event: Event) => 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<string, unknown>) => {
|
|
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');
|
|
});
|