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.
161 lines
11 KiB
TypeScript
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'));
|
|
});
|