storebound/apps/desktop/tests/resilience.test.ts
CRT d2ce185441 One product per machine: the CLI and the desktop app share one version, the store accounts and the app folder list
- one version: package.json's version is the desktop app's too (desktopVersion removed); tools/desktop-version.ts
  keeps the plugin manifests and the app's copies equal, desktop-release publish refuses another version, release
  order app first then npm publish; the unpublished desktop 0.1.0 candidate is noted in the docs and CHANGELOG
- storebound desktop installs (per user, no admin: macOS DMG -> /Applications or ~/Applications, Windows NSIS /S,
  Linux AppImage + .desktop with the storebound:// handler), updates an older app, notes a newer one (downgrade only
  with --force), asks once ([Y/n], --yes, exit 3 without a terminal), opens on the folder; --dry / --json / --download;
  the install steps are pure plans (cli/lib/desktop-install.ts); the version's own manifest dl/<version>/latest.json
- credentials: one store, the CLI's credential profiles in ~/.storebound/credentials; the desktop writes and reads
  them (profiles.rs, files 0600), moves old keychain accounts into profiles on first start, shows profiles the CLI
  made; precedence env > named profile > project file > default profile; config `account`, "account" in the
  project's credentials.json; storebound accounts list | import (dedup by key id / service account e-mail, key files
  copied 0600, project file keeps keystore and review contact, backup, --dry, never prints values) | use
- app folders: one list ~/.storebound/projects.json shared by the desktop (registry.rs), storebound desktop,
  apps folders | add | remove, ws init and ws without a workspace file
- desktop: notice when a folder pins another storebound version; storebound-desktop --version
- tests: version drift, desktop decisions and per-OS plans, credential precedence and import, registry (TS + Rust),
  keychain migration and profile round trip (Rust), boot check with old keychain state and the new shared state
2026-10-13 06:17:37 +08:00

105 lines
6.4 KiB
TypeScript

