waybetter/packages/shell/tests/openWith.test.ts
CRT 7091bca25d Store: Open With (files the OS hands over) and the icons and builtin catalog kinds
Open With: store_commands keeps each existing file the OS hands over (launch
argument, a second launch forwarded by single-instance with its working
directory, a macOS file:// open-URL) under a random id (Store::receive_file:
name says what it is, regular file, size cap, 8 kept, 15 minutes) and queues
LinkEvent.opened {id, name, kind}. The shell-only install_stage_opened {id}
reads that one file once and stages it exactly like a dropped file; it takes
an id only (testkit asserts that), so the shell cannot name a path. The shell
shows the same install screen; nothing installs without it. The NSIS template
lists the app under "Open with" for the declared extensions (OpenWithProgids)
and never makes it the default program, unlike Tauri's own macro.

Catalog kinds: icons (an icon pack, *.icons.json, at most 120 KB; validate_icons,
staged and committed as text for the shell, the install screen draws every icon,
.icons.json is no longer read as a theme) and builtin (a feature the app
already has, such as an area plugin: no url, sha256, size or file; never staged;
the Store view lists it as Built in with the app's own switch via the areas
prop). Rust: Kind, CatalogItem.check, verify_item, install links refuse builtin.
JS: storeModel, installController (requestOpened, icons adapter), InstallDialog,
StoreView, StoreSection (remove an icon pack, area switches), words. CLI and
JSON Schema: kinds, optional url/sha256/size on builtin, icons size cap; the
test schema validator learns not and if/then/else. docs/STORE.md.
2026-10-01 14:23:23 +07:00

144 lines
8.9 KiB
TypeScript

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<string, unknown> | undefined]> = [];
const listeners = new Map<string, (payload: unknown) => 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<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_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<T>(event: string, handler: (payload: T) => void) {
listeners.set(event, handler as (payload: unknown) => void);
return () => { listeners.delete(event); };
},
};
const savedThemes: string[] = [];
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 () => [],
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>): 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']);
});