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

124 lines
8.1 KiB
TypeScript

// Plugin commands in the Ctrl+K palette (registration in the page, report to the shell, palette
// items, cleanup when the plugin stops) and the workspace items next to them.
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import { createCommandRegistry } from '../../../waybetter/packages/runtime/src/commands.ts';
import { installCommandReport } from '../../../waybetter/packages/runtime/src/commandReport.ts';
import { MAX_PLUGIN_COMMANDS, buildItems, pluginCommandItems, type PaletteContext } from '../src/shell/paletteModel.ts';
const base: PaletteContext = { tabs: [], profiles: [{ id: 'default', name: 'Main' }], activeProfile: 'default' };
const names = { 'bt-focus-mode.plugin.js': 'Focus Mode', 'bt-thread-reader.plugin.js': 'Thread Reader' };
test('commands are listed under their plugin name and run in the focused tab', () => {
const items = pluginCommandItems([
{ id: 'bt-thread-reader.plugin.js:open', title: 'Open Thread Reader', keywords: 'read thread' },
{ id: 'bt-focus-mode.plugin.js:toggle', title: 'Toggle Focus Mode', keywords: 'distraction' },
{ id: 'bt-focus-mode.plugin.js:settings', title: 'Focus Mode settings', keywords: '' },
{ id: '_:loose', title: 'Loose command', keywords: '' },
{ id: 'my-own.plugin.js:x', title: 'Unnamed plugin', keywords: '' },
{ id: 'no-owner', title: 'Invalid', keywords: '' },
], names);
assert.deepEqual(items.map(item => [item.plugin, item.title]), [
['Thread Reader', 'Open Thread Reader'], ['Focus Mode', 'Toggle Focus Mode'], ['Focus Mode', 'Focus Mode settings'],
['Plugins', 'Loose command'], ['my-own', 'Unnamed plugin'],
]);
const palette = buildItems({ ...base, commands: { tab: 't9', items } }, '');
const commands = palette.filter(item => item.action.type === 'plugin-command');
// One heading per plugin, in name order; each plugin keeps its own order.
assert.deepEqual(commands.map(item => item.group), ['Focus Mode', 'Focus Mode', 'my-own', 'Plugins', 'Thread Reader']);
assert.deepEqual(commands[0]!.action, { type: 'plugin-command', tab: 't9', command: 'bt-focus-mode.plugin.js:toggle' });
// Found by title, plugin name or keywords.
assert.equal(buildItems({ ...base, commands: { tab: 't9', items } }, 'distraction')[0]!.label, 'Toggle Focus Mode');
assert.equal(buildItems({ ...base, commands: { tab: 't9', items } }, 'thread reader')[0]!.label, 'Open Thread Reader');
});
test('the palette lists a bounded number of commands and nothing without a focused X tab', () => {
const many = Array.from({ length: 80 }, (_, n) => ({ id: `a.plugin.js:c${n}`, title: `Command ${n}`, keywords: '' }));
assert.equal(pluginCommandItems(many, {}).length, MAX_PLUGIN_COMMANDS);
assert.equal(buildItems(base, '').filter(item => item.action.type === 'plugin-command').length, 0);
const long = pluginCommandItems([{ id: '_:a', title: 't'.repeat(300), keywords: 'k'.repeat(900) }], {});
assert.equal(long[0]!.title.length, 80);
assert.equal(long[0]!.keywords.length, 160);
});
test('the page reports its command list (strings only) when it changes, and an empty list after the plugin stops', async () => {
const registry = createCommandRegistry();
const reports: unknown[] = [];
const invoke = async (command: string, args: Record<string, unknown>) => { reports.push([command, args]); };
installCommandReport(registry, invoke, 5);
registry.withOwner('bt-focus-mode.plugin.js', () => registry.register({ id: 'toggle', title: 'Toggle Focus Mode', keywords: ['distraction'], shortcut: 'Alt+Shift+F', run() {} }));
await new Promise(resolve => setTimeout(resolve, 20));
assert.deepEqual(reports, [['tab_report', { report: { commands: [{ id: 'bt-focus-mode.plugin.js:toggle', title: 'Toggle Focus Mode', keywords: 'distraction' }] } }]]);
// Registering the same thing again is no change: nothing is sent.
registry.withOwner('bt-focus-mode.plugin.js', () => registry.register({ id: 'toggle', title: 'Toggle Focus Mode', keywords: ['distraction'], run() {} }));
await new Promise(resolve => setTimeout(resolve, 20));
assert.equal(reports.length, 1);
// What the host's stop script does after stop().
registry.removeOwner('bt-focus-mode.plugin.js');
await new Promise(resolve => setTimeout(resolve, 20));
assert.deepEqual(reports.at(-1), ['tab_report', { report: { commands: [] } }]);
const reported = JSON.stringify(reports);
assert.ok(!reported.includes('run') && !reported.includes('shortcut'), 'no code and no shortcut leave the page');
});
test('bundled plugins register palette commands in start() and lose them on stop()', () => {
const g = globalThis as Record<string, unknown>;
const saved = { window: g.window, document: g.document, location: g.location };
const registry = createCommandRegistry({ schedule: fn => fn() });
const calls: string[] = [];
const fakeApi = {
storage: () => ({ get: (_k: string, fallback: unknown) => fallback, set: () => true, remove() {}, keys: () => [] }),
injectCSS() {}, clearCSS() {}, isTypingTarget: () => false,
toast: (text: string) => calls.push(`toast:${text}`),
users: { get: () => null, onUpdate: () => () => {} },
commands: registry,
scope() {
const disposers: (() => void)[] = [];
const owner = registry.currentOwner();
return {
on() {}, css() {}, observe() {}, onRouteChange() {},
defer(fn: () => void) { disposers.push(fn); },
ui: { dialog: () => { calls.push('dialog'); return { open: true, close() {} }; }, toast: (text: string) => { calls.push(`toast:${text}`); return { close() {} }; }, h: () => ({}) },
commands: { register: (definition: Parameters<typeof registry.register>[0]) => { const dispose = registry.registerFor(owner, definition); disposers.push(dispose); return dispose; } },
dispose() { for (const fn of disposers.splice(0).reverse()) fn(); },
};
},
};
g.window = { wayBetter: { api: fakeApi } };
g.document = { querySelector: () => null, querySelectorAll: () => [] };
g.location = { pathname: '/home' };
const expected: Record<string, string[]> = {
'focus-mode.plugin.js': ['bt-focus-mode.plugin.js:toggle', 'bt-focus-mode.plugin.js:settings'],
'thread-reader.plugin.js': ['bt-thread-reader.plugin.js:open'],
'hide-blue-checks.plugin.js': ['bt-hide-blue-checks.plugin.js:settings'],
};
try {
for (const [file, ids] of Object.entries(expected)) {
const source = readFileSync(new URL(`../src-tauri/bundled-plugins/${file}`, import.meta.url), 'utf8');
assert.match(source, /^ \* @permissions .*\bcommands\b/m, `${file} declares the commands permission`);
const module = { exports: {} as unknown };
new Function('module', 'exports', source)(module, module.exports);
const Plugin = module.exports as new () => { start(): void; stop(): void };
// As native code starts it: the file (installed as bt-<file>) owns what start() registers.
const plugin = registry.withOwner(`bt-${file}`, () => { const instance = new Plugin(); instance.start(); return instance; });
assert.deepEqual(registry.list().map(item => item.id), ids, file);
calls.length = 0;
for (const id of ids) assert.equal(registry.run(id), true, id);
assert.ok(calls.length >= ids.length, `${file} commands did something: ${calls.join(', ')}`);
plugin.stop();
assert.deepEqual(registry.list(), [], `${file} removed its commands on stop`);
}
} finally {
g.window = saved.window;
g.document = saved.document;
g.location = saved.location;
}
});
test('workspaces: switch items and save', () => {
const items = buildItems({ ...base, workspaces: [{ id: 'launch', name: 'Launch day', tabs: 4, isDefault: true }, { id: 'night', name: 'Night', tabs: 1 }], activeWorkspace: 'night' }, '');
const group = items.filter(item => item.group === 'Workspaces');
assert.deepEqual(group.map(item => [item.label, item.hint]), [['Switch to Launch day', '4 tabs · on start'], ['Switch to Night', 'Active'], ['Save layout as workspace…', undefined]]);
assert.deepEqual(buildItems({ ...base, workspaces: [{ id: 'launch', name: 'Launch day', tabs: 4 }] }, 'launch')[0]!.action, { type: 'workspace', id: 'launch' });
});