131 lines
7.7 KiB
TypeScript
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();
|
|
});
|