waybetter-for-x/apps/desktop/tests/install.test.ts
2026-10-02 02:54:04 +07:00

144 lines
10 KiB
TypeScript

// Distribution features: plugin permissions on X's API, accurate headers on every bundled plugin,
// the install screen's helpers as the app binds them (framework storeModel), the store catalog on
// disk (X's part of the shared catalog), and the signing script round trip (framework CLI).
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { mkdtempSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import test from 'node:test';
import { createPluginContext, parsePermissions, PluginPermissionError } from '../../../waybetter/packages/runtime/src/permissions.ts';
import { createPluginApi, X_PERMISSION_GATES } from '../src/inject/pluginApi.ts';
import { supersededFiles } from '../../../waybetter/packages/runtime/src/featureTable.ts';
import { X_TABLE } from '../src/areas/table.ts';
import { filterItems, freeThemeId, installedThemeId, itemState, kindForFile, newerVersion, permissionBadges, previewLines, type StoreItem } from '../src/shell/installModel.ts';
const ROOT = new URL('../../../', import.meta.url);
const PLUGINS = new URL('../src-tauri/bundled-plugins/', import.meta.url);
const fakeWindow = { document: { documentElement: {} }, location: { href: 'https://x.com/home' } } as unknown as Window & typeof globalThis;
function header(source: string): string[] | null {
const body = /^\s*\/\*\*([\s\S]*?)\*\//.exec(source)?.[1] ?? '';
const line = body.split(/\r?\n/).map(l => l.trim().replace(/^\*+/, '').trim()).find(l => l.startsWith('@permissions'));
if (!line) return null;
const parsed = parsePermissions(line.slice('@permissions'.length));
assert.ok('permissions' in parsed, `invalid @permissions: ${line}`);
return (parsed as { permissions: string[] }).permissions;
}
/** Gated API members a plugin's source uses (the same members X_PERMISSION_GATES lists). */
function used(source: string): string[] {
const out = new Set<string>();
if (/\.storage\(/.test(source)) out.add('storage');
if (/\.ui\b|\.toast\(/.test(source)) out.add('ui');
if (/\.copyText\(/.test(source)) out.add('clipboard');
if (/\.account\(/.test(source)) out.add('account');
if (/\.users\./.test(source)) out.add('users');
if (/\.commands\./.test(source)) out.add('commands');
return [...out].sort();
}
test("X's plugin API gates account() and users on top of the core's storage/ui/clipboard", () => {
assert.deepEqual({ ...X_PERMISSION_GATES }, { storage: 'storage', ui: 'ui', toast: 'ui', copyText: 'clipboard', commands: 'commands', account: 'account', users: 'users' });
const full = createPluginApi(fakeWindow);
const context = createPluginContext(full, X_PERMISSION_GATES);
const view = context.run('notes.plugin.js', ['storage'], () => context.current());
assert.throws(() => view.account(), (error: unknown) => error instanceof PluginPermissionError && /notes\.plugin\.js: api\.account needs the "account" permission/.test(error.message));
assert.throws(() => view.users.get('someone'), /api\.users\.get needs the "users" permission/);
assert.throws(() => view.copyText('x'), /"clipboard"/);
assert.equal(typeof view.tweet, 'function', 'reading a rendered post needs no permission');
// A legacy plugin (no header line) keeps the full API.
const legacy = context.run('old.plugin.js', null, () => context.current());
assert.equal(legacy, full);
});
test('every bundled plugin declares exactly the gated API it uses, plus network:none', () => {
const files = readdirSync(PLUGINS).filter(file => file.endsWith('.plugin.js'));
assert.ok(files.length >= 14);
for (const file of files) {
const source = readFileSync(new URL(file, PLUGINS), 'utf8');
const declared = header(source);
// The Emoji Picker is being redesigned in parallel; its header is added with that work.
if (declared === null && file === 'emoji-picker.plugin.js') continue;
assert.ok(declared, `${file} has @permissions`);
assert.deepEqual(declared!.filter(p => !p.startsWith('network')).sort(), used(source), `${file}: declared vs used`);
assert.ok(declared!.includes('network:none'), `${file} promises no network`);
assert.doesNotMatch(source, /\bfetch\(|XMLHttpRequest|WebSocket|sendBeacon|EventSource/, `${file} really makes no requests`);
}
});
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');
const theme = item({ kind: 'theme', id: 'nord', file: undefined, permissions: undefined });
assert.equal(itemState(theme, 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, 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)[0]!.label, 'Legacy · full page access');
assert.equal(permissionBadges([])[0]!.id, 'none');
assert.deepEqual(permissionBadges(['ui', 'network:none']).map(b => b.tone), ['plain', 'ok']);
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('the committed store catalog lists every bundled plugin with its header version and permissions', () => {
const catalog = JSON.parse(readFileSync(new URL('apps/web/public/store/catalog.json', ROOT), 'utf8')) as { format: number; items: StoreItem[] };
assert.equal(catalog.format, 2);
const mine = catalog.items.filter(entry => entry.apps?.includes('x'));
assert.ok(mine.length > 0, "X's items are in the shared catalog");
const plugins = mine.filter(entry => entry.kind === 'plugin');
// A bundled file a built-in feature replaced stays in the app for the one-time move of an install,
// and is not a Store item any more (its area has a page on the site instead).
const superseded = new Set(supersededFiles(X_TABLE).map(entry => entry.file));
const bundled = readdirSync(PLUGINS).filter(file => file.endsWith('.plugin.js') && !superseded.has(`bt-${file}`));
assert.ok(bundled.length > 0 && superseded.size === 13, 'the Emoji Picker stays a plugin; the other thirteen are features');
assert.deepEqual(plugins.map(entry => entry.file).sort(), bundled.map(file => `bt-${file}`).sort(), 'every bundled plugin that is not a feature is in the store');
for (const entry of plugins) {
assert.match(entry.file!, /^bt-[a-z-]+\.plugin\.js$/, 'installed under the bundled file name');
assert.ok(entry.url?.startsWith('https://waybetter.fyi/store/'));
const copy = readFileSync(new URL(`apps/web/public/store/plugins/${entry.file}`, ROOT));
assert.equal(copy.length, entry.size);
}
assert.ok(mine.some(entry => entry.kind === 'theme' && entry.id === 'nord'));
assert.ok(!mine.some(entry => entry.kind === 'theme' && entry.id === 'x'), "X's own look is not in the store");
});
test('sign-store.mjs: a fresh key signs, the signature verifies, a changed byte does not', () => {
const dir = mkdtempSync(join(tmpdir(), 'wb-store-'));
const key = join(dir, 'test-store.key');
const pub = join(dir, 'store.pub');
const catalog = join(dir, 'catalog.json');
writeFileSync(catalog, JSON.stringify({ format: 2, name: 'Test', generatedAt: '2026-09-28T00:00:00Z', items: [] }));
const script = new URL('scripts/sign-store.mjs', ROOT).pathname.replace(/^\/([A-Za-z]:)/, '$1');
const run = (...args: string[]) => execFileSync(process.execPath, [script, ...args], { encoding: 'utf8' });
assert.match(run('--init', '--key', key, '--pub', pub), /Created the store key/);
assert.doesNotMatch(run('--init', '--key', key, '--pub', pub), /PRIVATE/, 'never prints key material');
assert.match(run(catalog, '--key', key, '--pub', pub), /Signed and verified/);
assert.equal(Buffer.from(readFileSync(`${catalog}.sig`, 'utf8').trim(), 'base64').length, 64);
assert.equal(Buffer.from(readFileSync(pub, 'utf8').trim(), 'base64').length, 32);
writeFileSync(catalog, readFileSync(catalog, 'utf8').replace('Test', 'Tesu'));
assert.throws(() => execFileSync(process.execPath, [script, '--verify', catalog, '--pub', pub], { stdio: 'pipe' }), /does NOT verify|Command failed/);
});