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 => ({ 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(); 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 | undefined]> = []; const listeners = new Map void>(); const preview = (kind: 'plugin' | 'theme', patch: Partial = {}): 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(command: string, args?: Record) { 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(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 = { 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')); });