#!/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). // The send-for-review cases (submit-ready, submit-unresolved; old D1 and new D3 saved state) answer the store reads // apple.status / play.status from apps/desktop/src/dev/submit-fixtures.ts and the owner actions' dry runs from the // same file (the dialog is answered "Cancel": nothing is ever sent), open Releases, check the submission state, the // build picker, the plan panel and a console plan (the real sidecar) by DOM text, then every tab of the app and the // Dependencies screen. // bun tools/desktop-boot-check.ts [--dist apps/desktop/dist] [--only d1-missing-folders] [--json] // Speed: one browser (killed at the end, never closed gracefully), one page per case, the cases in parallel // (STOREBOUND_BOOT_CHECK_JOBS, default 8) against the one build; no fixed sleeps: after each action it waits for a // condition (the element, the text, or settle(): no host call in flight and no DOM change for QUIET_MS). // Uses playwright: its headless shell (bunx playwright install chromium-headless-shell), else Google Chrome // (channel "chrome"), else its full 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'; import { APPLE_STATUS_FIRST_REVIEW, APPLE_STATUS_READY, APPLE_STATUS_UNRESOLVED, ownerDry, PLAY_STATUS, PLAY_STATUS_FIRST_200, PLAY_STATUS_FIRST_404 } from '../apps/desktop/src/dev/submit-fixtures'; import { appleReleases, playReleases, releaseTrains } from '../cli/lib/portfolio'; 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; freezePrototype?: boolean } } } const conf = JSON.parse(readFileSync(join(APP, 'src-tauri/tauri.conf.json'), 'utf8')) as TauriConf; export const cspHeader = (csp: Record | 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 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).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): Promise { 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 { 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; /** the host's answer before any store read: the machine's store-only apps (projects.json storeApps) under their account */ store_apps_cached?: { account: string; entry: 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; } /** Store answers of a send-for-review case: the reads come from the fixtures, owner dry runs too, dialogs say Cancel. */ interface LiveStores { apple: unknown; play: unknown; calls: string[] } const STORE_READS: Record> = { 'apple.status': 'apple', 'play.status': 'play' }; const toolResult = (method: string, data: unknown) => ({ method, ok: true, exitCode: 0, dryRun: false, command: `storebound ${method.replace('.', ' ')} --json`, output: '', data }); function host(fx: HostFixture, sidecar: Sidecar, unmocked: Set, logged: string[], live?: LiveStores) { 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): Promise => { 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.store_apps_cached ?? fx.accounts_list.map(x => ({ account: x.id, entry: null })); case 'cache_get': { const st = live && STORE_READS[String(a.method)]; return st ? { value: toolResult(String(a.method), live![st]), at: new Date(Date.now() - 5 * 60_000).toISOString() } : null; } case 'write_with_confirm': { if (!live) return fail('boot check: write_with_confirm is not answered here'); // the owner pressed Cancel in the native dialog: nothing is sent, ever live.calls.push(`write_with_confirm ${String(a.method)} ${JSON.stringify(a.args)}`); return { confirmed: false, stage: 'dialog', dryRun: ownerDry(String(a.method), (a.args ?? {}) as Record) }; } 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 | null) ?? {}) }; if (live && STORE_READS[method]) { live.calls.push(method); return toolResult(method, live[STORE_READS[method]!]); } if (live && kind === 'owner') { // the Rust host forces dryRun for every write and owner action from the window (policy::sanitize_js_call) if ('dryRun' in params || 'confirmToken' in params) fail(`boot check: ${method}: the window sent host params`); live.calls.push(`dry ${method} ${JSON.stringify(params.args)}`); return ownerDry(method, (params.args ?? {}) as Record); } 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) { window.__bootInflight = (window.__bootInflight || 0) + 1; window.__bootChanged = performance.now(); try { const r = await window.__bootCheckHost(cmd, args || {}); if ('err' in r) throw r.err; return r.ok; } finally { window.__bootInflight--; window.__bootChanged = performance.now(); } }, }; window.__TAURI_EVENT_PLUGIN_INTERNALS__ = { unregisterListener() {} }; // settle(): the last host call or DOM change, so the check waits for a quiet window instead of a fixed time window.__bootInflight = 0; window.__bootChanged = performance.now(); new MutationObserver(() => { window.__bootChanged = performance.now(); }) .observe(document, { childList: true, subtree: true, attributes: true, characterData: true }); })();`; // --------------------------------------------------------------------------------------------------------------------- // The page checks const TYPES: Record = { '.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), })); } /** A quiet window: no host call in flight and no DOM change for this long means the last action is done. */ const QUIET_MS = 150; /** Upper bound of one wait for a condition (a loaded machine); a wait that runs out is a failure or a check as-is. */ const WAIT_MS = 15_000; /** * Waits until the window is quiet (initScript's counters: no host call in flight, no DOM change for QUIET_MS): what a * click or the boot started has drawn. Condition-based, no fixed sleep; a window that never gets quiet (an animation * written by script) is checked as it is after `ms`. */ async function settle(page: Page, ms = WAIT_MS): Promise { await page.waitForFunction((q: number) => { const w = window as unknown as { __bootInflight: number; __bootChanged: number }; return w.__bootInflight === 0 && performance.now() - w.__bootChanged >= q; }, QUIET_MS, { timeout: ms, polling: 50 }).catch(() => {}); } /** Waits until `sel` is gone (a dialog or panel closed); false when it is still there after `ms`. */ const gone = (page: Page, sel: string, ms = WAIT_MS): Promise => page.waitForSelector(sel, { state: 'detached', timeout: ms }).then(() => true, () => false); /** Waits until the first `sel` contains `text` (whitespace folded); false after `ms`. */ const shows = (page: Page, sel: string, text: string, ms = WAIT_MS): Promise => page.waitForFunction(([s, t]) => (document.querySelector(s!) as HTMLElement | null)?.innerText.replace(/\s+/g, ' ').includes(t!) ?? false, [sel, text] as const, { timeout: ms, polling: 50 }).then(() => true, () => false); async function runCase(browser: Browser, name: string, sidecar: Sidecar, o: { breakBundle?: boolean } = {}): Promise { 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(); 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) => { 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: WAIT_MS }).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: WAIT_MS }).catch(() => problems.push('React never drew the window (no data-booted)')); await settle(page); 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 gone(page, '.wizard[role="dialog"]'); 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: WAIT_MS }).catch(e => problems.push(`could not open ${label}: ${String(e).split('\n')[0]}`)); await settle(page); 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({ timeout: WAIT_MS }); await shows(page, '#content', 'Folder not found'); await settle(page); 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`); } // shared with the CLI: each account names its credential profile (or that its old keychain key has not moved) const accts = fx.accounts_list as { label?: string; profile?: string | null }[]; if (accts.length) { const nav = page.locator('nav.side button.nav', { hasText: 'Accounts' }).first(); if (await nav.count()) { await nav.click({ timeout: WAIT_MS }); await settle(page); const text = await page.locator('#content').innerText(); for (const a of accts) { const want = a.profile ? `profile ${a.profile}` : 'key not moved from the keychain yet'; if (!text.includes(want)) problems.push(`Accounts: "${want}" not shown for ${a.label} (${text.replace(/\s+/g, " ").slice(text.replace(/\s+/g, " ").indexOf(a.label ?? "") , 400)})`); } notes.push(`Accounts: ${accts.map(a => a.profile ?? '(keychain)').join(', ')}`); } else problems.push('no Accounts entry in the sidebar'); } // store-only apps of the machine list (projects.json storeApps): in the portfolio, opened as "Store only", no folder for (const e of fx.store_apps_cached ?? []) for (const app of ((e.entry as { value?: { apps?: { name: string; registered?: boolean }[] } } | null)?.value?.apps ?? []).filter(x => x.registered)) { const item = page.locator('nav.side .nav-app', { hasText: app.name }).first(); if (!(await item.count())) { problems.push(`${app.name} (store-only entry): not in the sidebar`); continue; } await item.click({ timeout: WAIT_MS }); await settle(page); const text = await page.locator('#content').innerText(); await check(app.name); if (!/Store only/i.test(text)) problems.push(`${app.name}: not shown as "Store only" (${text.replace(/\s+/g, ' ').slice(0, 200)})`); else notes.push(`${app.name}: store-only entry, no local project`); } // a folder that pins another storebound version says so on its app page for (const p of fx.projects_list.filter(x => (x as { storeboundPin?: string }).storeboundPin && isDir(x.path))) { const pin = (p as { storeboundPin?: string }).storeboundPin!; 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({ timeout: WAIT_MS }); await settle(page); const text = await page.locator('#content').innerText(); if (!text.includes(`uses storebound ${pin}`)) problems.push(`${p.name}: no "project uses storebound ${pin}" notice`); else notes.push(`${p.name}: pin notice ${pin}`); } } 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 }; } /** Saved states for the send-for-review cases: the new D3 state, and the old D1 state with its folders present. */ function liveFixture(base: 'd3-full' | 'd1-missing-folders'): HostFixture { const fx = loadFixture(base); if (base === 'd1-missing-folders') for (const p of fx.projects_list) p.path = p.path.replace(/^\/nonexistent\/storebound\/worktrees\/[^/]+\/examples/, EXAMPLES); return fx; } /** Texts the Releases tab must show for each submission state (DOM text, not pixels). */ const SUBMIT_EXPECT: Record<'ready' | 'unresolved', string[]> = { ready: ['Send for review', 'App Store: submit for review', 'Empty draft', 'harmless', 'Pick the build you uploaded', 'Submit for review', 'Google Play: production release', 'Send to production', 'Managed publishing', 'No API for these Play steps', 'Send changes for review'], unresolved: ['Send for review', 'Back from App Review (unresolved issues)', 'UNRESOLVED_ISSUES', 'app version 2.4.0', 'REJECTED', 'marked resolved when you resubmit', 'Resubmit', 'Also mark rejected in-app purchase items resolved', 'Send to production'], }; async function runSubmitCase(browser: Browser, sidecar: Sidecar, base: 'd3-full' | 'd1-missing-folders', variant: 'ready' | 'unresolved'): Promise { const name = `submit-${variant} (${base === 'd3-full' ? 'D3 state' : 'D1 state'})`; const freeze = conf.app.security.freezePrototype === true; const server = serve(DIST, cspHeader(conf.app.security.csp), false); const ctx = await browser.newContext({ viewport: { width: 1360, height: 900 }, reducedMotion: 'reduce' }); const page = await ctx.newPage(); const problems: string[] = []; const notes: string[] = []; const unmocked = new Set(); const logged: string[] = []; const fx = liveFixture(base); const live: LiveStores = { apple: variant === 'ready' ? APPLE_STATUS_READY : APPLE_STATUS_UNRESOLVED, play: PLAY_STATUS, calls: [] }; const answer = host(fx, sidecar, unmocked, logged, live); await page.exposeFunction('__bootCheckHost', async (cmd: string, args: Record) => { 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; const text = async (sel = '#content') => (await page.locator(sel).first().innerText().catch(() => '')).replace(/\s+/g, ' '); const crashCheck = async (where: string) => { for (const c of (await state(page)).crash) problems.push(`${where}: error panel ${c}`); }; try { await page.goto(`http://127.0.0.1:${server.port}/`, { waitUntil: 'load' }); await page.waitForFunction(() => document.documentElement.dataset.booted === '1', null, { timeout: WAIT_MS }).catch(() => problems.push('React never drew the window (no data-booted)')); await settle(page); const wizard = page.locator('.wizard[role="dialog"]'); if (await wizard.count()) { await wizard.press('Escape').catch(() => {}); await gone(page, '.wizard[role="dialog"]'); } const app = fx.projects_list.find(p => isDir(p.path))!; // the sidebar names the app by its config name once the offline portfolio is read const item = page.locator('nav.side .nav-app').filter({ hasText: base === 'd3-full' ? /Prism Puddle|demo-game/ : new RegExp(app.name) }).first(); await item.click({ timeout: WAIT_MS }).catch(e => problems.push(`could not open the app: ${String(e).split('\n')[0]}`)); await settle(page); await page.locator('[role="tab"]#tab-releases').click({ timeout: WAIT_MS }).catch(() => {}); await page.waitForSelector('[data-section="submit"]', { timeout: WAIT_MS }).catch(() => problems.push('Releases: no send-for-review section')); await settle(page); const rel = await text('[data-section="submit"]'); for (const want of SUBMIT_EXPECT[variant]) if (!rel.includes(want)) problems.push(`Releases (${variant}): missing "${want}"`); if (rel.includes('storebound never submits for review')) problems.push('Releases: the old "submit stays in the console" note is still there'); // builds by number: the processing one cannot be picked, nothing is preselected const opts = await page.locator('[data-section="submit"] select').first().locator('option').evaluateAll(os => os.map(o => ({ v: (o as HTMLOptionElement).value, d: (o as HTMLOptionElement).disabled, t: o.textContent ?? '' }))); const sel = await page.locator('[data-section="submit"] select').first().inputValue().catch(() => 'x'); if (sel !== '') problems.push(`build picker: preselected "${sel}" (the owner picks; never the latest)`); if (!opts.some(o => o.v === '52' && !o.d) || !opts.some(o => o.v === '53' && o.d)) problems.push(`build picker: want 52 pickable and 53 (PROCESSING) disabled, got ${JSON.stringify(opts)}`); notes.push(`build picker: ${opts.filter(o => o.v).map(o => `${o.v}${o.d ? ' (disabled)' : ''}`).join(', ')}`); // the owner action: disabled until a build is picked, then the plan panel, then the dialog (answered Cancel here) const action = page.locator('[data-section="submit"] button', { hasText: variant === 'ready' ? 'Submit for review' : 'Resubmit' }).first(); if (!(await action.isDisabled())) problems.push('the submit button is enabled before a build is picked'); await page.locator('[data-section="submit"] select').first().selectOption('52'); await action.click({ timeout: WAIT_MS }); await page.waitForSelector('.oa-panel', { timeout: WAIT_MS }).catch(() => problems.push('no plan panel after the submit button')); await settle(page); const panel = await text('.oa-panel'); for (const want of ['Plan (dry run, nothing sent yet)', 'STEP', 'attach build 52', 'a system dialog shows this plan again']) if (!panel.includes(want)) problems.push(`plan panel: missing "${want}"`); await page.locator('.oa-panel .lb-foot button', { hasText: 'Continue' }).click({ timeout: WAIT_MS }).catch(e => problems.push(`Continue: ${String(e).split('\n')[0]}`)); await shows(page, '.oa-panel', 'nothing was sent'); if (!(await text('.oa-panel')).includes('nothing was sent')) problems.push('plan panel: no "nothing was sent" after the dialog said Cancel'); await page.keyboard.press('Escape'); await gone(page, '.oa-panel'); if (await page.locator('.oa-panel').count()) problems.push('plan panel does not close with Escape'); const want = variant === 'ready' ? 'owner.apple_submit' : 'owner.apple_resubmit'; if (!live.calls.some(c => c.startsWith(`dry ${want} {"build":"52","version":"2.4.0"}`))) problems.push(`no dry run of ${want} with build 52 and version 2.4.0: ${live.calls.join(' | ')}`); if (!live.calls.some(c => c.startsWith(`write_with_confirm ${want}`))) problems.push(`${want} did not go through write_with_confirm`); // Google Play: versionCodes by number (one already in production cannot be picked), then the production plan const codes = page.locator('[data-section="submit"] select[aria-describedby="sub-code-hint"]'); const codeOpts = await codes.locator('option').evaluateAll(os => os.map(o => `${(o as HTMLOptionElement).value}${(o as HTMLOptionElement).disabled ? '!' : ''}`)); if (JSON.stringify(codeOpts) !== JSON.stringify(['', '24', '23!', '22'])) problems.push(`versionCode picker: ${JSON.stringify(codeOpts)}`); await codes.selectOption('24'); await page.locator('[data-section="submit"] button', { hasText: 'Send to production' }).first().click({ timeout: WAIT_MS }); await page.waitForSelector('.oa-panel', { timeout: WAIT_MS }).catch(() => problems.push('no plan panel after Send to production')); await settle(page); const pp = await text('.oa-panel'); for (const w of ['--version-code 24 --production completed', 'managed publishing is off']) if (!pp.includes(w)) problems.push(`production plan: missing "${w}"`); await page.keyboard.press('Escape'); await gone(page, '.oa-panel'); if (!live.calls.some(c => c.startsWith('dry owner.play_production {"version_code":"24","status":"completed"}'))) problems.push(`no dry run of owner.play_production 24: ${live.calls.join(' | ')}`); // a console plan from the real sidecar (offline): Play's Send changes for review await page.locator('[data-section="submit"] button', { hasText: 'Send changes for review' }).first().click({ timeout: WAIT_MS }); await page.waitForSelector('.cp-panel', { timeout: WAIT_MS }).catch(() => problems.push('no console plan panel')); await settle(page); const cp = await text('.cp-panel'); for (const w of ['send the first production release for review', 'Send changes for review', 'Check the result']) if (!cp.includes(w)) problems.push(`console plan: missing "${w}"`); await page.keyboard.press('Escape'); await gone(page, '.cp-panel'); await crashCheck('Releases'); visited++; // every tab of the app, then App info's console steps and the Dependencies screen const tabs = await page.locator('[role="tab"]').evaluateAll(ts => ts.map(x => x.id)); for (const id of tabs) { await page.locator(`[role="tab"]#${id}`).click({ timeout: WAIT_MS }).catch(e => problems.push(`tab ${id}: ${String(e).split('\n')[0]}`)); await settle(page); await crashCheck(id); visited++; if (id === 'tab-appinfo') { const ai = await text(); for (const w of ['Console steps without an API', 'Play store settings (App category)', 'Production countries', 'Play: send the production release for review']) if (!ai.includes(w)) problems.push(`App info: missing "${w}"`); } if (id === 'tab-testflight' && !(await text()).length) problems.push('TestFlight: empty'); } notes.push(`tabs: ${tabs.join(', ')}`); const deps = page.locator('nav.side button.nav', { hasText: 'Dependencies' }).first(); await deps.click({ timeout: WAIT_MS }).catch(() => {}); await shows(page, '#content', 'Android SDK'); await settle(page); if (!(await text()).includes('Android SDK')) problems.push('Dependencies: no Android SDK card'); await crashCheck('Dependencies'); visited++; notes.push(`store calls: ${live.calls.length} (${[...new Set(live.calls.map(c => c.split(' ').slice(0, 2).join(' ')))].join(', ')})`); } catch (e) { problems.push(`case stopped: ${String(e).split('\n')[0]}`); } finally { await ctx.close(); server.stop(true); } 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 }; } /** * First release, live-shape store state (pitfall 56): App Store "1.0" waiting for review, Play production "1.0.0" * completed with the public store page 404 (not public yet) or 200. The last live portfolio read (cache_get * portfolio.status) carries that state; the check reads the Releases tab and the portfolio by DOM text: one row for * 1.0 / 1.0.0, and no "Live" for Play while the page is 404. */ async function runReleaseStateCase(browser: Browser, sidecar: Sidecar, page404: boolean): Promise { const name = `release-state (Play page ${page404 ? '404' : '200'})`; const freeze = conf.app.security.freezePrototype === true; const server = serve(DIST, cspHeader(conf.app.security.csp), false); const ctx = await browser.newContext({ viewport: { width: 1360, height: 900 }, reducedMotion: 'reduce' }); const page = await ctx.newPage(); const problems: string[] = []; const notes: string[] = []; const unmocked = new Set(); const logged: string[] = []; const fx = liveFixture('d3-full'); const play = page404 ? PLAY_STATUS_FIRST_404 : PLAY_STATUS_FIRST_200; const live: LiveStores = { apple: APPLE_STATUS_FIRST_REVIEW, play, calls: [] }; const base = host(fx, sidecar, unmocked, logged, live); const ios = appleReleases(APPLE_STATUS_FIRST_REVIEW), android = playReleases(play); const answer = async (cmd: string, a: Record): Promise => { if (cmd === 'cache_get' && a.method === 'portfolio.status') { const off = await base('portfolio_status', { live: false }) as { apps: Record[]; at: string }; const apps = off.apps.map(x => ({ ...x, storeErrors: {}, release: { source: 'live', ...releaseTrains(ios, android), all: [...ios, ...android] } })); return { value: { apps, at: new Date().toISOString() }, at: new Date(Date.now() - 60_000).toISOString() }; } return base(cmd, a); }; await page.exposeFunction('__bootCheckHost', async (cmd: string, args: Record) => { 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; const text = async (sel = '#content') => (await page.locator(sel).first().innerText().catch(() => '')).replace(/\s+/g, ' '); const crashCheck = async (where: string) => { for (const c of (await state(page)).crash) problems.push(`${where}: error panel ${c}`); }; const SENT = 'Sent to Google (in review or not yet published)'; try { await page.goto(`http://127.0.0.1:${server.port}/`, { waitUntil: 'load' }); await page.waitForFunction(() => document.documentElement.dataset.booted === '1', null, { timeout: WAIT_MS }).catch(() => problems.push('React never drew the window (no data-booted)')); await settle(page); const wizard = page.locator('.wizard[role="dialog"]'); if (await wizard.count()) { await wizard.press('Escape').catch(() => {}); await gone(page, '.wizard[role="dialog"]'); } // the portfolio: Play's cell names the state, never "Live" while the page is 404 await shows(page, '#content', page404 ? SENT : '1.0.0'); const pf = await text(); if (page404 && !pf.includes(SENT)) problems.push(`Portfolio: no "${SENT}"`); await crashCheck('Portfolio'); visited++; const item = page.locator('nav.side .nav-app').filter({ hasText: /Prism Puddle|demo-game/ }).first(); await item.click({ timeout: WAIT_MS }).catch(e => problems.push(`could not open the app: ${String(e).split('\n')[0]}`)); await settle(page); await page.locator('[role="tab"]#tab-releases').click({ timeout: WAIT_MS }).catch(() => {}); await shows(page, '#content', 'Versions on the stores'); await settle(page); const rows = await page.locator('#content table tbody tr').evaluateAll(rs => rs.map(r => Array.from(r.querySelectorAll('td')).map(td => (td.textContent ?? '').trim()))); notes.push(`history: ${JSON.stringify(rows)}`); if (rows.length !== 1) problems.push(`Releases: want one row for 1.0 / 1.0.0, got ${rows.length}: ${JSON.stringify(rows)}`); const row = rows[0] ?? []; if (row[0] !== '1.0.0 / 1.0') problems.push(`Releases: version cell "${row[0]}" (want "1.0.0 / 1.0")`); if (row[1] !== 'In review') problems.push(`Releases: App Store cell "${row[1]}" (want "In review")`); if (row[3] !== (page404 ? SENT : 'Live')) problems.push(`Releases: Google Play cell "${row[3]}" (want "${page404 ? SENT : 'Live'}")`); const rel = await text(); if (/is not on (App Store|Google Play) yet/.test(rel)) problems.push('Releases: a lane says the version is not on a store (1.0 and 1.0.0 not joined)'); for (const w of ['1.0 (3)', '1.0.0 (3)']) if (!rel.includes(w)) problems.push(`Releases: the lane does not show the store's own "${w}"`); if (page404) { if (!rel.includes(SENT)) problems.push(`Releases: no "${SENT}"`); const heads = await page.locator('#content .train-head').evaluateAll(hs => hs.map(h => (h.textContent ?? '').trim())); notes.push(`trains: ${JSON.stringify(heads)}`); if (heads.some(h => /Live/.test(h))) problems.push(`Releases: a "Live" train with Play page 404: ${JSON.stringify(heads)}`); } await crashCheck('Releases'); visited++; } catch (e) { problems.push(`case stopped: ${String(e).split('\n')[0]}`); } finally { await ctx.close(); server.stop(true); } 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 }; } /** * One headless browser for every case, as a browser server that is KILLED at the end (a throwaway profile, nothing to * flush): Google Chrome's graceful close takes 10-30 s after http pages (tests/helpers/browser.ts). Playwright's * headless shell first (fast to start), else Google Chrome (channel "chrome"), else playwright's full Chromium. */ async function launch(): Promise<{ browser: Browser; kill: () => Promise }> { const { chromium } = await import('playwright'); const opts = { headless: true, timeout: 60_000 }; let server: Awaited> | undefined, first: unknown; for (const o of [{}, { channel: 'chrome' }, { channel: 'chromium' }]) { try { server = await chromium.launchServer({ ...opts, ...o }); break; } catch (e) { first ??= e; } } if (!server) throw first; const s = server; const browser = await chromium.connect(s.wsEndpoint(), { timeout: 60_000 }).catch(async (e: unknown) => { await s.kill(); throw e; }); return { browser, kill: async () => { await Promise.race([browser.close().catch(() => {}), Bun.sleep(5000)]); await s.kill(); } }; } /** Cases at once (a page each, one browser); STOREBOUND_BOOT_CHECK_JOBS overrides. */ const JOBS = Math.max(1, Number(process.env.STOREBOUND_BOOT_CHECK_JOBS) || 8); export async function bootCheck(): Promise { 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(); const [, { browser, kill }] = await Promise.all([sidecar.start(), launch()]); // every case in its own context and page, JOBS at a time, against the one build; results keep the case order const cases: (() => Promise)[] = []; for (const n of names) if (!ONLY.length || ONLY.includes(n)) cases.push(() => runCase(browser, n, sidecar)); if (!ONLY.length || ONLY.includes('missing-bundle')) cases.push(() => runCase(browser, 'missing-bundle', sidecar, { breakBundle: true })); for (const base of ['d3-full', 'd1-missing-folders'] as const) for (const v of ['ready', 'unresolved'] as const) if (!ONLY.length || ONLY.includes('submit') || ONLY.includes(`submit-${v}`)) cases.push(() => runSubmitCase(browser, sidecar, base, v)); for (const p404 of [true, false]) if (!ONLY.length || ONLY.includes('release-state')) cases.push(() => runReleaseStateCase(browser, sidecar, p404)); const results: CaseResult[] = new Array(cases.length); let next = 0; try { await Promise.all(Array.from({ length: Math.min(JOBS, cases.length) }, async () => { while (next < cases.length) { const i = next++; results[i] = await cases[i]!(); } })); } finally { await kill(); 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); }