// 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) => { 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; 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[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 = { '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-) 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' }); });