124 lines
8.1 KiB
TypeScript
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' });
|
|
});
|