storebound/apps/desktop/tests/resilience.test.ts
CRT bd3ab20e1d Desktop: never a blank window (frozen-prototype fix, error screens, logs, state migration, boot check)
The signed release showed only its background colour and never started the sidecar. Tauri's freezePrototype
freezes Object.prototype before the bundle runs; d3-color and d3-format (under the visx charts) assign
`constructor` / `toString` on plain objects while loading, which throws on a frozen prototype, so the bundle
stopped before React drew anything. The saved state was not the cause.

- build/freeze-safe.ts: Vite plugin that rewrites those assignments in dependencies into a helper that defines
  the property when the frozen prototype would refuse it
- error boundaries for the window, each screen and each app tab (Copy details, Reload, Reset saved state with a
  backup); public/boot.js shows the same panel when the bundle fails or the window stays empty
- logs/host.log (start, saved state, sidecar start/stop, panics) and logs/webview.log (log_webview), rotated,
  home as ~, keys, tokens and e-mail addresses replaced
- state.json `version` 2 with an explicit 1 -> 2 migration (original kept as state.json.v1.bak), per-entry
  tolerant parsing, partial settings keep their fields, unreadable files kept aside
- folders that are gone: "Folder not found" with Locate... (project_locate) / Remove; portfolio reads skip them
- tools/desktop-boot-check.ts: the built window with the release CSP and the frozen prototype, a mocked host from
  the state fixtures (empty, D1 with folders gone, D3) and the real sidecar, through every screen; in bun run ci,
  the Forgejo CI workflow and the macOS release
2026-10-11 09:36:34 +08:00

92 lines
5.3 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 { 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 }]);
});
});
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('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\}/);
});
});