waybetter/packages/shell/tests/frame.test.ts
CRT fc4fdf171a shell: the window follows a theme's corners and, for a bevelled theme, its edges
Every radius in the chrome and the panel scales with the theme's own radius (--wb-c-rs = radius / 12,
0 to 1.5): a square theme gets a square window, a round one a rounder window; themes that keep the
site's colors keep wayBetter's own corners. A theme built from raised and sunken edges (ChromeTheme
edges: 'bevel') gets frame/edges.css on html[data-wb-edges=bevel], also from the first frame: a face
color under tabs, toolbar, menus and the panel, 98.css's four edge lines derived from that face,
the active title bar's gradient across the tab row, tabs as a tab control, small raised caption
buttons, a sunken address field, selected menu rows in the title bar's color, raised and default
buttons, etched group boxes and classic scrollbars in the panel.
2026-10-01 14:08:01 +07:00

172 lines
10 KiB
TypeScript

// The shell frame's pure parts: its words (every key it uses has English and every English key is
// used), the palette's ordering with a site's items, the address fallback, the one-in-flight layout
// sync, the first-frame script, the chrome variables, the account switcher's rules, workspaces and
// the deck settings with a site's flags.
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 { accountAction, accountStartUrl, tabsByAccount, type PaneInfo } from '../src/frame/accountModel.ts';
import { BOOT_CACHE_KEY, bootScript } from '../src/frame/boot.ts';
import { CHROME_VAR_NAMES, chromeEdges, chromeVars } from '../src/frame/chromeTheme.ts';
import { defaultDeckSettings, normalizeDeckSettings } from '../src/frame/deckStore.ts';
import { FRAME_MESSAGES } from '../src/frame/frameMessages.ts';
import { attachTarget } from '../src/frame/mediaModel.ts';
import { buildItems, type PaletteContext } from '../src/frame/paletteModel.ts';
import { toUrl } from '../src/frame/searchInput.ts';
import { configureSite, type ShellSite } from '../src/frame/site.ts';
import { latestOnly } from '../src/frame/syncGate.ts';
import { captureWorkspace, planRestore } from '../src/frame/workspaceModel.ts';
import { normalizeProfiles } from '../src/profileModel.ts';
const here = dirname(fileURLToPath(import.meta.url));
const FRAME = join(here, '../src/frame');
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 frame uses has English, and every English key is used', () => {
const sources = files(FRAME).filter(file => /\.tsx?$/.test(file) && !file.endsWith('frameMessages.ts')).map(file => readFileSync(file, 'utf8')).join('\n');
const used = new Set([...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`);
// `wb.backup.section.<id>` is built at runtime (backupModel.sectionLabel).
for (const key of Object.keys(FRAME_MESSAGES)) assert.ok(used.has(key) || key.startsWith('wb.backup.section.'), `${key} is used`);
});
const site = {
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',
search: text => ({ id: 'search', group: 'Search', label: `Search for ${text}`, action: { type: 'open', url: `https://example.com/search?q=${text}` } }),
completions: text => (text.endsWith(':') ? [{ id: 'op:from:', group: 'Operators', label: 'from:', action: { type: 'complete', text: `${text}someone` } }] : []),
sections: [{ id: 'themes', label: () => 'Themes', keywords: 'colors', icon: 'palette' }],
items: () => [{ id: 'page:/home', group: 'Example', label: 'Home', keywords: 'feed', action: { type: 'open', url: 'https://example.com/home' } }],
},
deck: {
kinds: [{ kind: 'home', label: 'Home', icon: 'home', hint: '' }], urlFor: () => 'https://example.com/home', title: () => ({ title: 'Home' }),
parseValue: () => '', invalid: () => '', flags: [{ key: 'quiet', label: 'Quiet', hint: '' }], extraFlags: ['hidden'], defaultFlags: { quiet: true },
},
} satisfies ShellSite;
configureSite(site);
const context: PaletteContext = {
tabs: [{ id: 't1', title: 'Home', url: 'https://example.com/home' }],
profiles: [{ id: 'default', name: 'Main' }],
activeProfile: 'default',
decks: [],
};
const setup = { site: site.palette, product: site.product, decks: true, media: false };
test('palette: frame groups, then the app, plugins and the site; the literal search stays last', () => {
const empty = buildItems(context, '', setup);
assert.deepEqual([...new Set(empty.map(item => item.group))], ['Tabs', 'Deck', 'Workspaces', 'Profiles', 'wayBetter for Example', 'Example']);
assert.ok(!empty.some(item => item.id === 'media'), 'no media item without a media panel');
const typed = buildItems(context, 'hom', setup);
assert.equal(typed.at(-1)?.id, 'search');
assert.equal(typed.filter(item => item.id === 'search').length, 1);
assert.equal(buildItems(context, 'x from:', setup)[0]?.id, 'op:from:', 'completions come first');
assert.ok(!buildItems(context, '', { ...setup, decks: false }).some(item => item.group === 'Deck'));
});
test('address text: URLs, bare domains, else the site search', () => {
assert.equal(toUrl(' https://a.dev/x ', site.searchUrl), 'https://a.dev/x');
assert.equal(toUrl('a.dev/x', site.searchUrl), 'https://a.dev/x');
assert.equal(toUrl('two words', site.searchUrl), 'https://example.com/search?q=two%20words');
});
test('layout sync: one send in flight, only the newest waits', async () => {
const sent: number[] = [];
let release!: () => void;
const send = latestOnly<number>(async value => { sent.push(value); if (value === 1) await new Promise<void>(resolve => { release = resolve; }); });
const first = send(1);
void send(2);
const third = send(3);
release();
await Promise.all([first, third]);
assert.deepEqual(sent, [1, 3]);
});
test('boot script: paints only the shell webview, only --wb-* variables', () => {
const source = bootScript();
assert.doesNotThrow(() => new Function(source));
assert.ok(source.includes(JSON.stringify(BOOT_CACHE_KEY)));
assert.ok(source.includes("label !== \"shell\""));
assert.ok(bootScript({ label: 'main' }).includes("label !== \"main\""));
});
test('chrome variables: none for the own style, fonts only without colors, every name listed', () => {
assert.deepEqual(chromeVars({ colors: null }, true), {});
assert.deepEqual(chromeVars({ chromeFont: 'Inter', colors: null }), { '--wb-f-chrome': 'Inter' });
const vars = chromeVars({ colors: { bg: '#ffffff', surface: '#f4f4f4', elevated: '#eeeeee', border: '#dddddd', text: '#111111', muted: '#666666', accent: '#1d9bf0' } });
assert.equal(vars['--wb-c-scheme'], 'light');
for (const name of Object.keys(vars)) assert.ok(CHROME_VAR_NAMES.includes(name), name);
});
test('chrome variables: radii scale with the theme, a bevelled theme gets 98-style edges from its own face', () => {
const colors = { bg: '#ffffff', surface: '#f4f4f4', elevated: '#dfdfdf', border: '#808080', text: '#000000', muted: '#3c3c3c', accent: '#0000a8', page: '#c0c0c0', inverse: '#000080', onInverse: '#ffffff' };
assert.equal(chromeVars({ colors, radius: 0 })['--wb-c-rs'], '0');
assert.equal(chromeVars({ colors, radius: 12 })['--wb-c-rs'], '1');
assert.equal(chromeVars({ colors, radius: 40 })['--wb-c-rs'], '1.5');
// A theme that keeps the site's colors keeps wayBetter's own corners and edges.
assert.deepEqual(chromeVars({ colors: null, radius: 0, edges: 'bevel' }), {});
const flat = chromeVars({ colors, radius: 0 });
assert.equal(chromeEdges(flat), null);
assert.equal(flat['--wb-c-face'], undefined);
const bevel = chromeVars({ colors, radius: 0, edges: 'bevel' });
assert.equal(chromeEdges(bevel), 'bevel');
// 98.css's four edge lines, from the theme's silver.
assert.equal(bevel['--wb-c-face'], '#c0c0c0');
assert.equal(bevel['--wb-c-bevel-hi'], '#ffffff');
assert.equal(bevel['--wb-c-bevel-sh'], '#808080');
assert.equal(bevel['--wb-c-bevel-dk'], '#0a0a0a');
assert.equal(bevel['--wb-c-title-from'], '#000080');
assert.equal(bevel['--wb-c-title-text'], '#ffffff');
for (const name of Object.keys(bevel)) assert.ok(CHROME_VAR_NAMES.includes(name), name);
// The first frame paints the edges too.
assert.ok(bootScript().includes("root.dataset.wbEdges = 'bevel'"));
});
test('account switcher: a visible pane on the site first, else the feed or sign-in beside', () => {
const settings = normalizeProfiles({ active: 'default', profiles: [
{ id: 'default', name: 'Main', color: '#1d9bf0', account: { handle: 'me', name: 'Me', signedIn: true } },
{ id: 'pnew1', name: 'New', color: '#f91880' },
] }, site.accounts);
const panes: PaneInfo[] = [
{ id: 'a', kind: 'web', profile: 'default', url: 'https://other.dev/', visible: true },
{ id: 'b', kind: 'web', profile: 'default', url: 'https://example.com/home', visible: true },
];
const pages = { homeUrl: site.homeUrl, loginUrl: site.loginUrl, isSiteUrl: site.isSiteUrl };
assert.deepEqual(accountAction(settings, panes, null, 'default', pages), { type: 'focus', paneId: 'b' });
assert.deepEqual(accountAction(settings, panes, null, 'pnew1', pages), { type: 'open', url: site.loginUrl, profile: 'pnew1', where: 'beside' });
assert.equal(accountStartUrl(settings.profiles[0]!, pages), site.homeUrl);
assert.deepEqual([...tabsByAccount(settings, panes).keys()], ['default']);
assert.equal(attachTarget([{ id: 'a', url: 'https://other.dev/' }, { id: 'b', url: 'https://example.com/x' }], 'a', site.isSiteUrl)?.id, 'b');
});
test('workspaces: web tabs get fresh ids on restore, the app panel keeps its own', () => {
const layout = {
grid: { root: { type: 'branch', data: [{ type: 'leaf', data: { views: ['w1', 'app'], activeView: 'w1', id: '1' }, size: 100 }], size: 100 }, width: 100, height: 100, orientation: 'HORIZONTAL' },
panels: {
w1: { id: 'w1', contentComponent: 'web', params: { kind: 'web', url: 'https://example.com/home', loading: true } },
app: { id: 'app', contentComponent: 'panel', params: { kind: 'panel', section: 'themes', nonce: 4 } },
},
};
const workspace = captureWorkspace({ id: 'one', name: 'One', layout });
assert.deepEqual(workspace.layout.panels.w1?.params, { kind: 'web', url: 'https://example.com/home' });
const plan = planRestore(workspace, () => 'fresh');
assert.deepEqual(Object.keys(plan.layout.panels).sort(), ['app', 'fresh']);
});
test('deck settings: the site\'s default flags; unknown flags are dropped', () => {
assert.deepEqual(defaultDeckSettings().flags, { quiet: true });
assert.deepEqual(normalizeDeckSettings({ flags: { quiet: false, hidden: true, other: true } }).flags, { quiet: false, hidden: true });
});