import assert from 'node:assert/strict'; import test from 'node:test'; import { formatMessage } from '../../runtime/src/i18n.ts'; import type { IconPack } from '../../runtime/src/themeIcons.ts'; import { createInstallController, type StoreBackend, type ThemeAdapter } from '../src/installController.ts'; import { STORE_MESSAGES, type StoreText } from '../src/storeMessages.ts'; import { filterItems, itemState, kindForFile, MAX_FILE_BYTES, type InstallKind, type InstallPreview, type StoreItem } from '../src/storeModel.ts'; const t: StoreText = (key, params) => formatMessage(STORE_MESSAGES[key], params); const settle = () => new Promise(resolve => setTimeout(resolve, 0)); const THEME = '{"name":"Nord","id":"nord"}'; const ICONS = '{"id":"line-2","name":"Line 2","author":"me","license":"CC0-1.0","glyphs":{"home":{"d":"M3 3h18v18H3z"}}}'; type Theme = { id: string; name: string }; function harness(options: { pending?: unknown[]; withIcons?: boolean } = {}) { const calls: Array<[string, Record | undefined]> = []; const listeners = new Map void>(); const preview = (kind: InstallKind, text: string): InstallPreview => ({ token: `tok-${calls.length}`, kind, source: { kind: 'file', url: null, label: 'A file you dropped', signed: false }, sha256: 'f'.repeat(64), size: text.length, file: null, text, version: '', plugin: null, theme: null, findings: [], existing: null, store: null, icons: kind === 'icons' ? { id: 'line-2', name: 'Line 2', author: 'me', license: 'CC0-1.0', glyphs: 1 } : null, }); 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_opened') return preview(args!.id === 'icons-id' ? 'icons' : 'theme', args!.id === 'icons-id' ? ICONS : THEME) as T; if (command === 'install_stage_file') return preview(String(args!.name).endsWith('.icons.json') ? 'icons' : 'theme', String(args!.name).endsWith('.icons.json') ? ICONS : THEME) as T; if (command === 'install_commit') return { themeText: args!.themeId ? THEME : null, iconsText: args!.themeId ? null : ICONS } 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 savedThemes: string[] = []; 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 () => [], builtinThemes: ['x'], idOf: theme => theme.id, facts: theme => ({ name: theme.name }), save: async (theme, id, opts) => { savedThemes.push(id); return { enabled: opts.enable, fonts: [] }; }, }; const savedPacks: Array<[IconPack, { enable: boolean }]> = []; const controller = createInstallController({ backend, t, site: 'X', theme: themes, ...(options.withIcons === false ? {} : { icons: { save: async (pack: IconPack, opts: { enable: boolean }) => { savedPacks.push([pack, opts]); } } }), }); return { controller, calls, listeners, savedThemes, savedPacks }; } test('a file the OS handed over is staged by its id and installs only after the screen is confirmed', async () => { const h = harness(); const stop = h.controller.start(); await settle(); h.listeners.get('install-link')!({ opened: { id: 'abc123', name: 'nord.json', kind: 'theme' } }); await settle(); const stage = h.calls.find(c => c[0] === 'install_stage_opened')!; assert.deepEqual(stage[1], { id: 'abc123' }, 'only the id goes to native code; the shell has no path'); assert.equal(h.controller.current()?.status, 'ready', 'the same install screen as a dropped file'); assert.ok(!h.calls.some(c => c[0] === 'install_commit') && h.savedThemes.length === 0, 'nothing is written before Install'); await h.controller.confirm(true); assert.deepEqual(h.savedThemes, ['nord']); assert.equal(h.controller.current()?.status, 'done'); stop(); }); test('a file opened before the shell listened is shown from the pending queue', async () => { const h = harness({ pending: [{ opened: { id: 'abc123', name: 'nord.json', kind: 'theme' } }] }); const stop = h.controller.start(); await settle(); assert.equal(h.calls.filter(c => c[0] === 'install_stage_opened').length, 1); assert.equal(h.controller.current()?.status, 'ready'); stop(); }); test('a file the OS handed over that is refused says why, in the words for a file', async () => { const h = harness(); const stop = h.controller.start(); await settle(); h.listeners.get('install-link')!({ error: 'FILE: only a theme (.json), a plugin (.plugin.js), a string pack (.strings.json) or an icon pack (.icons.json) can be opened here' }); await settle(); const state = h.controller.current(); assert.equal(state?.status, 'error'); assert.deepEqual(state && state.status === 'error' ? [state.title, state.message.startsWith('only a theme')] : null, ['This file cannot be installed', true]); assert.ok(!h.calls.some(c => c[0] === 'install_stage_opened')); stop(); }); test('icon packs: told apart from themes by name, staged like any file, parsed again from the committed bytes', async () => { assert.equal(kindForFile('line-2.icons.json'), 'icons'); assert.equal(kindForFile('nord.json'), 'theme'); assert.equal(MAX_FILE_BYTES.icons, 120_000); const h = harness(); const stop = h.controller.start(); await settle(); await h.controller.requestFile({ name: 'line-2.icons.json', size: ICONS.length, text: async () => ICONS }); const ready = h.controller.current(); assert.equal(ready?.status, 'ready'); assert.equal(ready?.status === 'ready' ? ready.pack?.name : null, 'Line 2', 'the screen can draw the pack'); assert.ok(!h.calls.some(c => c[0] === 'install_commit') && h.savedPacks.length === 0, 'nothing is written before Install'); 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.deepEqual(h.savedPacks.map(([pack, opts]) => [pack.id, Object.keys(pack.glyphs), opts.enable]), [['line-2', ['home'], true]]); assert.deepEqual(h.controller.current(), { status: 'done', kind: 'icons', name: 'Line 2', fonts: [], enabled: true }); assert.equal(h.savedThemes.length, 0, 'an icon pack is not a theme'); stop(); }); test('icon packs: too large, not installable without the app\'s support,', async () => { const h = harness(); await h.controller.requestFile({ name: 'big.icons.json', size: MAX_FILE_BYTES.icons + 1, text: async () => ICONS }); const state = h.controller.current(); assert.equal(state?.status === 'error' ? state.message : '', 'Icon packs are limited to 120 KB.'); const bare = harness({ withIcons: false }); await bare.controller.requestFile({ name: 'line-2.icons.json', size: ICONS.length, text: async () => ICONS }); assert.equal(bare.controller.current()?.status, 'error'); assert.ok(!bare.calls.some(c => c[0] === 'install_commit')); }); const item = (patch: Partial): StoreItem => ({ apps: ['x'], kind: 'icons', id: 'line-2', name: 'Line 2', author: 'me', version: '1.1.0', description: 'Thin lines', category: 'icons', tags: [], url: 'https://waybetter.fyi/store/icons/line-2.icons.json', sha256: 'a'.repeat(64), size: 100, ...patch, }); test('the Store: icon packs follow their install records and the packs the owner still has; built-in items are areas', () => { const base = { plugins: {}, records: {}, builtinThemes: [], userThemes: [] as string[] }; assert.equal(itemState(item({}), base).state, 'install'); const records = { 'icons:line-2-copy': { kind: 'icons' as const, source: 'store' as const, storeId: 'line-2', version: '1.0.0', sha256: 'b'.repeat(64), installedAt: '' } }; assert.equal(itemState(item({}), { ...base, records }).state, 'update', 'no list of packs: the records decide'); assert.equal(itemState(item({}), { ...base, records, userIcons: ['line-2-copy'] }).state, 'update'); assert.equal(itemState(item({}), { ...base, records, userIcons: [] }).state, 'install', 'a pack the owner removed in Appearance is not installed'); assert.equal(itemState(item({ sha256: 'b'.repeat(64) }), { ...base, records, userIcons: ['line-2-copy'] }).state, 'installed'); const area = item({ kind: 'builtin', id: 'compose', name: 'Compose+', url: undefined, sha256: undefined, size: undefined, category: 'writing' }); assert.equal(itemState(area, base).state, 'area'); const all = [item({}), area]; assert.deepEqual(filterItems(all, '', 'builtin', 'all').map(i => i.id), ['compose']); assert.deepEqual(filterItems(all, '', 'icons', 'all').map(i => i.id), ['line-2']); });