The app menu's "Report a problem…" opens a small dialog with the summary (reportModel.ts, English on purpose: the people who read reports do): the app and framework versions, the system and webview (coarse, from the user agent), the language and how it was chosen, the mode, the theme and icon pack ids, the file names of the plugins that are on, what paused itself (the panel's `reportDetails`), and safe mode. The model has no field for an account, a handle, an address, a tab or a path, and a value that is not made of id characters is left out rather than trimmed into shape, so nothing of that kind can ride along (tested with hostile input). The dialog sends nothing: Copy puts the text on the clipboard, and "Open the issue page" opens `ShellSite.menu.report.issueUrl` bare in the system browser (the summary is never put in a URL). Without an issue page the button is off and the dialog says so. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
273 lines
19 KiB
TypeScript
273 lines
19 KiB
TypeScript
// The app menu's model (frame/menuModel.ts): which rows an app gets (its groups, the built-in rows
|
|
// and what gates them), how the keyboard moves through them, the language list, the zoom steps,
|
|
// how shortcuts are written and where the menu's words come from.
|
|
import assert from 'node:assert/strict';
|
|
import { readFileSync } from 'node:fs';
|
|
import { dirname, join } from 'node:path';
|
|
import test from 'node:test';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { setShellI18n, type ShellI18n } from '../src/frame/i18n.ts';
|
|
import {
|
|
DEFAULT_MENU, MENU_BUILTINS, MENU_MESSAGES, ZOOM_MAX, ZOOM_MIN, ZOOM_STEPS, builtinAvailable, findLabel, findSpec, joinGroups, languageChoice, languageRows, menuText, menuWith,
|
|
menuWithout, moveStop, planMenu, shortcutText, specRows, submenuKeys, submenuLeft, typeahead, updateRow, zoomPercent, zoomStep,
|
|
type MenuEnv, type MenuGroups, type MenuItemRow, type MenuItemSpec, type MenuLanguage, type MenuRow,
|
|
} from '../src/frame/menuModel.ts';
|
|
import { buildItems } from '../src/frame/paletteModel.ts';
|
|
|
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
const ALL: MenuEnv = { decks: true, languages: true, themes: true, modes: true, zoom: true, find: true, safeMode: true, store: true, workspaces: true, developer: true, updates: true };
|
|
const NONE: MenuEnv = { decks: false, languages: false, themes: false, modes: false, zoom: false, find: false, safeMode: false, store: false, workspaces: false, developer: false, updates: false };
|
|
const ids = (groups: ReturnType<typeof planMenu>) => groups.map(group => group.map(entry => (typeof entry === 'string' ? entry : entry.id)));
|
|
const items = (rows: MenuRow[]) => rows.filter((row): row is MenuItemRow => row.kind === 'item');
|
|
|
|
test('the default menu lists every built-in row once, and an app that serves everything gets them all', () => {
|
|
assert.deepEqual([...DEFAULT_MENU.flat()].sort(), [...MENU_BUILTINS].sort());
|
|
assert.deepEqual(ids(planMenu(DEFAULT_MENU, ALL)), DEFAULT_MENU.map(group => [...group]));
|
|
});
|
|
|
|
test('a row the app cannot serve is left out, and a group left empty takes its rule with it', () => {
|
|
// An app that serves none of the optional parts: no decks, languages, themes, zoom, find, Store, workspaces, developer mode or updater.
|
|
assert.deepEqual(ids(planMenu(DEFAULT_MENU, NONE)), [
|
|
['new-tab', 'split-right', 'split-down'],
|
|
['plugins'],
|
|
['shortcuts', 'palette'],
|
|
['report', 'about', 'settings'],
|
|
]);
|
|
// Each gate is its own.
|
|
const gates: Array<[keyof MenuEnv, string]> = [
|
|
['decks', 'new-column'], ['languages', 'language'], ['themes', 'theme'], ['modes', 'mode'], ['zoom', 'zoom'], ['find', 'find'], ['safeMode', 'safe-mode'],
|
|
['store', 'store'], ['workspaces', 'workspaces'], ['developer', 'developer'], ['updates', 'updates'],
|
|
];
|
|
for (const [feature, row] of gates) {
|
|
const only = ids(planMenu(DEFAULT_MENU, { ...NONE, [feature]: true })).flat();
|
|
assert.ok(only.includes(row), `${feature} shows ${row}`);
|
|
assert.equal(ids(planMenu(DEFAULT_MENU, { ...ALL, [feature]: false })).flat().includes(row), false, `without ${feature} there is no ${row}`);
|
|
assert.equal(builtinAvailable(row as typeof MENU_BUILTINS[number], NONE), false);
|
|
}
|
|
for (const row of ['new-tab', 'split-right', 'split-down', 'plugins', 'shortcuts', 'palette', 'report', 'about', 'settings'] as const) assert.ok(builtinAvailable(row, NONE), row);
|
|
// The rows that remain are separated once, never twice.
|
|
const rows = joinGroups(planMenu(DEFAULT_MENU, NONE).map(group => group.map((entry): MenuRow => ({ kind: 'item', id: String(entry), label: String(entry) }))));
|
|
assert.equal(rows.filter(row => row.kind === 'separator').length, 3);
|
|
assert.ok(rows.every((row, index) => row.kind !== 'separator' || (index > 0 && index < rows.length - 1 && rows[index - 1]!.kind !== 'separator')));
|
|
});
|
|
|
|
test('an app orders, removes and adds rows; its own rows follow their `when`', () => {
|
|
let drafts = true;
|
|
const own: MenuItemSpec = { id: 'drafts', label: () => 'Drafts…', icon: 'draft', when: () => drafts, run() {} };
|
|
const groups = menuWith(DEFAULT_MENU, 'workspaces', [own]);
|
|
assert.deepEqual(ids(planMenu(groups, ALL))[3], ['plugins', 'store', 'workspaces', 'drafts']);
|
|
assert.deepEqual(ids(planMenu(menuWith(DEFAULT_MENU, 'plugins', [own], 'before'), ALL))[3], ['drafts', 'plugins', 'store', 'workspaces']);
|
|
// Without its anchor the app's rows form a last group.
|
|
assert.deepEqual(ids(planMenu(menuWith(DEFAULT_MENU, 'nothing', [own]), ALL)).at(-1), ['drafts']);
|
|
drafts = false;
|
|
assert.equal(ids(planMenu(groups, ALL)).flat().includes('drafts'), false);
|
|
drafts = true;
|
|
assert.deepEqual(ids(planMenu(menuWithout(groups, 'store', 'zoom', 'drafts'), ALL)), [
|
|
['new-tab', 'split-right', 'split-down', 'new-column'], ['language', 'theme', 'mode'], ['plugins', 'workspaces'], ['find', 'shortcuts', 'palette'], ['developer', 'updates', 'safe-mode', 'report', 'about', 'settings'],
|
|
]);
|
|
// Explicit order; a row listed twice shows once; an unknown name and an own row named like a built-in are ignored.
|
|
const custom: MenuGroups = [['settings', 'new-tab', 'settings', 'nope' as never], [{ id: 'zoom', label: () => 'Fake zoom' }, own]];
|
|
assert.deepEqual(ids(planMenu(custom, ALL)), [['settings', 'new-tab'], ['drafts']]);
|
|
});
|
|
|
|
test("the app's rows become menu rows: toggles, hints, submenus and groups inside a submenu", () => {
|
|
let on = false;
|
|
const specs: MenuItemSpec[] = [
|
|
{ id: 'a', label: () => 'Alpha', shortcut: 'Ctrl+Shift+A', run() {} },
|
|
{ id: 'b', label: () => 'Beta', checked: () => on, keepOpen: true, run() { on = !on; } },
|
|
{ id: 'c', label: () => 'Gamma', hint: () => '3', separated: true, submenu: () => [{ id: 'c1', label: () => 'Inner', submenu: () => [{ id: 'c2', label: () => 'Deep' }] }] },
|
|
{ id: 'd', label: () => 'Hidden', when: () => false },
|
|
];
|
|
const rows = specRows(specs);
|
|
assert.deepEqual(rows.map(row => (row.kind === 'item' ? row.id : row.kind)), ['a', 'b', 'separator', 'c']);
|
|
const [a, b, c] = items(rows);
|
|
assert.equal(a!.shortcut, 'Ctrl+Shift+A');
|
|
assert.deepEqual([b!.check, b!.checked, b!.keepOpen], ['checkbox', false, true]);
|
|
assert.deepEqual([c!.submenu, c!.hint, a!.submenu, a!.check], [true, '3', false, undefined]);
|
|
(b!.run as () => void)();
|
|
assert.equal(items(specRows(specs))[1]!.checked, true, 'a toggle shows its state when the menu is drawn again');
|
|
// Submenus are found by id at any depth.
|
|
assert.equal(findSpec([['new-tab', ...specs]], 'c2')?.label(), 'Deep');
|
|
assert.equal(findSpec([['new-tab', ...specs]], 'c')?.submenu?.().length, 1);
|
|
assert.equal(findSpec([['new-tab']], 'c'), null);
|
|
});
|
|
|
|
test('arrows wrap, Home and End jump, and with nothing focused an arrow enters at the near end', () => {
|
|
assert.equal(moveStop(0, 'ArrowDown', 4), 1);
|
|
assert.equal(moveStop(3, 'ArrowDown', 4), 0);
|
|
assert.equal(moveStop(0, 'ArrowUp', 4), 3);
|
|
assert.equal(moveStop(2, 'Home', 4), 0);
|
|
assert.equal(moveStop(1, 'End', 4), 3);
|
|
assert.equal(moveStop(-1, 'ArrowDown', 4), 0);
|
|
assert.equal(moveStop(-1, 'ArrowUp', 4), 3);
|
|
assert.equal(moveStop(2, 'a', 4), 2);
|
|
assert.equal(moveStop(0, 'ArrowDown', 0), -1);
|
|
// Submenus open along the reading direction and close against it.
|
|
assert.deepEqual(submenuKeys(false), { open: 'ArrowRight', close: 'ArrowLeft' });
|
|
assert.deepEqual(submenuKeys(true), { open: 'ArrowLeft', close: 'ArrowRight' });
|
|
});
|
|
|
|
test('a submenu opens toward the middle of the window and never leaves it', () => {
|
|
// Under the button at the window's end: to the left of the menu.
|
|
assert.equal(submenuLeft({ left: 990, right: 1294 }, 250, 1440), 990 - 250 + 3);
|
|
// Under the logo at the window's start: to the right of the menu.
|
|
assert.equal(submenuLeft({ left: 8, right: 312 }, 250, 1440), 312 - 3);
|
|
// No room toward the middle: the other side.
|
|
assert.equal(submenuLeft({ left: 100, right: 404 }, 250, 700), 404 - 3);
|
|
// No room on either side (a narrow window): over the menu, inside the window.
|
|
assert.equal(submenuLeft({ left: 11, right: 315 }, 250, 420), 162);
|
|
assert.equal(submenuLeft({ left: 108, right: 412 }, 250, 420), 8);
|
|
for (const [left, width, viewport] of [[0, 300, 320], [40, 310, 330], [500, 260, 1000], [900, 320, 1000]] as const) {
|
|
const at = submenuLeft({ left, right: left + 304 }, width, viewport);
|
|
assert.ok(at >= 8 && (at + width <= viewport - 8 || at === 8), `${left}/${width}/${viewport}: ${at}`);
|
|
}
|
|
});
|
|
|
|
test('type-ahead finds the next row that starts with what was typed', () => {
|
|
const labels = ['New tab', 'Split right', 'Split down', 'Language', 'Theme', 'Settings…', 'Écran', 'İstanbul'];
|
|
assert.equal(typeahead(labels, -1, 's'), 1);
|
|
assert.equal(typeahead(labels, 1, 's'), 2, 'the same letter again moves on');
|
|
assert.equal(typeahead(labels, 2, 'ss'), 5, 'a repeated letter cycles');
|
|
assert.equal(typeahead(labels, 5, 's'), 1, 'and wraps');
|
|
assert.equal(typeahead(labels, 1, 'se'), 5, 'a typed word narrows');
|
|
assert.equal(typeahead(labels, 5, 'set'), 5, 'and stays on the row that still matches');
|
|
assert.equal(typeahead(labels, 0, 'T'), 4, 'case is ignored');
|
|
assert.equal(typeahead(labels, 0, 'e'), 6, 'accents are ignored');
|
|
assert.equal(typeahead(labels, 0, 'i'), 7);
|
|
assert.equal(typeahead(labels, 0, 'x'), -1);
|
|
assert.equal(typeahead(labels, 0, ''), -1);
|
|
assert.equal(typeahead([], 0, 'a'), -1);
|
|
});
|
|
|
|
test('shortcuts are written the way the platform writes them', () => {
|
|
assert.equal(shortcutText('Ctrl+T', false), 'Ctrl+T');
|
|
assert.equal(shortcutText('Ctrl+Shift+\\', false), 'Ctrl+Shift+\\');
|
|
assert.equal(shortcutText('Ctrl+T', true), '⌘T');
|
|
assert.equal(shortcutText('Ctrl+Shift+\\', true), '⇧⌘\\');
|
|
assert.equal(shortcutText('Ctrl+Alt+N', true), '⌥⌘N');
|
|
assert.equal(shortcutText('F10', true), 'F10');
|
|
});
|
|
|
|
const LANGUAGES: MenuLanguage[] = [
|
|
{ code: 'en', name: 'English', englishName: 'English', dir: 'ltr', tier: 'supported' },
|
|
{ code: 'tr', name: 'Türkçe', englishName: 'Turkish', dir: 'ltr', tier: 'supported' },
|
|
{ code: 'ar', name: 'العربية', englishName: 'Arabic', dir: 'rtl', tier: 'supported' },
|
|
{ code: 'sv', name: 'Svenska', englishName: 'Swedish', dir: 'ltr', tier: 'community' },
|
|
];
|
|
const WORDS = { site: 'Follow X', system: 'Follow system', more: 'More languages…' };
|
|
|
|
test('the Language submenu: follow the site or the system, the supported languages by their own names, then the full list', () => {
|
|
const rows = languageRows(LANGUAGES, { preference: 'site', siteLang: 'tr', locale: 'tr' }, WORDS);
|
|
assert.deepEqual(rows.map(row => (row.kind === 'item' ? row.id : row.kind)), ['lang:site', 'lang:system', 'separator', 'lang:en', 'lang:tr', 'lang:ar', 'separator', 'lang:more']);
|
|
const [site, system, en, tr, ar, more] = items(rows);
|
|
assert.deepEqual([site!.label, site!.checked, site!.hint], ['Follow X', true, 'Türkçe'], 'following the site says which language that is');
|
|
assert.deepEqual([system!.checked, system!.hint], [false, undefined]);
|
|
assert.deepEqual([tr!.label, tr!.hint, tr!.checked, tr!.check], ['Türkçe', 'Turkish', false, 'radio'], 'a language reads in its own name, English beside it');
|
|
assert.equal(en!.hint, undefined, 'no English name beside English');
|
|
assert.deepEqual([ar!.lang, ar!.dir], ['ar', 'rtl']);
|
|
assert.deepEqual([more!.label, more!.check], ['More languages…', undefined]);
|
|
assert.equal(items(rows).filter(row => row.checked).length, 1, 'exactly one is checked');
|
|
});
|
|
|
|
test('the Language submenu checks what was picked, a community language included', () => {
|
|
const picked = items(languageRows(LANGUAGES, { preference: 'ar', siteLang: 'tr', locale: 'ar' }, WORDS));
|
|
assert.deepEqual(picked.filter(row => row.checked).map(row => row.id), ['lang:ar']);
|
|
assert.equal(picked[0]!.hint, 'Türkçe', 'the site row still says what following it would give');
|
|
const system = items(languageRows(LANGUAGES, { preference: 'system', siteLang: null, locale: 'en' }, WORDS));
|
|
assert.deepEqual([system[0]!.hint, system[1]!.hint, system[1]!.checked], [undefined, 'English', true]);
|
|
// Community languages are in Settings; one that is in use is listed, so the check is never missing.
|
|
assert.equal(picked.some(row => row.id === 'lang:sv'), false);
|
|
const community = items(languageRows(LANGUAGES, { preference: 'sv', siteLang: null, locale: 'sv' }, WORDS));
|
|
assert.deepEqual(community.filter(row => row.checked).map(row => row.id), ['lang:sv']);
|
|
// A row stands for the preference the language state takes.
|
|
assert.deepEqual(['lang:site', 'lang:system', 'lang:zh-Hant', 'lang:more', 'theme:x'].map(languageChoice), ['site', 'system', 'zh-Hant', null, null]);
|
|
});
|
|
|
|
test('zoom moves through a browser\'s steps and stays inside the limits native code enforces', () => {
|
|
assert.equal(zoomStep(1, 1), 1.1);
|
|
assert.equal(zoomStep(1, -1), 0.9);
|
|
assert.equal(zoomStep(1.1, 1), 1.25);
|
|
assert.equal(zoomStep(1.2, 1), 1.25, 'from between two steps to the next one');
|
|
assert.equal(zoomStep(1.2, -1), 1.1);
|
|
assert.equal(zoomStep(2.5, 0), 1);
|
|
assert.equal(zoomStep(ZOOM_MAX, 1), ZOOM_MAX);
|
|
assert.equal(zoomStep(ZOOM_MIN, -1), ZOOM_MIN);
|
|
assert.equal(zoomStep(Number.NaN, 1), 1);
|
|
assert.deepEqual([ZOOM_STEPS[0], ZOOM_STEPS.at(-1)], [ZOOM_MIN, ZOOM_MAX]);
|
|
assert.ok(ZOOM_STEPS.every((step, index) => index === 0 || step > ZOOM_STEPS[index - 1]!));
|
|
// Up through every step and down again: no step is skipped or repeated.
|
|
let factor: number = ZOOM_MIN;
|
|
const up: number[] = [];
|
|
while (factor < ZOOM_MAX) { factor = zoomStep(factor, 1); up.push(factor); }
|
|
assert.deepEqual(up, ZOOM_STEPS.slice(1));
|
|
assert.deepEqual([zoomPercent(1.1), zoomPercent(0.67), zoomPercent(0.33)], [110, 67, 33]);
|
|
// The limits are the host's (zoom.rs).
|
|
const rust = readFileSync(join(here, '../../../crates/waybetter-host/src/zoom.rs'), 'utf8');
|
|
assert.match(rust, new RegExp(`pub const MIN: f64 = ${ZOOM_MIN};`));
|
|
assert.match(rust, new RegExp(`pub const MAX: f64 = ${ZOOM_MAX.toFixed(1)};`));
|
|
});
|
|
|
|
test("the menu's words: English from menuMessages.en.json until a catalog has them", () => {
|
|
// Every key is a wb.menu.* key with plain text (no markup), as the catalogs require.
|
|
for (const [key, value] of Object.entries(MENU_MESSAGES)) {
|
|
assert.match(key, /^wb\.menu\.[A-Za-z]+$/, key);
|
|
assert.ok(value.trim() && !/[<>&]|javascript:/i.test(value), key);
|
|
}
|
|
// Unbound (no i18n instance) and bound to a catalog without the key: the English file speaks.
|
|
assert.equal(menuText('label'), 'Settings and more');
|
|
assert.equal(menuText('about', { product: 'wayBetter for X' }), 'About wayBetter for X');
|
|
assert.equal(menuText('section', { section: 'Plugins' }), 'Plugins…');
|
|
const catalog: Record<string, string> = { 'wb.menu.label': 'Ayarlar ve daha fazlası', 'wb.menu.about': '{product} hakkında' };
|
|
const bound: ShellI18n = {
|
|
locale: 'tr',
|
|
tx: (key, params, fallback) => (key in catalog ? catalog[key]!.replace('{product}', String(params?.product ?? '')) : fallback ?? key),
|
|
onChange: () => () => {}, number: String, relative: String, date: String,
|
|
};
|
|
setShellI18n(bound);
|
|
assert.equal(menuText('label'), 'Ayarlar ve daha fazlası', 'a catalog that has the key wins');
|
|
assert.equal(menuText('about', { product: 'wayBetter for X' }), 'wayBetter for X hakkında');
|
|
assert.equal(menuText('zoom'), 'Zoom', 'a key the catalog lacks still reads in English');
|
|
// The component and the palette name their words through menuText, never as raw keys.
|
|
const used = new Set<string>();
|
|
for (const file of ['AppMenu.tsx', 'TitlebarExtras.tsx', 'AccountSwitcher.tsx', 'paletteModel.ts', 'FindBar.tsx', 'menuModel.ts', 'ReportDialog.tsx']) {
|
|
for (const match of readFileSync(join(here, '../src/frame', file), 'utf8').matchAll(/menuText\('([A-Za-z]+)'/g)) used.add(`wb.menu.${match[1]}`);
|
|
}
|
|
for (const key of used) assert.ok(key in MENU_MESSAGES, `${key} has English`);
|
|
for (const key of Object.keys(MENU_MESSAGES)) assert.ok(used.has(key), `${key} is used`);
|
|
});
|
|
|
|
test('the Updates row: a real check that says what came of it, calmly', () => {
|
|
assert.deepEqual(updateRow({ state: 'idle' }, false, '1.3.2'), { label: 'Check for updates', hint: '1.3.2', action: 'check' });
|
|
assert.deepEqual(updateRow({ state: 'checking' }, true, '1.3.2'), { label: 'Check for updates', hint: 'Checking…', action: 'wait' });
|
|
assert.deepEqual(updateRow({ state: 'upToDate' }, true, '1.3.2'), { label: 'Check for updates', hint: 'Up to date · 1.3.2', action: 'check' });
|
|
assert.equal(updateRow({ state: 'upToDate' }, true, '').hint, 'Up to date');
|
|
assert.deepEqual(updateRow({ state: 'downloading', version: '1.4.0' }, true, '1.3.2'), { label: 'Check for updates', hint: 'Downloading 1.4.0…', action: 'wait' });
|
|
assert.deepEqual(updateRow({ state: 'ready', version: '1.4.0', notes: '' }, false, '1.3.2'), { label: 'Restart to update', hint: '1.4.0', action: 'restart' });
|
|
assert.equal(updateRow({ state: 'installing', version: '1.4.0' }, false, '1.3.2').action, 'wait');
|
|
assert.equal(updateRow({ state: 'problem', downloadUrl: 'https://example.com/download' }, false, '1.3.2').action, 'open');
|
|
// A check that did not get through: a short state and a plain sentence, never the error text; and only
|
|
// when the owner asked (the timer's own failures stay quiet).
|
|
const failed = updateRow({ state: 'error', message: 'UPDATE: error sending request for url (https://updates.example/x): dns error' }, true, '1.3.2');
|
|
assert.deepEqual([failed.label, failed.hint, failed.action], ['Check for updates', 'Could not check', 'check']);
|
|
assert.ok(failed.title && !/UPDATE:|https?:|dns|[Ee]rror/.test(`${failed.hint} ${failed.title}`), 'no error dump');
|
|
assert.deepEqual(updateRow({ state: 'error', message: 'UPDATE: offline' }, false, '1.3.2'), { label: 'Check for updates', hint: '1.3.2', action: 'check' });
|
|
});
|
|
|
|
test("the find bar says which match of how many, in the reader's numbers", () => {
|
|
assert.equal(findLabel({ count: 17, index: 2 }), '3/17');
|
|
assert.equal(findLabel({ count: 1, index: 0 }), '1/1');
|
|
assert.equal(findLabel({ count: 0, index: -1 }), 'No matches');
|
|
assert.equal(findLabel({ count: 5, index: -1 }), '5 matches', 'a count without an active match');
|
|
assert.equal(findLabel({ count: 1, index: -1 }), '1 match');
|
|
assert.equal(findLabel({ count: 12, index: 3 }, value => `<${value}>`), '<4>/<12>');
|
|
});
|
|
|
|
test('the palette opens the menu', () => {
|
|
const all = buildItems({ tabs: [], profiles: [], activeProfile: 'default' }, '', { site: { placeholder: () => '', sections: [] }, product: 'App', decks: false, media: false });
|
|
const entry = all.find(item => item.id === 'app-menu');
|
|
assert.deepEqual([entry?.action, entry?.group], [{ type: 'menu' }, 'App']);
|
|
// Find in page is there unless the app has no find.
|
|
assert.deepEqual(all.find(item => item.id === 'find')?.action, { type: 'find' });
|
|
const without = buildItems({ tabs: [], profiles: [], activeProfile: 'default' }, '', { site: { placeholder: () => '', sections: [] }, product: 'App', decks: false, media: false, find: false });
|
|
assert.equal(without.some(item => item.id === 'find'), false);
|
|
});
|