waybetter/packages/shell/tests/store.test.ts
CRT dc449de9c2 shell: the Store view and install screen as framework React components, strings in the framework catalogs
Moved from wayBetter for X so every wayBetter app gets the Store UI; the app passes its native
bridge, theme adapter, translator and (optionally) its own controls and button classes.

- storeModel.ts: wire types of the native store commands (InstallPreview, StoreCatalogView,
  LinkEvent, StoreItem with `apps`), permission badges in the site's words, categories, item state
  (install/installed/update/changed locally/built in), search, free theme ids, versions, previews.
- installController.ts: the install screen's state machine (links incl. those queued before the
  shell listened, dropped files, store items, cancel, confirm), generic over the app's theme format
  (`ThemeAdapter`: parse, ids, facts, save) and its bridge (`StoreBackend`: invoke/listen).
- InstallDialog.tsx (`InstallHost`): the screen (Install enabled after 1 s, Enter never installs,
  tabs hidden via setOverlay) and the window-wide drop target. StoreView.tsx: search, kinds,
  categories, cards, permission badges, install/update/remove; default controls or the app's.
- store.css: both, themed with framework `--wb-*` tokens (documented; dark defaults).
- storeMessages.ts + every framework catalog: 120 `wb.store.*`, `wb.install.*`, `wb.permission.*`
  messages moved from the app catalogs with all existing translations; the six that named X take
  `{site}`; generic words the components need copied with their translations.
- npm package: exports for the new modules and store.css, optional react/react-dom peers; @types
  for the typecheck. Tests: models, controller flows with a fake bridge, every looked-up key exists.
2026-09-29 11:08:04 +07:00

161 lines
11 KiB
TypeScript

