storebound/tools/desktop-boot-check.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

295 lines
17 KiB
TypeScript

#!/usr/bin/env bun
// Release boot check of the desktop window: the built frontend (apps/desktop/dist) in a headless browser, served with
// the release CSP of tauri.conf.json and with Object.prototype frozen when `freezePrototype` is on (as Tauri does
// before any page script), against a mocked host. The host's answers come from the saved state fixtures
// (apps/desktop/tests/fixtures/state/*.host.json, written by the Rust host's own migration: src-tauri/tests/state.rs);
// sidecar calls go to the real sidecar (`storebound serve --stdio`, offline reads only), so a folder that is gone gets
// the sidecar's real error. For each fixture it boots the window, opens every screen and every app in the sidebar,
// and fails on an uncaught error, a console error, an error panel (boundary or public/boot.js) or an empty #root.
// A last case loads a page whose bundle is missing and expects the boot.js error panel (the guard itself works).
// bun tools/desktop-boot-check.ts [--dist apps/desktop/dist] [--only d1-missing-folders] [--json]
// Uses playwright: Google Chrome when installed (channel "chrome"), else playwright's Chromium
// (bunx playwright install chromium). Part of `bun run ci` and of `tools/desktop-release.ts mac`.
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
import { extname, join, resolve } from 'node:path';
import type { Browser, Page } from 'playwright';
const ROOT = resolve(import.meta.dir, '..');
const APP = join(ROOT, 'apps/desktop');
const FIXTURES = join(APP, 'tests/fixtures/state');
const EXAMPLES = join(ROOT, 'examples');
/** A made-up home folder: paths under it show as ~ in the window. */
const HOME = '/Users/example';
const argv = process.argv.slice(2);
const opt = (k: string): string | undefined => { const i = argv.indexOf(`--${k}`); return i >= 0 ? argv[i + 1] : undefined; };
const DIST = resolve(opt('dist') ?? join(APP, 'dist'));
const ONLY = (opt('only') ?? '').split(',').filter(Boolean);
interface TauriConf { version: string; identifier: string; app: { security: { csp: Record<string, string> | string; freezePrototype?: boolean } } }
const conf = JSON.parse(readFileSync(join(APP, 'src-tauri/tauri.conf.json'), 'utf8')) as TauriConf;
export const cspHeader = (csp: Record<string, string> | string): string => (typeof csp === 'string' ? csp : Object.entries(csp).map(([k, v]) => `${k} ${v}`).join('; '));
// ---------------------------------------------------------------------------------------------------------------------
// The real sidecar (JSON-RPC over stdio), started once
class Sidecar {
private proc = Bun.spawn(['bun', join(ROOT, 'cli/index.ts'), 'serve', '--stdio'], { cwd: ROOT, stdin: 'pipe', stdout: 'pipe', stderr: 'ignore', env: { ...process.env, HOME: process.env.HOME ?? HOME } });
private next = 1;
private pending = new Map<number, { ok: (v: unknown) => void; bad: (e: unknown) => void }>();
hello: { protocol: string; server: { version: string }; methods: { method: string; kind: string }[] } | null = null;
constructor() {
(async () => {
let buf = '';
const dec = new TextDecoder();
const reader = (this.proc.stdout as ReadableStream<Uint8Array>).getReader();
for (;;) {
const { value, done } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true });
let nl: number;
while ((nl = buf.indexOf('\n')) >= 0) {
const line = buf.slice(0, nl); buf = buf.slice(nl + 1);
let m: { id?: number; result?: unknown; error?: { code: number; message: string } };
try { m = JSON.parse(line); } catch { continue; }
if (typeof m.id !== 'number') continue;
const p = this.pending.get(m.id);
this.pending.delete(m.id);
if (m.error) p?.bad({ code: m.error.code, message: m.error.message }); else p?.ok(m.result);
}
}
})();
}
call(method: string, params: Record<string, unknown>): Promise<unknown> {
const id = this.next++;
return new Promise((ok, bad) => {
this.pending.set(id, { ok, bad });
this.proc.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`);
this.proc.stdin.flush();
});
}
async start(): Promise<void> {
const secret = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('hex');
this.hello = await this.call('session.hello', { secret, client: { name: 'storebound-boot-check', version: conf.version } }) as Sidecar['hello'];
}
kind(method: string): string | undefined { return this.hello?.methods.find(m => m.method === method)?.kind; }
stop(): void { this.proc.kill(); }
}
// ---------------------------------------------------------------------------------------------------------------------
// The mocked host: what src-tauri/src/commands.rs answers for one saved state
interface Project { id: string; path: string; name: string; playAccount: string | null; appleAccount: string | null; added: string }
interface HostFixture { projects_list: Project[]; accounts_list: { id: string }[]; workspace_get: { overrides: { ignored: string[] } } & Record<string, unknown> }
const isDir = (p: string): boolean => { try { return statSync(p).isDirectory(); } catch { return false; } };
export function loadFixture(name: string): HostFixture {
const text = readFileSync(join(FIXTURES, `${name}.host.json`), 'utf8').split('@EXAMPLES@').join(EXAMPLES);
return JSON.parse(text) as HostFixture;
}
function host(fx: HostFixture, sidecar: Sidecar, unmocked: Set<string>, logged: string[]) {
const projects = () => fx.projects_list.map(p => ({ ...p, missing: !isDir(p.path) }));
const fail = (message: string) => { throw { code: -1, message }; };
return async (cmd: string, a: Record<string, unknown>): Promise<unknown> => {
switch (cmd) {
case 'plugin:event|listen': return Math.floor(Math.random() * 1e9);
case 'plugin:event|unlisten': return null;
case 'app_info': return { version: conf.version, identifier: conf.identifier, platform: 'macos', debug: false, home: HOME };
case 'projects_list': return projects();
case 'accounts_list': return fx.accounts_list;
case 'workspace_get': return fx.workspace_get;
case 'store_apps_cached': return fx.accounts_list.map(x => ({ account: x.id, entry: null }));
case 'cache_get': return null;
case 'log_webview': logged.push(`${String(a.level)}: ${String(a.message).slice(0, 300)}`); return null;
case 'sidecar_status': {
const h = sidecar.hello!;
const tools = await sidecar.call('system.tools', {}).catch(() => null);
return { running: true, version: h.server.version, protocol: h.protocol, methods: h.methods, tools };
}
case 'portfolio_status': {
if (a.live) fail('boot check: live store reads are not part of the boot check');
const ignored = fx.workspace_get.overrides.ignored.filter(i => i.startsWith('project:')).map(i => i.slice(8));
const paths = projects().filter(p => !ignored.includes(p.id) && !p.missing).map(p => p.path);
if (!paths.length) return { apps: [], at: new Date().toISOString() };
return sidecar.call('portfolio.status', { projects: paths });
}
case 'sidecar_call': {
const method = String(a.method);
const kind = sidecar.kind(method);
const params = { ...((a.params as Record<string, unknown> | null) ?? {}) };
if (kind === 'read' || kind === 'write' || kind === 'owner') {
if (kind !== 'read') fail(`boot check: ${method} writes`);
const p = projects().find(x => x.id === a.projectId);
if (!p) fail(String(a.projectId).startsWith('store:') ? 'boot check: store reads are not part of the boot check' : 'unknown project');
params.project = p!.path;
}
return sidecar.call(method, params);
}
default:
unmocked.add(cmd);
return fail(`boot check: ${cmd} is not answered here`);
}
};
}
/** Before any page script, as Tauri: the frozen prototype (tauri.conf.json freezePrototype), then the IPC object. */
const initScript = (freeze: boolean) => `(() => {
${freeze ? 'Object.freeze(Object.prototype);' : ''}
let n = 1;
const cbs = {};
window.__TAURI_INTERNALS__ = {
metadata: { currentWindow: { label: 'main' }, currentWebview: { windowLabel: 'main', label: 'main' } },
transformCallback(cb) { const id = n++; cbs[id] = cb; return id; },
unregisterCallback(id) { delete cbs[id]; },
convertFileSrc(p) { return p; },
async invoke(cmd, args) {
const r = await window.__bootCheckHost(cmd, args || {});
if ('err' in r) throw r.err;
return r.ok;
},
};
window.__TAURI_EVENT_PLUGIN_INTERNALS__ = { unregisterListener() {} };
})();`;
// ---------------------------------------------------------------------------------------------------------------------
// The page checks
const TYPES: Record<string, string> = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.svg': 'image/svg+xml', '.png': 'image/png', '.json': 'application/json' };
function serve(dist: string, csp: string, breakBundle: boolean) {
return Bun.serve({
port: 0, hostname: '127.0.0.1',
fetch(req) {
const p = decodeURIComponent(new URL(req.url).pathname);
const file = join(dist, p === '/' ? 'index.html' : p);
if (!file.startsWith(dist) || !existsSync(file) || statSync(file).isDirectory()) return new Response('not found', { status: 404 });
if (breakBundle && p.startsWith('/assets/') && p.endsWith('.js')) return new Response('not found', { status: 404 });
return new Response(Bun.file(file), { headers: { 'content-type': TYPES[extname(file)] ?? 'application/octet-stream', 'content-security-policy': csp } });
},
});
}
interface CaseResult { name: string; ok: boolean; problems: string[]; visited: number; notes: string[] }
async function state(page: Page) {
return page.evaluate(() => ({
booted: document.documentElement.dataset.booted === '1',
rootChildren: document.getElementById('root')?.childElementCount ?? 0,
textLength: (document.getElementById('root')?.innerText ?? '').trim().length,
crash: [...document.querySelectorAll('[data-crash]')].map(e => `${e.getAttribute('data-crash')}: ${(e.querySelector('.crash-msg')?.textContent ?? '').slice(0, 300)}`),
frozen: Object.isFrozen(Object.prototype),
}));
}
async function runCase(browser: Browser, name: string, sidecar: Sidecar, o: { breakBundle?: boolean } = {}): Promise<CaseResult> {
const freeze = conf.app.security.freezePrototype === true;
const server = serve(DIST, cspHeader(conf.app.security.csp), !!o.breakBundle);
const ctx = await browser.newContext({ viewport: { width: 1280, height: 800 }, reducedMotion: 'reduce' });
const page = await ctx.newPage();
const problems: string[] = [];
const notes: string[] = [];
const unmocked = new Set<string>();
const logged: string[] = [];
const fx = o.breakBundle ? loadFixture('empty') : loadFixture(name);
const answer = host(fx, sidecar, unmocked, logged);
await page.exposeFunction('__bootCheckHost', async (cmd: string, args: Record<string, unknown>) => {
try { return { ok: (await answer(cmd, args)) ?? null }; } catch (e) { return { err: e }; }
});
page.on('pageerror', e => problems.push(`uncaught: ${e.stack ?? e.message}`.split('\n').slice(0, 4).join(' | ')));
page.on('console', m => { if (m.type() === 'error') problems.push(`console.error: ${m.text().slice(0, 400)}`); });
await page.addInitScript(initScript(freeze));
let visited = 0;
try {
await page.goto(`http://127.0.0.1:${server.port}/`, { waitUntil: 'load' });
if (o.breakBundle) {
await page.waitForSelector('#boot-fallback', { timeout: 8000 }).catch(() => {});
const s = await state(page);
const ok = s.crash.some(c => c.startsWith('boot'));
return { name, ok, problems: ok ? [] : ['the boot.js error panel did not appear for a missing bundle'], visited: 0, notes: [`boot.js error panel shown: ${ok}`, ...logged.map(l => `webview.log: ${l}`)] };
}
await page.waitForFunction(() => document.documentElement.dataset.booted === '1', null, { timeout: 15000 }).catch(() => problems.push('React never drew the window (no data-booted)'));
await page.waitForTimeout(1500);
const check = async (where: string) => {
const s = await state(page);
if (freeze && !s.frozen) problems.push('Object.prototype is not frozen in the page (the check would not see the override mistake)');
if (!s.rootChildren || s.textLength < 40) problems.push(`${where}: empty window (#root has ${s.rootChildren} elements, ${s.textLength} characters)`);
for (const c of s.crash) problems.push(`${where}: error panel ${c}`);
};
await check('start');
// a first run opens the welcome guide (a modal): it must draw too, then it is closed like the owner would
const wizard = page.locator('.wizard[role="dialog"]');
if (await wizard.count()) {
notes.push('welcome guide shown');
await wizard.press('Escape').catch(() => {});
await page.waitForTimeout(300);
if (await wizard.count()) problems.push('the welcome guide does not close with Escape');
}
// every sidebar entry: the portfolio, each app (folders that are gone included), each workspace screen
const count = await page.locator('nav.side button.nav').count();
for (let i = 0; i < count; i++) {
const b = page.locator('nav.side button.nav').nth(i);
const label = ((await b.innerText().catch(() => '')) || `entry ${i}`).split('\n')[0]!.trim();
await b.click({ timeout: 5000 }).catch(e => problems.push(`could not open ${label}: ${String(e).split('\n')[0]}`));
await page.waitForTimeout(700);
await check(label);
visited++;
}
// apps whose folder is gone show "Folder not found" with Locate… and Remove
for (const p of fx.projects_list.filter(x => !isDir(x.path))) {
const item = page.locator('nav.side .nav-app', { hasText: p.name }).first();
if (!(await item.count())) { problems.push(`${p.name}: not in the sidebar`); continue; }
await item.click();
await page.waitForTimeout(500);
const text = await page.locator('#content').innerText();
if (!text.includes('Folder not found') || !text.includes('Locate…') || !text.includes('Remove from list')) problems.push(`${p.name}: no "Folder not found" panel with Locate… / Remove`);
else notes.push(`${p.name}: Folder not found, Locate… / Remove`);
}
} finally {
await ctx.close();
server.stop(true);
}
// anything the window logged as an error is a failure too (warnings are listed)
for (const l of logged) (l.startsWith('error') ? problems : notes).push(`webview.log: ${l}`);
if (unmocked.size) notes.push(`not answered (the window handled the rejection): ${[...unmocked].join(', ')}`);
return { name, ok: problems.length === 0, problems: [...new Set(problems)], visited, notes };
}
async function launch(): Promise<Browser> {
const { chromium } = await import('playwright');
try { return await chromium.launch({ channel: 'chrome', headless: true }); } catch { /* no Google Chrome */ }
return chromium.launch({ headless: true });
}
export async function bootCheck(): Promise<CaseResult[]> {
if (!existsSync(join(DIST, 'index.html'))) throw new Error(`no built frontend at ${DIST} (bun run --cwd apps/desktop build)`);
const names = readdirSync(FIXTURES).filter(f => f.endsWith('.host.json')).map(f => f.replace(/\.host\.json$/, '')).sort();
if (names.length < 3) throw new Error(`expected the state fixtures in ${FIXTURES}`);
const sidecar = new Sidecar();
await sidecar.start();
const browser = await launch();
const results: CaseResult[] = [];
try {
for (const n of names) if (!ONLY.length || ONLY.includes(n)) results.push(await runCase(browser, n, sidecar));
if (!ONLY.length || ONLY.includes('missing-bundle')) results.push(await runCase(browser, 'missing-bundle', sidecar, { breakBundle: true }));
} finally {
await browser.close();
sidecar.stop();
}
return results;
}
if (import.meta.main) {
const results = await bootCheck().catch(e => { console.error(`desktop boot check: ${(e as Error).message}`); process.exit(1); });
if (argv.includes('--json')) console.log(JSON.stringify(results, null, 2));
else for (const r of results) {
console.log(`${r.ok ? 'ok ' : 'FAIL'} ${r.name}${r.visited ? ` (${r.visited} screens)` : ''}`);
for (const n of r.notes) console.log(` ${n}`);
for (const p of r.problems) console.log(` ! ${p}`);
}
const bad = results.filter(r => !r.ok);
console.log(bad.length ? `desktop boot check: ${bad.length} of ${results.length} failed` : `desktop boot check: ${results.length} cases, window drawn every time (CSP${conf.app.security.freezePrototype ? ', frozen Object.prototype' : ''})`);
process.exit(bad.length ? 1 : 0);
}