waybetter/packages/shell/tests/panel.test.ts
CRT 632ba863c9 Feature health on the area cards: a feature that paused itself is named by its area's "health" palette entry, and the panel reads it for the row and the card (no new report to the host)
The entry opens the notes in the page; its keywords carry the paused flags of that area. English
for the three new words; the other catalogs follow with the words step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 22:00:04 +07:00

241 lines
18 KiB
TypeScript

// The panel's pure parts: its words (every key it uses has English and every English key is used),
// which sections an app gets and in what order, the Theme Builder's model, the Google Fonts
// catalog, Make's plugin header rules and UI kit demos, and the Markdown the bundled docs use.
import assert from 'node:assert/strict';
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { dirname, join } from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
import { themeFromColors, themeVars } from '../../runtime/src/themeApi.ts';
import { BASE_EFFECTS, BASE_FONT, BASE_LAYOUT, BASE_SHAPE, sanitizeSpec, type ThemeSpec } from '../../runtime/src/themeSpec.ts';
import { configureSite, type ShellSite } from '../src/frame/site.ts';
import { autoFix, contrastReport, draftFrom, paletteFrom, pinnedRoles, record, redo, startHistory, themeJson, undo, uniqueId } from '../src/panel/builderModel.ts';
import { compareLines } from '../src/panel/compare.ts';
import { defaultWeights, parseCatalog, searchFonts, stackFor } from '../src/panel/fonts/catalog.ts';
import { CREATE_COMMANDS, KIT_DEMOS, cssVarOf, parsePluginHeader, pluginFileFor, pluginWarnings, validPluginFileName } from '../src/panel/makeModel.ts';
import { anchorOf, inlineTokens, parseMarkdown, resolveLink } from '../src/panel/markdown.ts';
import { sparseFlags, tableDefaults, type FeatureTable } from '../../runtime/src/featureTable.ts';
import { areaCards, featureFiles, fixRows, modePlan, rowKeywords } from '../src/panel/featureModel.ts';
import { navEntries } from '../src/panel/nav.ts';
import { PANEL_MESSAGES } from '../src/panel/panelMessages.ts';
import type { PanelSite } from '../src/panel/site.ts';
const here = dirname(fileURLToPath(import.meta.url));
const PANEL = join(here, '../src/panel');
const files = (dir: string): string[] => readdirSync(dir).flatMap(name => {
const path = join(dir, name);
return statSync(path).isDirectory() ? files(path) : [path];
});
test('every key the panel uses has English, and every English key is used', () => {
const sources = files(PANEL).filter(file => /\.tsx?$/.test(file) && !file.endsWith('panelMessages.ts')).map(file => readFileSync(file, 'utf8')).join('\n');
const used = new Set([...sources.matchAll(/'(wb\.[A-Za-z0-9_.-]+)'/g)].map(match => match[1]!));
// Keys built at runtime (`wb.fonts.role.<role>.label`, ...): every key under the prefix counts.
const prefixes = [...sources.matchAll(/`(wb\.[A-Za-z0-9_.]+\.)\$\{/g)].map(match => match[1]!);
const en = JSON.parse(readFileSync(join(here, '../../waybetter/locales/en.json'), 'utf8')).messages as Record<string, string>;
for (const key of used) assert.ok(key in en, `${key} is in the framework catalog`);
for (const prefix of prefixes) assert.ok(Object.keys(en).some(key => key.startsWith(prefix)), `${prefix}* is in the framework catalog`);
// `wb.fonts.choice.<id>` is named by the runtime's FONT_CHOICES (`key`).
for (const key of Object.keys(PANEL_MESSAGES)) {
assert.ok(used.has(key) || prefixes.some(prefix => key.startsWith(prefix)) || key.startsWith('wb.fonts.choice.'), `${key} is used`);
assert.equal(en[key], PANEL_MESSAGES[key as keyof typeof PANEL_MESSAGES], `${key} is the catalog's English`);
}
});
const shellSite = (extra: Partial<ShellSite> = {}) => ({
name: 'Example', product: 'wayBetter for Example', logo: '', homeUrl: 'https://example.com/home', loginUrl: 'https://example.com/login', logoutUrl: 'https://example.com/logout',
isSiteUrl: (url: string) => url.startsWith('https://example.com/'),
accounts: { handle: /^\w{1,15}$/, avatarPrefixes: [], handlePrefix: '@' },
searchUrl: (text: string) => `https://example.com/search?q=${encodeURIComponent(text)}`,
address: () => null,
panel: { id: 'app', title: 'App', component: () => null },
newTab: { search: () => 'Search', links: [] },
palette: { placeholder: () => 'Search', sections: [] },
...extra,
}) satisfies ShellSite;
const home: PanelSite['home'] = { kicker: { name: 'wayBetter', rest: ' for Example' }, title: () => ['Example, ', 'better', '.'], lede: () => 'An example.' };
test('an app gets the sections it serves, in its order', () => {
// A small app: no themes, store, decks, backup, developer mode or updater.
configureSite(shellSite({ features: { workspaces: false, sync: false, developer: false, pluginCommands: false } }));
const small: PanelSite = { brand: { name: 'Example', logo: '' }, home };
assert.deepEqual(navEntries(small).map(entry => entry.id), ['home', 'plugins', 'accounts', 'settings']);
// Its own sections come after the built-in ones unless it orders them.
const Own = () => null;
const own: PanelSite = { ...small, sections: [{ id: 'tweaks', label: () => 'Tweaks', icon: 'M0 0', component: Own, searchable: true }] };
assert.deepEqual(navEntries(own).map(entry => entry.id), ['home', 'plugins', 'accounts', 'settings', 'tweaks']);
const ordered = navEntries({ ...own, order: ['home', 'tweaks', 'plugins', 'nope', 'store'], backup: true, updates: true });
assert.deepEqual(ordered.map(entry => entry.id), ['home', 'tweaks', 'plugins', 'accounts', 'settings', 'backup', 'updates']);
assert.equal(ordered[1]!.component, Own);
assert.equal(ordered[1]!.label(key => key), 'Tweaks');
assert.equal(ordered[0]!.label(key => `[${key}]`), '[wb.panel.section.home]');
// Developer mode is a frame feature; Power User needs a deck.
configureSite(shellSite({
deck: { kinds: [], urlFor: () => null, title: () => ({ title: '' }), parseValue: () => '', invalid: () => '', flags: [], defaultFlags: {} },
}));
assert.deepEqual(navEntries(small).map(entry => entry.id), ['home', 'plugins', 'accounts', 'power', 'settings', 'developer']);
});
const SITE_THEME: ThemeSpec = {
id: 'site', name: 'Site default', description: '', author: 'Example', version: 1, mode: 'auto', colors: null,
font: BASE_FONT, shape: BASE_SHAPE, layout: BASE_LAYOUT, background: { kind: 'none' }, effects: BASE_EFFECTS, css: '',
};
const DRAFT_SITE = { colors: { bg: '#000000', text: '#e7e9ea', accent: '#1d9bf0' }, siteTheme: 'site' };
test('builder: history, palette from three colors, contrast report and auto-fix, export', () => {
const draft = draftFrom(SITE_THEME, DRAFT_SITE, 'Mine');
assert.ok(draft.colors, "the site's own look gets a real palette to edit");
assert.equal(draft.colors!.bg, '#000000');
assert.equal(draft.description, '', "the site's own theme is not \"based on\" anything");
assert.equal(draftFrom({ ...SITE_THEME, id: 'paper', name: 'Paper' }, DRAFT_SITE).description, 'Based on Paper.');
let history = startHistory(draft);
const light = { ...draft, colors: paletteFrom({ bg: '#fbf8f1', text: '#8a8a8a', accent: '#ffd400' }) };
history = record(history, light);
// A slider drag: many in-place updates, one undo step from where it started.
const dragged = record(record(history, { ...light, name: 'a' }, { commit: false }), { ...light, name: 'ab' }, { commit: false });
const committed = record(dragged, { ...light, name: 'abc' }, { from: light });
assert.equal(committed.past.length, 2);
assert.equal(undo(committed).present.name, light.name);
assert.equal(redo(undo(committed)).present.name, 'abc');
assert.equal(undo(undo(undo(committed))).present, draft);
const report = contrastReport(light.colors!);
assert.ok(report.some(check => !check.pass));
assert.ok(contrastReport(autoFix(light.colors!)).every(check => check.pass), 'auto-fix passes every check');
const colors = paletteFrom({ bg: '#000000', text: '#ffffff', accent: '#1d9bf0' }, { border: '#ff0000' });
assert.deepEqual(pinnedRoles(colors), { border: '#ff0000' });
assert.equal(uniqueId('Paper', ['site', 'paper']), 'paper-2', 'built-in ids are taken');
assert.equal(uniqueId('Mine', ['mine']), 'mine-2');
assert.equal(uniqueId('Mine', []), 'mine');
const schema = JSON.parse(readFileSync(join(here, '../../waybetter/theme.schema.json'), 'utf8'));
const json = JSON.parse(themeJson(light));
assert.equal(json.$schema, schema.$id);
assert.deepEqual(sanitizeSpec(json), sanitizeSpec(light));
});
test('Google Fonts catalog: parse, search, default weights, fallback stacks', () => {
const catalog = parseCatalog({ families: [
['Inter', 'sans', [100, 900], [100, 400, 900], 3], ['Lato', 'sans', null, [100, 300, 400, 700, 900], 2],
['Fraunces', 'serif', [100, 900], [400], 9], ['Bad/Name', 'sans', null, [400], 1], ['Odd', 'weird', null, [400], 1],
] });
assert.deepEqual(catalog.map(font => font.family), ['Inter', 'Lato', 'Fraunces']);
assert.deepEqual(searchFonts(catalog, {}).map(font => font.family), ['Lato', 'Inter', 'Fraunces'], 'popularity');
assert.deepEqual(searchFonts(catalog, { query: 'in' }).map(font => font.family), ['Inter'], 'prefix');
assert.deepEqual(searchFonts(catalog, { category: 'serif' }).map(font => font.family), ['Fraunces']);
assert.deepEqual(searchFonts(catalog, { only: new Set(['Lato']) }).map(font => font.family), ['Lato']);
assert.deepEqual(defaultWeights(catalog[0]!), { kind: 'range', min: 100, max: 900 });
assert.deepEqual(defaultWeights(catalog[1]!), { kind: 'list', values: [400, 700] });
assert.deepEqual(stackFor(catalog[2]!), ['Fraunces', 'Georgia', 'serif']);
// The bundled snapshot parses and only holds names native code accepts.
const bundled = parseCatalog(JSON.parse(readFileSync(join(PANEL, 'fonts/googleFontsCatalog.json'), 'utf8')));
assert.ok(bundled.length > 1500);
assert.ok(bundled.every(font => /^[A-Za-z0-9]+( [A-Za-z0-9]+)*$/.test(font.family) && font.family.length <= 48));
});
test('Make: header parsing follows the plugin store (waybetter-host parse_meta)', () => {
const source = '/**\n * @name Hide Trends\n * @description Hides trends\n * @version 1.2.0\n * @author Someone\n */\nmodule.exports = class {};';
assert.deepEqual(parsePluginHeader('hide.plugin.js', source), { file: 'hide.plugin.js', name: 'Hide Trends', description: 'Hides trends', version: '1.2.0', author: 'Someone', header: true });
assert.equal(parsePluginHeader('plain.plugin.js', 'module.exports = {};').name, 'plain', 'no header: the file name');
assert.equal(parsePluginHeader('one.plugin.js', ' /** @name One */ module.exports = {};').name, 'One');
assert.equal(parsePluginHeader('x.plugin.js', '/**\n * @name \n */').name, 'x', 'an empty @name keeps the file name');
assert.equal(parsePluginHeader('x.plugin.js', `/**\n * @description ${'d'.repeat(400)}\n */`).description.length, 300);
assert.equal(validPluginFileName('../evil.plugin.js'), false);
assert.equal(validPluginFileName('.hidden.plugin.js'), false);
assert.equal(validPluginFileName('ok name.plugin.js'), true);
assert.equal(pluginFileFor('Hide Trends'), 'hide-trends.plugin.js');
assert.deepEqual(pluginWarnings(parsePluginHeader('a.plugin.js', 'console.log(1)'), 'console.log(1)').length, 3);
});
test('Make: UI kit demos name real kit calls; color roles map to the --wb-* variables', () => {
for (const demo of KIT_DEMOS) assert.ok(demo.code.includes(`ui.${demo.kind}(`), demo.id);
const theme = themeFromColors('#101010', '#f0f0f0', '#3ddc97');
const vars = themeVars(theme);
for (const role of Object.keys(theme.colors)) assert.ok(cssVarOf(role) in vars, role);
assert.ok(CREATE_COMMANDS.every(item => item.command.startsWith('npm create waybetter ')));
});
test('Markdown: headings, tables, code and links as data; anchors and doc links resolve', () => {
const blocks = parseMarkdown('# Title\n\nSee [the API](./OTHER.md#the-api) and `code`.\n\n| a | b |\n| - | - |\n| 1 | 2 |\n\n```js\nlet x = 1;\n```\n\n- one\n- **two**\n');
assert.deepEqual(blocks.map(block => block.kind), ['heading', 'paragraph', 'table', 'code', 'list']);
const table = blocks.find(block => block.kind === 'table')!;
assert.ok(table.kind === 'table' && table.rows.every(row => row.length === table.head.length));
const links = inlineTokens('See [the API](./OTHER.md#the-api) and `code`.').filter(token => token.kind === 'link');
assert.equal(links.length, 1);
assert.deepEqual(resolveLink('./OTHER.md#the-api', ['OTHER.md']), { doc: 'OTHER.md', anchor: 'the-api' });
assert.equal(anchorOf('Matching the theme: `api.theme`'), 'matching-the-theme-apitheme');
});
test('compare: changed lines are marked on both sides', () => {
const diff = compareLines('one\ntwo\nthree', 'one\n2\nthree');
assert.deepEqual(diff.previous.map(line => line.changed), [false, true, false]);
assert.deepEqual(diff.current.map(line => line.changed), [false, true, false]);
assert.equal(diff.current[1]!.text, '2');
});
// ---------------------------------------------------------------------------------------------
// Area plugins: what the cards show of a feature table, and what choosing a mode changes.
const FEATURE_TABLE: FeatureTable = {
areas: [
{ id: 'navigation', name: 'Navigation+', description: 'plugin.navigation-plus.description', owner: 'wb-navigation-plus.plugin.js', namespace: 'navigation-plus' },
{ id: 'reader', name: 'Reader+', description: 'plugin.reader-plus.description', owner: 'wb-reader-plus.plugin.js', namespace: 'reader-plus' },
],
features: [
{ id: 'logo', area: null, group: 'branding', default: true, backing: 'app' },
{ id: 'fixScroll', area: null, group: 'fixes', default: true, backing: 'app' },
{ id: 'hideTrends', area: 'navigation', group: 'sidebar', default: false, backing: 'module' },
{ id: 'navigation.focusMode', area: 'navigation', group: 'tools', default: false, backing: 'module', settings: true, supersedes: 'wb-focus-mode.plugin.js', keywords: 'Focus Mode' },
{ id: 'hideAds', area: 'reader', group: 'timeline', default: true, backing: 'app' },
{ id: 'reader.cleanLinks', area: 'reader', group: 'posts', default: false, backing: 'plugin', file: 'wb-clean-links.plugin.js', label: 'plugin.clean-links.name' },
{ id: 'reader.readingTime', area: 'reader', group: 'posts', default: false, backing: 'plugin', file: 'wb-reading-time.plugin.js' },
],
modes: [
{ id: 'basic', name: 'mode.basic', description: 'mode.basic.text', flags: { 'area.navigation': false, 'area.reader': false, logo: false } },
{ id: 'advanced', name: 'mode.advanced', description: 'mode.advanced.text', recommended: true, flags: {} },
],
};
test('area cards: a feature is its flag or, until it is ported, its plugin file; the Fixes list is what no area owns', () => {
const flags = { ...tableDefaults(FEATURE_TABLE), 'navigation.focusMode': true };
const plugins = [{ file: 'wb-clean-links.plugin.js', enabled: true, name: 'Clean Links' }, { file: 'mine.plugin.js', enabled: true }];
const [navigation, reader] = areaCards(FEATURE_TABLE, flags, plugins);
assert.deepEqual([navigation!.on, navigation!.total, navigation!.enabled, navigation!.settings], [1, 2, true, true]);
assert.deepEqual(navigation!.groups.map(group => [group.id, group.rows.map(row => row.spec.id)]), [['sidebar', ['hideTrends']], ['tools', ['navigation.focusMode']]]);
assert.deepEqual(reader!.rows.map(row => [row.spec.id, row.kind, row.on, row.missing]), [
['hideAds', 'flag', true, false],
['reader.cleanLinks', 'plugin', true, false],
['reader.readingTime', 'plugin', false, true],
], 'an old plugin file is on when it is enabled; a deleted one cannot be switched');
assert.equal(reader!.rows[1]!.label, 'plugin.clean-links.name');
assert.equal(reader!.rows[0]!.label, 'features.hideAds.label');
assert.deepEqual([...featureFiles(FEATURE_TABLE)].sort(), ['wb-clean-links.plugin.js', 'wb-reading-time.plugin.js'], 'listed in a card, so not again under JavaScript plugins');
// The area's switch off: the card says so and offers no Settings; the feature's own choice is kept.
const off = areaCards(FEATURE_TABLE, { ...flags, 'area.navigation': false }, plugins)[0]!;
assert.deepEqual([off.enabled, off.settings, off.on], [false, false, 1]);
// The owner kept an edited copy of the plugin a feature replaced.
const own = areaCards(FEATURE_TABLE, tableDefaults(FEATURE_TABLE), [{ file: 'wb-focus-mode.plugin.js', enabled: true, name: 'Focus Mode' }])[0]!;
assert.equal(own.rows[1]!.ownCopy?.enabled, true);
assert.equal(own.rows[1]!.on, false, 'never both: the built-in feature stays off while their copy is on');
assert.match(rowKeywords(own.rows[1]!, own.area), /Focus Mode.*wb-focus-mode\.plugin\.js.*Navigation\+/, 'the search finds a feature by its old plugin name');
assert.deepEqual(fixRows(FEATURE_TABLE, flags, ['logo']).map(row => row.spec.id), ['fixScroll'], 'core flags, minus what has a control elsewhere');
// A feature that paused itself in the focused tab: its row and its card say so, only while it would run.
const paused = new Set(['navigation.focusMode', 'hideTrends']);
const noted = areaCards(FEATURE_TABLE, flags, plugins, paused)[0]!;
assert.deepEqual([noted.paused, noted.rows.map(row => row.paused)], [1, [false, true]], 'the one that is off is not "paused"');
assert.equal(navigation!.paused, 0);
assert.equal(areaCards(FEATURE_TABLE, { ...flags, 'area.navigation': false }, plugins, paused)[0]!.paused, 0, 'an area that is off runs nothing');
});
test('modes: a mode replaces the owner\'s changes or keeps them, old plugin files included', () => {
const defaults = tableDefaults(FEATURE_TABLE);
const mine = { ...defaults, hideTrends: true };
const plugins = [{ file: 'wb-clean-links.plugin.js', enabled: true }, { file: 'mine.plugin.js', enabled: true }];
const fresh = modePlan(FEATURE_TABLE, mine, plugins, null, 'basic', false);
assert.equal(fresh.changes, 2, 'one flag and one old plugin file');
assert.deepEqual(fresh.disable, ['wb-clean-links.plugin.js'], 'a plugin of the owner\'s own is never touched');
assert.deepEqual(sparseFlags(FEATURE_TABLE, fresh.flags), { 'area.navigation': false, 'area.reader': false, logo: false });
const kept = modePlan(FEATURE_TABLE, mine, plugins, null, 'basic', true);
assert.deepEqual(kept.disable, []);
assert.deepEqual(sparseFlags(FEATURE_TABLE, kept.flags), { 'area.navigation': false, 'area.reader': false, logo: false, hideTrends: true });
assert.equal(modePlan(FEATURE_TABLE, defaults, [], 'advanced', 'basic', false).changes, 0, 'nothing of the owner\'s to ask about');
});