import assert from 'node:assert/strict';
import { readFileSync, readdirSync } from 'node:fs';
import test from 'node:test';
import { formatMessage } from '../../runtime/src/i18n.ts';
import { createInstallController, type StoreBackend, type ThemeAdapter } from '../src/installController.ts';
import { STORE_MESSAGES, type StoreText } from '../src/storeMessages.ts';
import {
categoryLabel, filterItems, freeThemeId, installedThemeId, itemState, kindForFile, newerVersion, permissionBadges, previewLines,
type InstallPreview, type StoreItem,
} from '../src/storeModel.ts';
const t: StoreText = (key, params) => formatMessage(STORE_MESSAGES[key], params);
const item = (patch: Partial<StoreItem>): StoreItem => ({
apps: ['x'], kind: 'plugin', id: 'tool', name: 'Tool', author: 'me', version: '1.1.0', description: 'Does things', category: 'tools', tags: ['x'],
url: 'https://waybetter.fyi/store/plugins/tool.plugin.js', file: 'tool.plugin.js', sha256: 'a'.repeat(64), size: 10, permissions: ['ui'], ...patch,
});
test('store item state: install, installed, update, changed locally, built in', () => {
const base = { plugins: {}, records: {}, builtinThemes: ['nord'], userThemes: [] as string[] };
assert.equal(itemState(item({}), base).state, 'install');
assert.equal(itemState(item({}), { ...base, plugins: { 'tool.plugin.js': { version: '1.1.0', sha256: 'a'.repeat(64), enabled: true } } }).state, 'installed');
assert.equal(itemState(item({}), { ...base, plugins: { 'tool.plugin.js': { version: '1.0.0', sha256: 'b'.repeat(64), enabled: false } } }).state, 'update');
assert.equal(itemState(item({}), { ...base, plugins: { 'tool.plugin.js': { version: '1.1.0', sha256: 'b'.repeat(64), enabled: false } } }).state, 'modified');
assert.equal(itemState(item({ kind: 'theme', id: 'nord', file: undefined, permissions: undefined }), base).state, 'builtin');
const own = item({ kind: 'theme', id: 'sunset', file: undefined, version: '1.2.0' });
const records = { 'theme:sunset-2': { kind: 'theme' as const, source: 'store' as const, storeId: 'sunset', version: '1.0.0', sha256: 'c'.repeat(64), installedAt: '' } };
assert.equal(itemState(own, { ...base, records, userThemes: ['sunset-2'] }).state, 'update');
assert.equal(itemState(own, { ...base, records, userThemes: [] }).state, 'install', 'a theme the owner deleted is not installed');
assert.equal(installedThemeId(own, records, ['sunset-2']), 'sunset-2');
});
test('install helpers: file kinds, theme ids, versions, badges in the site\'s words, categories, search, previews', () => {
assert.equal(kindForFile('Focus.plugin.js'), 'plugin');
assert.equal(kindForFile('nord.bttheme.JSON'), 'theme');
assert.equal(kindForFile('photo.png'), null);
assert.equal(freeThemeId('nord', ['x', 'paper']), 'nord');
assert.equal(freeThemeId('nord', ['nord', 'nord-2']), 'nord-3');
assert.ok(newerVersion('1.10.0', '1.9.0') && !newerVersion('1.0.0', '1.0.0') && !newerVersion('0.9', '1.0'));
assert.equal(permissionBadges(null, t, 'X')[0]!.label, 'Legacy · full page access');
assert.equal(permissionBadges([], t, 'X')[0]!.id, 'none');
assert.deepEqual(permissionBadges(['ui', 'network:none', 'root'], t, 'X').map(b => [b.label, b.tone]), [['Dialogs', 'plain'], ['No network', 'ok'], ['root', 'warn']]);
assert.match(permissionBadges(['account'], t, 'Kick')[0]!.detail, /which Kick account/);
assert.equal(categoryLabel('reading', t), 'Reading');
assert.equal(categoryLabel('new-one', t), 'new-one', 'unknown categories show their id');
const items = [item({}), item({ id: 'nord', kind: 'theme', name: 'Nord', category: 'dark', description: 'Arctic', permissions: undefined })];
assert.deepEqual(filterItems(items, 'arctic', 'all', 'all').map(i => i.id), ['nord']);
assert.deepEqual(filterItems(items, '', 'plugin', 'all').map(i => i.id), ['tool']);
assert.deepEqual(filterItems(items, 'ui', 'all', 'tools').map(i => i.id), ['tool'], 'permissions are searchable');
assert.deepEqual(previewLines('a\r\nb\nc', 2), { lines: ['a', 'b'], hidden: 1 });
});
test('every message key the store UI looks up exists, and {site} strings take the site', () => {
const dir = new URL('../src/', import.meta.url);
const used = new Set<string>();
for (const file of readdirSync(dir).filter(name => /^(InstallDialog|StoreView|installController|storeModel)\.tsx?$/.test(name))) {
for (const match of readFileSync(new URL(file, dir), 'utf8').matchAll(/\bt\(\s*'(wb\.[\w.]+)'/g)) used.add(match[1]!);
}
assert.ok(used.size > 60, `${used.size} keys`);
for (const key of used) assert.ok(key in STORE_MESSAGES, `${key} is in STORE_MESSAGES`);
for (const [key, value] of Object.entries(STORE_MESSAGES)) {
if (value.includes('{site}')) assert.doesNotMatch(value, /\bX\b/, `${key} names the site through {site} only`);
}
assert.equal(t('wb.install.pageAccess', { site: 'Kick' }).match(/Kick/g)?.length, 2);
});
type Theme = { id: string; name: string };
function harness(options: { pending?: unknown[]; theme?: string } = {}) {
const calls: Array<[string, Record<string, unknown> | undefined]> = [];
const listeners = new Map<string, (payload: unknown) => void>();
const preview = (kind: 'plugin' | 'theme', patch: Partial<InstallPreview> = {}): InstallPreview => ({
token: `tok-${calls.length}`, kind, source: { kind: 'link', url: 'https://waybetter.fyi/a', label: 'Link: waybetter.fyi', signed: false },
sha256: 'f'.repeat(64), size: 3, file: kind === 'plugin' ? 'a.plugin.js' : null, text: options.theme ?? '{"name":"Nord","id":"nord"}', version: '1.0.0',
plugin: kind === 'plugin' ? { file: 'a.plugin.js', name: 'A', description: '', version: '1.0.0', author: '', enabled: false, permissions: ['ui'] } : null,
theme: null, findings: [], existing: null, store: null, ...patch,
});
const backend: StoreBackend = {
async invoke<T>(command: string, args?: Record<string, unknown>) {
calls.push([command, args]);
if (command === 'install_records') return { items: {} } as T;
if (command === 'install_pending') return (options.pending ?? []) as T;
if (command === 'install_stage_link') return preview(args!.kind as 'plugin' | 'theme') as T;
if (command === 'install_stage_file') return preview(String(args!.name).endsWith('.json') ? 'theme' : 'plugin') as T;
if (command === 'install_commit') return { themeText: args!.themeId ? options.theme ?? '{"name":"Nord","id":"nord"}' : null } as T;
return undefined as T;
},
async listen<T>(event: string, handler: (payload: T) => void) {
listeners.set(event, handler as (payload: unknown) => void);
return () => { listeners.delete(event); };
},
};
const saved: Array<[Theme, string, { replaces: string | null; enable: boolean }]> = [];
const themes: ThemeAdapter<Theme> = {
parse: text => { try { const value = JSON.parse(text) as Theme; return value.name ? { theme: value, warnings: [] } : { error: 'no name' }; } catch { return { error: 'not JSON' }; } },
userThemes: async () => ['nord'],
builtinThemes: ['x', 'paper'],
idOf: theme => theme.id,
facts: theme => ({ name: theme.name }),
save: async (theme, id, opts) => { saved.push([theme, id, opts]); return { enabled: opts.enable, fonts: [] }; },
};
let pluginsChanged = 0;
const controller = createInstallController({ backend, t, site: 'X', theme: themes, onPluginsChanged: () => { pluginsChanged += 1; } });
return { controller, calls, listeners, saved, pluginsChanged: () => pluginsChanged };
}
const settle = () => new Promise(resolve => setTimeout(resolve, 0));
test('install controller: a link is staged natively, shown, and committed only on confirm', async () => {
const h = harness();
const stop = h.controller.start();
await settle();
assert.deepEqual(h.calls.map(c => c[0]), ['install_pending']);
h.listeners.get('install-link')!({ link: { kind: 'plugin', url: 'https://waybetter.fyi/store/plugins/a.plugin.js' } });
await settle();
const ready = h.controller.current();
assert.equal(ready?.status, 'ready');
assert.ok(!h.calls.some(c => c[0] === 'install_commit'), 'nothing is written before confirm');
await h.controller.confirm(true);
assert.deepEqual(h.calls.find(c => c[0] === 'install_commit')![1], { token: (ready as { preview: InstallPreview }).preview.token, enable: true, themeId: null });
assert.equal(h.controller.current()?.status, 'done');
assert.equal(h.pluginsChanged(), 1);
stop();
await settle();
assert.equal(h.listeners.size, 0);
});
test('install controller: dropped themes get a free id, are parsed again from what native code wrote, and saved by the app', async () => {
const h = harness();
await h.controller.requestFile({ name: 'photo.png', size: 1, text: async () => '' });
assert.equal(h.controller.current()?.status, 'error');
assert.match((h.controller.current() as { message: string }).message, /X’s composer/, 'the site name is filled in');
await h.controller.requestFile({ name: 'big.plugin.js', size: 3 * 1024 * 1024, text: async () => '' });
assert.match((h.controller.current() as { title: string }).title, /too large/);
await h.controller.requestFile({ name: 'nord.json', size: 10, text: async () => '{"name":"Nord","id":"nord"}' });
assert.equal(h.controller.current()?.status, 'ready');
await h.controller.confirm(false);
assert.deepEqual(h.saved.map(([theme, id, opts]) => [theme.name, id, opts]), [['Nord', 'nord-2', { replaces: null, enable: false }]]);
assert.equal(h.controller.current()?.status, 'done');
});
test('install controller: cancel drops the staged install; a bad theme is an error; a rejected link says why', async () => {
const h = harness({ theme: '{"colors":{}}' });
await h.controller.requestFile({ name: 'bad.json', size: 10, text: async () => '{}' });
assert.equal(h.controller.current()?.status, 'error');
await h.controller.requestLink({ kind: 'plugin', url: 'https://waybetter.fyi/a.plugin.js' });
const token = (h.controller.current() as { preview: InstallPreview }).preview.token;
await h.controller.cancel();
assert.equal(h.controller.current(), null);
assert.deepEqual(h.calls.at(-1), ['install_cancel', { token }]);
h.controller.linkRejected('LINK: only https links');
assert.deepEqual(h.controller.current(), { status: 'error', title: 'This install link cannot be used', message: 'only https links' });
});
test('install controller: the newest link that arrived before the shell listened is shown', async () => {
const h = harness({ pending: [{ error: 'LINK: old' }, { link: { kind: 'theme', url: 'https://waybetter.fyi/store/themes/nord.json' } }] });
h.controller.start();
await settle();
await settle();
assert.equal(h.controller.current()?.status, 'ready');
assert.ok(h.calls.some(c => c[0] === 'install_stage_link'));
});