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

131 lines
7.7 KiB
TypeScript

// Every palette command and keyboard shortcut of the eight area plugins, in one table
// (docs/AREA-PLUGINS.md, section 4). A new command or shortcut is added here on purpose: the test
// fails on a shortcut two features share, on one the app itself uses, and on an area that could
// exceed its palette budget with everything on.
import assert from 'node:assert/strict';
import { readdirSync, readFileSync } from 'node:fs';
import test from 'node:test';
import { X_TABLE } from '../src/areas/table.ts';
import { AREA_COMMAND_BUDGET } from '../../../waybetter/packages/runtime/src/featureModules.ts';
import { areaFeatures } from '../../../waybetter/packages/runtime/src/featureTable.ts';
import { parseShortcut } from '../../../waybetter/packages/runtime/src/commands.ts';
import { areaPage, flagsWith } from './helpers/areaPage.ts';
const AREAS = new URL('../src/areas/', import.meta.url);
/** `<area>: <command id> [shortcut]`, in the order of the table's areas. "settings" is the registry's own entry of an area. */
const COMMANDS: Record<string, Array<[id: string, shortcut?: string]>> = {
compose: [['replyGuard.settings', 'Alt+Shift+R'], ['snippets.insert']],
reader: [['mutedRegex.edit', 'Alt+Shift+M'], ['quickMute.add', 'Alt+Shift+U'], ['readMarker.jump'], ['threadReader.open']],
profile: [['privateLabels.edit']],
chat: [['bookmarks.open'], ['personFilter.pick'], ['pins.edit'], ['searchLoaded.open', 'Ctrl+Shift+F']],
media: [['blurMedia.toggle', 'Alt+Shift+B'], ['videoSpeed.faster', 'Alt+Shift+ArrowUp'], ['videoSpeed.slower', 'Alt+Shift+ArrowDown']],
search: [['advancedSearch.open'], ['savedSearches.open'], ['savedSearches.pin']],
notifications: [],
navigation: [['focusMode.toggle', 'Alt+Shift+F']],
};
/** Keys outside the palette (a key listener of the module), kept from the plugin the feature was. */
const KEYS: Array<[area: string, file: string, shortcut: string]> = [['reader', 'collapseVerified.ts', 'Alt+Shift+H']];
/**
* Keys the app takes before a page sees them (waybetter shell `shortcutFor` / `deckShortcutFor`,
* zoom, the app menu, find in page) and the ones X itself uses with a modifier. A feature never
* takes one of these: the tab's bridge forwards them to the shell before the page sees the key, so
* a feature's shortcut on one of them would simply never fire. (Chat+'s "Find in conversation" is
* Ctrl+Shift+F for that reason: Ctrl+F is the app's find bar.)
*/
const RESERVED = [
'Ctrl+T', 'Ctrl+Shift+T', 'Ctrl+W', 'Ctrl+Shift+W', 'Ctrl+L', 'Ctrl+Shift+G', 'Ctrl+K', 'Ctrl+R', 'Ctrl+Shift+R', 'Ctrl+\\', 'Ctrl+Shift+\\',
'Alt+ArrowLeft', 'Alt+ArrowRight', 'Ctrl+Shift+D', 'Ctrl+Alt+N', 'Alt+R', 'Alt+U', ...[1, 2, 3, 4, 5, 6, 7, 8, 9].map(digit => `Alt+${digit}`),
'Ctrl+=', 'Ctrl+-', 'Ctrl+0', 'Ctrl+Enter', 'Ctrl+F', 'Alt+F', 'F3', 'F10',
];
const canonical = (shortcut: string) => {
const parsed = parseShortcut(shortcut);
assert.ok(parsed, `${shortcut} is a shortcut the command registry accepts`);
return [parsed.ctrl && 'ctrl', parsed.alt && 'alt', parsed.shift && 'shift', parsed.meta && 'meta', parsed.key].filter(Boolean).join('+');
};
/** What the sources register: every `ctx.command({ id, ..., shortcut? })` of an area's modules. */
function registered(area: string): Array<[string, string?]> {
const found: Array<[string, string?]> = [];
for (const file of readdirSync(new URL(`${area}/`, AREAS)).filter(name => name.endsWith('.ts')).sort()) {
const source = readFileSync(new URL(`${area}/${file}`, AREAS), 'utf8');
for (const match of source.matchAll(/ctx\.command\(\{\s*id: '([^']+)'([\s\S]*?)\brun\b/g)) {
const shortcut = /shortcut: '([^']+)'/.exec(match[2]!)?.[1];
found.push(shortcut ? [match[1]!, shortcut] : [match[1]!]);
}
}
return found.sort(([a], [b]) => a.localeCompare(b));
}
test('area commands: the table is what the modules register, and a command is named after its feature', () => {
assert.deepEqual(Object.keys(COMMANDS), X_TABLE.areas.map(area => area.id));
for (const area of X_TABLE.areas) {
assert.deepEqual(registered(area.id), COMMANDS[area.id], area.name);
const features = areaFeatures(X_TABLE, area.id).map(feature => feature.id.replace(/^[a-z]+\./, ''));
for (const [id] of COMMANDS[area.id]!) assert.ok(features.includes(id.split('.')[0]!), `${area.name}: ${id} is <feature>.<action>`);
}
});
test('area commands: no shortcut is shared by two features, and none is one the app or X uses', () => {
const owners = new Map<string, string>();
const reserved = new Set(RESERVED.map(canonical));
for (const [area, list] of Object.entries(COMMANDS)) {
for (const [id, shortcut] of list) {
if (!shortcut) continue;
const key = canonical(shortcut);
assert.ok(!owners.has(key), `${shortcut}: ${area} ${id} and ${owners.get(key)}`);
assert.ok(!reserved.has(key), `${shortcut} (${area} ${id}) is taken by the app`);
owners.set(key, `${area} ${id}`);
}
}
// A key a module listens for itself counts too, and every such listener is in the list.
for (const [area, file, shortcut] of KEYS) {
const key = canonical(shortcut);
assert.ok(!owners.has(key), `${shortcut}: ${area} ${file} and ${owners.get(key)}`);
assert.ok(!reserved.has(key), `${shortcut} (${area} ${file}) is taken by the app`);
owners.set(key, `${area} ${file}`);
}
const listening: string[] = [];
for (const area of X_TABLE.areas) {
for (const file of readdirSync(new URL(`${area.id}/`, AREAS)).filter(name => name.endsWith('.ts'))) {
if (/\.altKey && \w+\.shiftKey/.test(readFileSync(new URL(`${area.id}/${file}`, AREAS), 'utf8'))) listening.push(`${area.id}/${file}`);
}
}
assert.deepEqual(listening, KEYS.map(([area, file]) => `${area}/${file}`), 'a new Alt+Shift key listener belongs in KEYS (or, better, in a command)');
// All of them with a modifier: a bare key would fire while reading.
for (const key of owners.keys()) assert.match(key, /^(?:ctrl|alt)\+/, key);
});
test(`area commands: no area can exceed ${AREA_COMMAND_BUDGET} palette entries, its settings entry included; in the page they are what the table says`, () => {
for (const area of X_TABLE.areas) {
const settings = areaFeatures(X_TABLE, area.id).some(feature => feature.settings) ? 1 : 0;
assert.ok(COMMANDS[area.id]!.length + settings <= AREA_COMMAND_BUDGET, `${area.name}: ${COMMANDS[area.id]!.length} commands + ${settings}`);
}
// Every module on, on the pages the areas live on: nothing is refused for the budget, and each
// command shows under its area's owner.
const all = Object.fromEntries(X_TABLE.features.filter(feature => feature.backing === 'module').map(feature => [feature.id, true]));
const env = areaPage('/home');
const errors: string[] = [];
const original = console.error;
console.error = (...args: unknown[]) => { errors.push(args.join(' ')); };
const seen = new Set<string>();
try {
env.registry.setFlags(flagsWith(all));
for (const path of ['/home', '/someone', '/someone/status/1', '/notifications', '/search', '/explore', '/i/bookmarks', '/i/chat', '/compose/post']) {
env.go(path);
const ids = env.api.commands.list().map(command => command.id);
for (const id of ids) seen.add(id);
for (const area of X_TABLE.areas) assert.ok(ids.filter(id => id.startsWith(`${area.owner}:`)).length <= AREA_COMMAND_BUDGET, `${area.name} on ${path}`);
}
} finally { console.error = original; }
assert.deepEqual(errors.filter(line => /palette commands per area/.test(line)), []);
const expected = X_TABLE.areas.flatMap(area => [
...COMMANDS[area.id]!.map(([id]) => `${area.owner}:${id}`),
...(areaFeatures(X_TABLE, area.id).some(feature => feature.settings) ? [`${area.owner}:settings`] : []),
]);
assert.deepEqual([...seen].sort(), expected.sort());
env.registry.dispose();
});