// The window must never be blank: the bundle loads under Tauri's frozen Object.prototype (build/freeze-safe.ts), host
// data of any age is made complete before render (src/lib/normalize.ts), errors are reported with the home folder as
// ~ (src/lib/crash.ts), and the pieces that show an error panel are wired in.
import { describe, expect, test } from 'bun:test';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { runInNewContext } from 'node:vm';
import { parseAst } from 'vite';
import { rewrite } from '../build/freeze-safe';
import { DEFAULT_SETTINGS } from '../src/api';
import { ignoredRejection } from '../src/lib/crash';
import { NO_OVERRIDES } from '../src/lib/identity';
import { normalizeAccounts, normalizeProjects, normalizeSettings, normalizeWorkspace } from '../src/lib/normalize';
const ROOT = join(import.meta.dir, '..');
/** Runs `code` (which defines main()) in a fresh realm, with that realm's Object.prototype frozen as Tauri does. */
const run = (code: string, freeze: boolean, strict: boolean): unknown =>
JSON.parse(runInNewContext(`${freeze ? 'Object.freeze(Object.prototype);' : ''}(function () { ${strict ? "'use strict';" : ''}\n${code}\nreturn JSON.stringify(main()); })()`) as string);
describe('freeze-safe', () => {
// d3-color's define() and d3-format's FormatSpecifier, as they ship
const d3 = `
function main() {
function define(constructor, factory, prototype) { constructor.prototype = factory.prototype = prototype; prototype.constructor = constructor; }
function Color() {} function color() {}
define(Color, color, { copy() { return 1; } });
function Spec() { this.x = 1; }
Spec.prototype.toString = function () { return 'spec'; };
return [new Color().constructor === Color, String(new Spec()), Object.keys(Color.prototype).includes('constructor')];
}`;
test('the original code throws under a frozen Object.prototype; the rewritten code does not and behaves the same', () => {
const fixed = rewrite(d3, c => parseAst(c))!;
expect(fixed).not.toBeNull();
const plain = run(d3, false, true);
expect(plain).toEqual([true, 'spec', true]);
expect(() => run(d3, true, true)).toThrow(/read ?only property/) // JavaScriptCore (WKWebView) and V8 word it differently;
expect(run(fixed, true, true)).toEqual(plain);
expect(run(fixed, true, false)).toEqual(plain);
expect(run(fixed, false, true)).toEqual(plain);
});
test('only member assignments of Object.prototype names are touched; nested ones too', () => {
expect(rewrite('a.b = 1; a.toStringX = 2; a.toString == 3;', c => parseAst(c))).toBeNull();
const out = rewrite('"use strict"; x.valueOf = y.toString = f;', c => parseAst(c))!;
expect(out.startsWith('"use strict";')).toBe(true);
expect(out).toContain('__storeboundFreezeSafeSet(x, "valueOf", __storeboundFreezeSafeSet(y, "toString", f))');
});
test('the Vite build uses it', () => {
expect(readFileSync(join(ROOT, 'vite.config.ts'), 'utf8')).toMatch(/plugins: \[freezeSafe\(\)/);
});
});
describe('host data of any age', () => {
test('an empty or broken workspace gives complete defaults', () => {
for (const w of [undefined, null, {}, { studios: 'x', overrides: [], settings: 3 }]) {
expect(normalizeWorkspace(w)).toEqual({ studios: [], overrides: NO_OVERRIDES, settings: DEFAULT_SETTINGS });
}
});
test('partial overrides and settings keep what is there', () => {
const w = normalizeWorkspace({ overrides: { ignored: ['project:a', 5] }, settings: { refresh: { autoMinutes: 15 }, channel: 'beta' } });
expect(w.overrides).toEqual({ ...NO_OVERRIDES, ignored: ['project:a'] });
expect(w.settings.refresh).toEqual({ ...DEFAULT_SETTINGS.refresh, autoMinutes: 15 });
expect(w.settings.channel).toBe('beta');
expect(normalizeSettings({ cache: { keepDays: 'x' } }).cache.keepDays).toBe(30);
});
test('projects: missing folders flagged, bad entries dropped, names filled', () => {
const p = normalizeProjects([
{ id: 'a', path: '/gone/demo-game', name: 'Prism Puddle', playAccount: null, appleAccount: null, added: '2026-10-11T00:00:00Z', missing: true },
{ id: 'b', path: '/x/Tidy' },
{ id: 'a', path: '/dup' },
{ path: '/no-id' },
'junk',
]);
expect(p.map(x => [x.id, x.name, x.missing])).toEqual([['a', 'Prism Puddle', true], ['b', 'Tidy', false]]);
expect(normalizeProjects(undefined)).toEqual([]);
expect(normalizeAccounts([{ id: 'x', kind: 'play' }, { id: 'y', kind: 'fax' }])).toEqual([{ id: 'x', label: 'x', kind: 'play', created: '', studio: null, profile: null }]);
// 0.8: the credential profile shared with the CLI; a folder pinning another storebound version
expect(normalizeAccounts([{ id: 'z', kind: 'apple', profile: 'default' }])[0]!.profile).toBe('default');
expect(normalizeProjects([{ id: 'p', path: '/w/a', storeboundPin: '0.6.0' }])[0]!.storeboundPin).toBe('0.6.0');
});
});
describe('error screens are wired in', () => {
const src = (f: string) => readFileSync(join(ROOT, f), 'utf8');
test('boot.js loads before the bundle and React marks the start', () => {
const html = src('index.html');
expect(html.indexOf('/boot.js')).toBeGreaterThan(0);
expect(html.indexOf('/boot.js')).toBeLessThan(html.indexOf('/src/main.tsx'));
expect(src('public/boot.js')).toContain("'log_webview'");
expect(src('src/main.tsx')).toContain("dataset.booted = '1'");
expect(src('src/main.tsx')).toContain('<ErrorBoundary where="app" full>');
});
test("the notification plugin's refused permission probe is not reported (boot.js and crash.ts)", () => {
// seen on Linux in CI (Xvfb): the plugin's init script calls is_permission_granted, which the capability refuses
const probe = 'Command plugin:notification|is_permission_granted not allowed by ACL';
expect(ignoredRejection(probe)).toBe(true);
expect(ignoredRejection(new Error(probe))).toBe(true);
expect(ignoredRejection('Command plugin:notification|notify not allowed by ACL')).toBe(false);
expect(ignoredRejection(new Error('sidecar did not start'))).toBe(false);
expect(src('public/boot.js')).toContain('/plugin:notification\\|is_permission_granted not allowed/.test(text(e))');
});
test('every screen and app tab has its own boundary', () => {
expect(src('src/App.tsx')).toMatch(/<ErrorBoundary key=\{route\.screen === 'app'/);
expect(src('src/screens/AppView.tsx')).toMatch(/<ErrorBoundary key=\{tab\}/);
});
});