storebound/tools/desktop-boot-check.ts

1165 lines
84 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).
// 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 { cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { mint } from '../cli/lib/confirm-token';
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';
import { LOAD, loadNote, scaled } from '../tests/helpers/load';
import { EXAMPLE_CLOUD_PROJECT, healthApiDisabled, SCAN_FAILED, SCAN_FAILED_LINE, SCAN_FINDINGS } from '../apps/desktop/src/dev/error-fixtures';
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;
/** The session secret (the editor case mints the host's confirm token for project.edit with it, as the Rust host does). */
secret = Buffer.alloc(0);
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.secret = Buffer.from(secret, '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>;
/** 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<string, keyof Omit<LiveStores, 'calls'>> = { '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<string>, 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<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;
// background store reads are the auto-read case's subject (runAutoReadCase); the saved-state cases check drawing
case 'workspace_get': return withAutoRead(fx.workspace_get, false);
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<string, unknown>) };
}
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 (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<string, unknown>);
}
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`);
}
};
}
/** The saved workspace with Settings > Reading > auto-read set (older states have no such field: on by default). */
function withAutoRead(w: HostFixture['workspace_get'], on: boolean): HostFixture['workspace_get'] {
const settings = (w.settings ?? {}) as { refresh?: Record<string, unknown> };
return { ...w, settings: { ...settings, refresh: { ...settings.refresh, autoRead: on } } };
}
/** 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<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),
}));
}
/** 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, scaled by load ÷ cores (tests/helpers/load.ts: the machine is shared); a
* wait that runs out is a failure or a check as-is. */
const WAIT_MS = scaled(15_000);
console.log(`desktop boot check: ${loadNote(LOAD)}`);
/**
* 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<void> {
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<boolean> =>
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<boolean> =>
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<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: 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<CaseResult> {
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<string>();
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<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;
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<CaseResult> {
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<string>();
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<string, unknown>): Promise<unknown> => {
if (cmd === 'cache_get' && a.method === 'portfolio.status') {
const off = await base('portfolio_status', { live: false }) as { apps: Record<string, unknown>[]; 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<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;
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 = 'In review'; // decided from the store page: 404 = in review (no uncertain wording)
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 (/not yet published|may still be reviewing|Sent to Google/.test(rel + pf)) problems.push('uncertain Play wording on screen');
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 };
}
/** Longest wait for the background reader to read everything of every app (a loaded machine). */
const READ_ALL_MS = scaled(120_000);
/**
* Auto-read: the dev fixture state (src/dev/fixtures.ts: 2 app folders and the store-only apps of 5 accounts, at least 8
* apps) with an empty cache. Without any click the background reader must read everything: the status line shows
* "Reading n of m" and the App Store quota, then "Everything read"; then no tab of any app says "not read yet". The
* Health tab's first top issue opens and shows its sample stack. Zero uncaught errors.
*/
async function runAutoReadCase(browser: Browser): Promise<CaseResult> {
const name = 'auto-read (fixture state, cold cache)';
const fx = await import('../apps/desktop/src/dev/fixtures');
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 logged: string[] = [];
let reads = 0;
const playOpen = new Map<string, number>();
let playPeak = 0;
// fixture art is file: URLs (blocked by the CSP): icons are left out
const clean = (v: unknown): unknown => (v === undefined ? null : JSON.parse(JSON.stringify(v), (_k, x) => (typeof x === 'string' && x.startsWith('file:') ? null : x)));
const answer = 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 'log_webview': logged.push(`${String(a.level)}: ${String(a.message).slice(0, 300)}`); return null;
case 'cache_get': return null;
case 'sidecar_call': {
const method = String(a.method);
if (method === 'system.tools') return clean(await fx.mockInvoke(cmd, a));
reads++;
if (method.startsWith('play.') || ['health', 'reviews.list', 'catalog.diff', 'prices.current', 'validate'].includes(method)) {
// Play reads of one app may run at once (the scheduler's 4 per store, starts 250 ms apart per key): count the
// overlap and hold each read long enough for it to happen
const k = String(a.projectId);
const n = (playOpen.get(k) ?? 0) + 1;
playOpen.set(k, n); playPeak = Math.max(playPeak, n);
await new Promise(r => setTimeout(r, 400));
playOpen.set(k, playOpen.get(k)! - 1);
}
return clean(fx.fixtureRead(method, String(a.projectId), ((a.params as { args?: Record<string, unknown> } | null)?.args) ?? {}));
}
default: return clean(await fx.mockInvoke(cmd, a));
}
};
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;
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)'));
const wizard = page.locator('.wizard[role="dialog"]');
if (await wizard.count()) { await wizard.press('Escape').catch(() => {}); await gone(page, '.wizard[role="dialog"]'); }
const apps = await page.locator('nav.side .nav-app').count();
if (apps < 8) problems.push(`want at least 8 apps in the sidebar, got ${apps}`);
// no click: the status line shows the reader's progress, then that everything is read
const line = '[data-testid="reader-line"]';
if (!(await page.waitForFunction(s => /Reading \d+ of \d+/.test(document.querySelector(s)?.textContent ?? ''), line, { timeout: WAIT_MS, polling: 20 }).then(() => true, () => false)))
problems.push('status line: no "Reading n of m" while reading');
else notes.push(`status line while reading: ${await text(line)}`);
if (!(await shows(page, line, 'Everything read', READ_ALL_MS))) problems.push(`status line: not "Everything read" after ${READ_ALL_MS / 1000} s: ${await text(line)}`);
const done = await text(line);
notes.push(`status line after: ${done}; ${reads} reads, ${apps} apps`);
if (!/App Store limit 3,412\/3,600 left this hour/.test(done)) problems.push(`status line: no App Store quota (${done})`);
await settle(page);
// every tab of every app: nothing says "not read yet" (the reads were done in the background)
const shown = await page.locator('nav.side .nav-app:visible').count();
for (let i = 0; i < shown; i++) {
await page.locator('nav.side .nav-app:visible').nth(i).click({ timeout: WAIT_MS }).catch(e => problems.push(`app ${i}: ${String(e).split('\n')[0]}`));
await settle(page);
const appName = (await page.locator('nav.side .nav-app:visible').nth(i).innerText().catch(() => `app ${i}`)).split('\n')[0]!.trim();
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(`${appName} ${id}: ${String(e).split('\n')[0]}`));
await settle(page);
await crashCheck(`${appName} ${id}`);
const tx = await text();
// a Play read that lost its edit to a parallel read (pitfall), or a store that failed called empty
for (const bad of [/Edit has been deleted/i, /is not on Google Play yet/i]) if (bad.test(tx)) problems.push(`${appName} ${id}: says "${(bad.exec(tx) ?? [''])[0]}"`);
// the Store art tab's live thumbnails are read on demand (art live: one image download per slot), never by
// the background read: its "Live store art not read yet" is expected there
const unread = id === 'tab-art' ? tx.replace(/Live store art not read yet/gi, '') : tx;
if (/not read yet|Read the stores to see this/i.test(unread)) problems.push(`${appName} ${id}: says "${(/not read yet|Read the stores to see this/i.exec(tx) ?? [''])[0]}" after the background read`);
visited++;
}
}
notes.push(`Play reads of one app at once: up to ${playPeak}`);
if (playPeak < 2) problems.push(`no two Play reads of one app ran at once (peak ${playPeak}): the case does not exercise parallel reads`);
// the stores' current versions differ (Play 3.1.0, App Store 3.0.4): a neutral header, each store its own state
await page.locator('nav.side .nav-app', { hasText: 'Orbit Hop' }).first().click({ timeout: WAIT_MS });
await settle(page);
await page.locator('[role="tab"]#tab-releases').click({ timeout: WAIT_MS });
await settle(page);
const rel = await text();
for (const w of ['Current', 'Play 3.1.0 · App Store 3.0.4', '3.0.4 · Live (3.1.0 not submitted)']) if (!rel.includes(w)) problems.push(`Orbit Hop Releases: missing "${w}"`);
for (const w of ['Not on this store', 'is not on App Store yet', '3.1.0 Released']) if (rel.includes(w)) problems.push(`Orbit Hop Releases: says "${w}"`);
notes.push(`Orbit Hop releases: ${rel.slice(0, 200)}`);
await crashCheck('Orbit Hop Releases');
// Health: the first top issue opens with its full title, the sample stack and Copy for AI
await page.locator('nav.side .nav-app', { hasText: 'Prism Puddle' }).first().click({ timeout: WAIT_MS });
await settle(page);
await page.locator('[role="tab"]#tab-health').click({ timeout: WAIT_MS });
await settle(page);
await page.locator('.issue-row').first().click({ timeout: WAIT_MS }).catch(() => problems.push('Health: no issue row to open'));
if (!(await shows(page, '[data-testid="issue-stack"]', 'ComboMeter.update(ComboMeter.kt:88)'))) problems.push('Health: the opened issue shows no sample stack');
const det = await text('[data-testid="issue-detail"]');
for (const w of ['ComboMeter.update called after the level ended', 'Copy for AI', 'Open in Play Console', '224 – 231']) if (!det.includes(w)) problems.push(`Health issue detail: missing "${w}"`);
notes.push(`issue detail: ${det.slice(0, 160)}`);
await crashCheck('Health');
} 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}`);
return { name, ok: problems.length === 0, problems: [...new Set(problems)], visited, notes };
}
/**
* Errors the window must say in words (pitfall 57). `api-disabled`: the last health read of the app is Google's live
* 403 SERVICE_DISABLED (Reporting API off in Cloud project 123456789000): the portfolio shows it once for the project,
* the Health tab one line with "Turn it on in Google Cloud" and "Check again", and no '{"error"' text anywhere visible.
* `scan-findings`: `scan --json` exits 1 with its findings (by design): the Checks tab lists them, no "Exit code".
* `scan-failed`: a real failure shows the command's own error line.
*/
async function runErrorCase(browser: Browser, sidecar: Sidecar, variant: 'api-disabled' | 'scan-findings' | 'scan-failed'): Promise<CaseResult> {
const name = `errors (${variant})`;
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<string>();
const logged: string[] = [];
const fx = liveFixture('d3-full');
const base = host(fx, sidecar, unmocked, logged);
const opened: string[] = [];
const answer = async (cmd: string, a: Record<string, unknown>): Promise<unknown> => {
if (variant === 'api-disabled' && cmd === 'cache_get' && a.method === 'health') {
const pkg = 'com.example.' + String(a.projectId).replace(/[^a-z0-9]/gi, '').toLowerCase().slice(0, 20);
return { value: { method: 'health', ok: true, exitCode: 0, dryRun: false, command: 'storebound health --json', output: '', data: healthApiDisabled(pkg) }, at: new Date(Date.now() - 60_000).toISOString() };
}
if (cmd === 'open_external') { opened.push(String(a.url)); return null; }
if (cmd === 'sidecar_call' && a.method === 'scan' && variant !== 'api-disabled') return variant === 'scan-findings' ? SCAN_FINDINGS : SCAN_FAILED;
if (cmd === 'sidecar_call' && a.method === 'health') return { method: 'health', ok: true, exitCode: 0, dryRun: false, command: 'storebound health --json', output: '', data: healthApiDisabled('com.example.app') };
return base(cmd, a);
};
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;
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 noRaw = (where: string, s: string) => {
if (s.includes('{"error"') || s.includes('"status":') || /SERVICE_DISABLED|PERMISSION_DENIED/.test(s)) problems.push(`${where}: raw API error text is visible`);
if (/Exit code \d/.test(s)) problems.push(`${where}: a bare "Exit code N" is visible`);
};
const LINE = `Crash and ANR data needs the Google Play Developer Reporting API turned on in Google Cloud project ${EXAMPLE_CLOUD_PROJECT}.`;
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"]'); }
if (variant === 'api-disabled') {
await shows(page, '#content', 'needs an API turned on');
const pf = await text();
const m = /(\d+) Google Cloud projects? needs? an API turned on: affects ([^.]{0,160}?)(?= Project | Crash |$)/.exec(pf);
notes.push(`portfolio: ${m ? m[0].slice(0, 200) : 'no project notice'}`);
if (!m) problems.push('Portfolio: no "1 Google Cloud project needs an API turned on" notice');
else if (m[1] !== '1') problems.push(`Portfolio: ${m[1]} projects for one Cloud project (want 1)`);
const count = (pf.match(/needs the Google Play Developer Reporting API turned on/g) ?? []).length;
if (count !== 1) problems.push(`Portfolio: the project line shows ${count} times (want once, not per app)`);
noRaw('Portfolio', pf);
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);
if (variant === 'api-disabled') {
await page.locator('[role="tab"]#tab-health').click({ timeout: WAIT_MS }).catch(e => problems.push(`no Health tab: ${String(e).split('\n')[0]}`));
await shows(page, '#content', 'Turn it on in Google Cloud');
await settle(page);
const h = await text();
if (!h.includes(LINE)) problems.push(`Health: no "${LINE}"`);
const turnOn = page.locator('#content button', { hasText: 'Turn it on in Google Cloud' }).first();
if (!(await turnOn.count())) problems.push('Health: no "Turn it on in Google Cloud" button');
if (!(await page.locator('#content button', { hasText: 'Check again' }).count())) problems.push('Health: no "Check again" button');
if (!(await page.locator('#content details.store-error-details:not([open])').count())) problems.push('Health: the raw text is not in a collapsed Details');
noRaw('Health', h);
await turnOn.click({ timeout: WAIT_MS }).catch(() => {});
await settle(page);
const want = `https://console.cloud.google.com/apis/library/playdeveloperreporting.googleapis.com?project=${EXAMPLE_CLOUD_PROJECT}`;
if (!opened.includes(want)) problems.push(`Health: the button opened ${JSON.stringify(opened)} (want ${want})`);
await crashCheck('Health');
visited++;
} else {
await page.locator('[role="tab"]#tab-checks').click({ timeout: WAIT_MS }).catch(e => problems.push(`no Checks tab: ${String(e).split('\n')[0]}`));
await shows(page, '#content', variant === 'scan-findings' ? 'Sign-up without in-app account deletion' : SCAN_FAILED_LINE);
await settle(page);
const c = await text();
if (variant === 'scan-findings') {
if (!c.includes('5.1.1(v) · Sign-up without in-app account deletion')) problems.push('Checks: the scan findings (exit 1) are not listed');
if (!c.includes('lib/auth/sign_up.dart:12')) problems.push('Checks: the finding\'s evidence is not shown');
} else if (!c.includes(SCAN_FAILED_LINE)) problems.push(`Checks: the failure does not show the command's error line "${SCAN_FAILED_LINE}"`);
noRaw('Checks', c);
await crashCheck('Checks');
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.
*/
/** Dry runs of the store writes the editor's Apply panels preview (no store is read: canned plans). */
const APPLY_DRY: Record<string, string> = {
'apple.listing': 'version 2.4.0: keep (PREPARE_FOR_SUBMISSION)\n en-US: update subtitle "Old subtitle" -> "Lists that stay tidy"\n tr: same',
'play.listing': 'app details: same\n en-US: same\n tr-TR: same',
};
/**
* The listing editor end to end on a copy of the demo app: the Listings tab edits a field (the counter and the store
* limit check while typing, the unsaved mark on the tab), "Review and save" shows the plan of the real sidecar
* (project.edit dry run), Save writes the file (the host's confirm token minted like the Rust host after its dialog),
* the fixture file changes and the config does not, then Apply shows the store's dry run (old -> new per language).
* Text checks only; any uncaught error fails.
*/
async function runEditorCase(browser: Browser, sidecar: Sidecar): Promise<CaseResult> {
const name = 'listing editor (edit, counter, check, save, apply dry run)';
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<string>();
const logged: string[] = [];
const dir = mkdtempSync(join(tmpdir(), 'sb-boot-editor-'));
copyExample('demo-app', dir);
const appstore = join(dir, 'store', 'appstore.md');
const before = { md: readFileSync(appstore, 'utf8'), cfg: readFileSync(join(dir, 'storebound.config.ts'), 'utf8') };
const fx = loadFixture('empty');
fx.projects_list = [{ id: 'editor-app', path: dir, name: 'Tidy Lists', playAccount: null, appleAccount: null, added: '2026-10-14T00:00:00Z' }];
const base = host(fx, sidecar, unmocked, logged);
const writes: string[] = [];
const answer = async (cmd: string, a: Record<string, unknown>): Promise<unknown> => {
if (cmd === 'write_with_confirm' && a.method === 'project.edit') {
// the Rust host: dry run, the native dialog (answered "Save" here), a token for exactly this call, the real run
const bound = { project: dir, args: a.args as Record<string, unknown> };
const dryRun = await sidecar.call('project.edit', { ...bound, dryRun: true }) as { ok: boolean };
if (!dryRun.ok) return { confirmed: false, stage: 'dry-run', dryRun };
const result = await sidecar.call('project.edit', { ...bound, dryRun: false, confirmToken: mint(sidecar.secret, 'project.edit', bound) });
writes.push('project.edit');
return { confirmed: true, stage: 'done', dryRun, result };
}
// every other write: the owner presses Cancel in the native dialog (nothing is sent)
if (cmd === 'write_with_confirm') { writes.push(String(a.method)); return { confirmed: false, stage: 'dialog', dryRun: { ...toolResult(String(a.method), null), dryRun: true, output: APPLY_DRY[String(a.method)] ?? '' } }; }
// "Review and save": the window's dry run of project.edit (the Rust host forces dryRun for calls from the window)
if (cmd === 'sidecar_call' && a.method === 'project.edit') return sidecar.call('project.edit', { project: dir, args: (a.params as { args?: unknown } | null)?.args ?? {} });
if (cmd === 'sidecar_call' && APPLY_DRY[String(a.method)] !== undefined) return { ...toolResult(String(a.method), null), dryRun: true, output: APPLY_DRY[String(a.method)] };
return base(cmd, a);
};
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));
const text = async (sel: string) => (await page.locator(sel).first().innerText().catch(() => '')).replace(/\s+/g, ' ');
const field = '[data-edit="listing:apple:en-US:subtitle"]';
const col = '[data-store="apple"][data-lang="en-US"]';
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"]'); }
await page.locator('nav.side .nav-app', { hasText: 'Tidy Lists' }).first().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-listings').click({ timeout: WAIT_MS }).catch(e => problems.push(`no Listings tab: ${String(e).split('\n')[0]}`));
await page.waitForSelector(field, { timeout: WAIT_MS }).catch(() => problems.push('the listing editor has no App Store en-US subtitle field'));
// over the limit: the counter and the check say so while typing
await page.locator(field).fill('Lists that stay tidy and organized forever');
const over = await text(col);
if (!over.includes('42 / 30')) problems.push(`no live counter "42 / 30" (${over.slice(0, 200)})`);
if (!over.includes('subtitle is 42 characters; the App Store takes 30')) problems.push('no limit check while typing');
else notes.push('typing: counter 42 / 30 and "the App Store takes 30"');
if (!(await page.locator('#tab-listings .ed-dot').count())) problems.push('the Listings tab has no unsaved mark');
// undo goes back to the saved text, redo brings the edit back
await page.locator('[data-edit-bar="listings"] button', { hasText: 'Undo' }).click({ timeout: WAIT_MS });
const undone = await page.locator(field).inputValue();
if (undone === 'Lists that stay tidy and organized forever') problems.push('Undo did not change the field');
// a valid value, then review: the real sidecar's dry run in the window
await page.locator(field).fill('Lists that stay tidy');
if (!(await text(col)).includes('20 / 30')) problems.push('the counter does not follow the text (20 / 30)');
await page.locator('[data-edit-bar="listings"] [data-action="review"]').click({ timeout: WAIT_MS });
if (!(await shows(page, '[data-edit-plan]', 'Lists that stay tidy'))) problems.push(`no review plan (${await text('[data-edit-bar="listings"]')})`);
else {
const plan = await text('[data-edit-plan]');
if (!plan.includes('App Store en-US subtitle') || !plan.includes('store/appstore.md')) problems.push(`the plan does not name the field and the file (${plan.slice(0, 300)})`);
else notes.push('review: App Store en-US subtitle -> "Lists that stay tidy" in store/appstore.md');
}
await page.locator('[data-edit-bar="listings"] button', { hasText: 'Save to the app folder' }).click({ timeout: WAIT_MS })
.catch(async e => problems.push(`no enabled Save button: ${String(e).split('\n')[0]} (${(await text('[data-edit-bar="listings"]')).slice(0, 600)})`));
await page.waitForFunction(() => !document.querySelector('#tab-listings .ed-dot'), null, { timeout: WAIT_MS, polling: 50 }).catch(() => problems.push('the unsaved mark stays after Save'));
await settle(page);
const md = readFileSync(appstore, 'utf8');
const changed = md.split('\n').filter((l, i) => l !== before.md.split('\n')[i]);
if (!md.includes('Lists that stay tidy') || changed.length !== 1) problems.push(`store/appstore.md: expected exactly the subtitle line to change, got ${JSON.stringify(changed).slice(0, 300)}`);
else notes.push(`saved: store/appstore.md changed one line (${changed[0]})`);
if (readFileSync(join(dir, 'storebound.config.ts'), 'utf8') !== before.cfg) problems.push('storebound.config.ts changed although only a listing field was edited');
// Apply: the store's dry run (old -> new per language) in the window first, then the confirm dialog
await page.locator('#ed-version').fill('2.4.0').catch(() => problems.push('no version field for the App Store apply'));
await page.locator('[data-apply="apple.listing"] button').first().click({ timeout: WAIT_MS }).catch(e => problems.push(`no App Store apply preview: ${String(e).split('\n')[0]}`));
if (!(await shows(page, '[data-apply="apple.listing"] .ed-dry', 'en-US: update subtitle'))) problems.push('the App Store apply preview does not show the dry run per language');
else notes.push('apply: App Store dry run shows en-US subtitle old -> new');
await page.locator('[data-apply="apple.listing"] button', { hasText: 'Apply to the store' }).click({ timeout: WAIT_MS }).catch(e => problems.push(`no Apply button after the preview: ${String(e).split('\n')[0]}`));
await settle(page);
if (!writes.includes('apple.listing')) problems.push('Apply did not go through the confirm dialog (write_with_confirm)');
// the other editors draw, and a config edit (App info) saves only that value, comments kept
for (const [tab, root] of [['appinfo', '#appinfo-editor'], ['testflight', '#testflight-editor'], ['art', '#art-editor'], ['catalog', '#catalog-editor, [data-edit-bar], .card']] as const) {
await page.locator(`[role="tab"]#tab-${tab}`).click({ timeout: WAIT_MS }).catch(e => problems.push(`no ${tab} tab: ${String(e).split('\n')[0]}`));
if (!(await page.waitForSelector(root, { timeout: WAIT_MS }).then(() => true, () => false))) problems.push(`${tab}: the editor did not draw (${root})`);
await settle(page);
}
await page.locator('[role="tab"]#tab-appinfo').click({ timeout: WAIT_MS });
const notes1 = '[data-edit="config:apple.info.reviewNotes"]';
await page.waitForSelector(notes1, { timeout: WAIT_MS }).catch(() => problems.push('App info: no review notes field'));
await page.locator(notes1).fill('Sign in with the demo account.');
await page.locator('[data-edit-bar="appinfo"] [data-action="review"]').click({ timeout: WAIT_MS });
if (!(await shows(page, '[data-edit-bar="appinfo"] [data-edit-plan]', 'apple.info.reviewNotes'))) problems.push(`App info: no review plan (${(await text('[data-edit-bar="appinfo"]')).slice(0, 400)})`);
await page.locator('[data-edit-bar="appinfo"] button', { hasText: 'Save to the app folder' }).click({ timeout: WAIT_MS })
.catch(async e => problems.push(`App info: no enabled Save button: ${String(e).split('\n')[0]} (${(await text('[data-edit-bar="appinfo"]')).slice(0, 600)})`));
await page.waitForFunction(() => !document.querySelector('#tab-appinfo .ed-dot'), null, { timeout: WAIT_MS, polling: 50 }).catch(() => problems.push('App info: the unsaved mark stays after Save'));
const cfg = readFileSync(join(dir, 'storebound.config.ts'), 'utf8');
const cfgChanged = cfg.split('\n').filter((l, i) => l !== before.cfg.split('\n')[i]);
if (!cfg.includes("reviewNotes: 'Sign in with the demo account.'") || cfgChanged.length !== 1 || !cfg.includes('// storebound config of the demo app')) problems.push(`storebound.config.ts: expected one changed line with reviewNotes and the comments kept, got ${JSON.stringify(cfgChanged).slice(0, 300)}`);
else notes.push(`saved: storebound.config.ts changed one line (${cfgChanged[0]!.trim()})`);
for (const c of (await state(page)).crash) problems.push(`error panel ${c}`);
} finally {
await ctx.close();
server.stop(true);
}
for (const l of logged) (l.startsWith('error') ? problems : notes).push(`webview.log: ${l}`);
return { name, ok: problems.length === 0, problems: [...new Set(problems)], visited: 1, notes };
}
/** art.live of the store-art case: what the stores have live (made-up thumbnails on the store CDNs). */
const LIVE_ART = {
generated: '2026-10-14T09:00:00.000Z',
stores: [{ store: 'apple', ok: true, version: '2.4.0 (READY_FOR_SALE)', images: 3 }, { store: 'play', ok: true, images: 3 }],
icons: [{ store: 'apple', index: 0, type: 'icon', w: 1024, h: 1024, thumb: 'https://is1-ssl.mzstatic.com/image/thumb/example/icon.png/256x256bb.jpg', state: 'missing' }],
sets: [
{ store: 'apple', locale: 'en-US', lang: 'en', kind: 'screenshot', type: 'APP_IPHONE_67', size: 'ios69', label: 'App Store iPhone 6.9"', tiles: [
{ index: 0, type: 'APP_IPHONE_67', w: 1320, h: 2868, thumb: 'https://is1-ssl.mzstatic.com/image/thumb/example/en1.png/138x300bb.jpg', state: 'missing' },
{ index: 1, type: 'APP_IPHONE_67', w: 1320, h: 2868, thumb: 'https://is1-ssl.mzstatic.com/image/thumb/example/en2.png/138x300bb.jpg', state: 'missing' }] },
{ store: 'play', locale: 'en-US', lang: 'en', kind: 'screenshot', type: 'phoneScreenshots', size: 'play', label: 'Play phone', tiles: [
{ index: 0, type: 'phoneScreenshots', w: 540, h: 960, thumb: 'https://lh3.googleusercontent.com/example-en-1=w300', state: 'same', file: 'store/renders/en/play/01.png' },
{ index: 1, type: 'phoneScreenshots', w: 540, h: 960, thumb: 'https://lh3.googleusercontent.com/example-en-2=w300', state: 'differs', file: 'store/renders/en/play/02.png' }] },
],
notes: [], pulled: true,
};
/** The dry run of art.pull the native dialog shows (canned: the boot check reads no store). */
const ART_PULL_DRY = 'add store/renders/en/ios69/ 2 files (01.png .. 02.png) not in the project yet\nconflict store/renders/en/play/02.png your own file differs from the store\ndry run: would write 2 new, 0 changed, 0 removed, 1 same as the store, 1 kept (differ) (run with --write)';
/** A 1x1 PNG as the thumbnail art.thumb answers. */
const THUMB = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGNgYGAAAAAEAAH2FzhVAAAAAElFTkSuQmCC';
/** Copies an example as git has it: the example's own .gitignore entries (a local .storebound/, renders, frames) stay
* out, so a checkout with generated files behaves like CI's clean one. */
function copyExample(name: string, dir: string): void {
const src = join(EXAMPLES, name);
const ignored = (existsSync(join(src, '.gitignore')) ? readFileSync(join(src, '.gitignore'), 'utf8') : '')
.split('\n').map(l => l.trim()).filter(l => l && !l.startsWith('#')).map(l => l.replace(/^\/|\/$/g, ''));
cpSync(src, dir, { recursive: true, filter: f => { const rel = f.slice(src.length + 1).split('\\').join('/'); return !ignored.some(i => rel === i || rel.startsWith(`${i}/`)); } });
}
/**
* The Store art tab of an app folder with pulled art: the live store art (art.live, canned) draws its thumbnails
* (art.thumb data: URLs) with same / differs / not pulled per image, "Pull from stores" goes through the confirm dialog
* with its dry run (answered Cancel), and "Replace with a file" on a pulled render picks a file (image_pick) and runs
* the real sidecar's art.replace dry run (size and checks) before the dialog. Text checks; any uncaught error fails.
*/
async function runLiveArtCase(browser: Browser, sidecar: Sidecar): Promise<CaseResult> {
const name = 'store art (live thumbnails, pull dry run, replace with a file)';
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<string>();
const logged: string[] = [];
const dir = mkdtempSync(join(tmpdir(), 'sb-boot-art-'));
copyExample('demo-app', dir);
// a pulled Play screenshot in the renders folder (a real PNG, so the gallery and the replace dry run read it)
const { encodePng } = await import('../tests/build-fixtures');
const shot = encodePng(540, 960, () => [40, 120, 200], { colorType: 2 });
mkdirSync(join(dir, 'store/renders/en/play'), { recursive: true });
writeFileSync(join(dir, 'store/renders/en/play/01.png'), shot);
writeFileSync(join(dir, 'store/renders/en/play/02.png'), shot);
const picked = join(dir, 'picked-new.png');
writeFileSync(picked, encodePng(540, 960, () => [200, 80, 40], { colorType: 2 }));
const fx = loadFixture('empty');
fx.projects_list = [{ id: 'art-app', path: dir, name: 'Tidy Lists', playAccount: null, appleAccount: null, added: '2026-10-14T00:00:00Z' }];
const base = host(fx, sidecar, unmocked, logged);
const writes: { method: string; args: unknown; dry?: string }[] = [];
const thumbs: string[] = [];
const answer = async (cmd: string, a: Record<string, unknown>): Promise<unknown> => {
if (cmd === 'sidecar_call' && a.method === 'art.live') return toolResult('art.live', LIVE_ART);
if (cmd === 'sidecar_call' && a.method === 'art.thumb') { thumbs.push(String((a.params as { args?: { url?: string } } | null)?.args?.url)); return toolResult('art.thumb', { dataUrl: THUMB, bytes: 68 }); }
if (cmd === 'image_pick') return picked;
if (cmd === 'write_with_confirm') {
const method = String(a.method);
if (method === 'art.replace') {
// the Rust host: the real dry run first (the native dialog shows it), then the owner presses Cancel
const dryRun = await sidecar.call('art.replace', { project: dir, args: a.args as Record<string, unknown>, dryRun: true }) as { ok: boolean; output: string; data?: unknown };
writes.push({ method, args: a.args, dry: JSON.stringify(dryRun.data ?? dryRun.output) });
return { confirmed: false, stage: dryRun.ok ? 'dialog' : 'dry-run', dryRun };
}
writes.push({ method, args: a.args, dry: method === 'art.pull' ? ART_PULL_DRY : undefined });
return { confirmed: false, stage: 'dialog', dryRun: { ...toolResult(method, null), dryRun: true, output: method === 'art.pull' ? ART_PULL_DRY : '' } };
}
return base(cmd, a);
};
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));
const text = async (sel: string) => (await page.locator(sel).first().innerText().catch(() => '')).replace(/\s+/g, ' ');
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"]'); }
await page.locator('nav.side .nav-app', { hasText: 'Tidy Lists' }).first().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-art').click({ timeout: WAIT_MS }).catch(e => problems.push(`no Store art tab: ${String(e).split('\n')[0]}`));
await page.waitForSelector('#live-art', { timeout: WAIT_MS }).catch(() => problems.push('no live store art card'));
// nothing is read from the stores until asked (auto-read is off in the saved state)
await page.locator('#live-art button', { hasText: 'Read the stores' }).click({ timeout: WAIT_MS }).catch(e => problems.push(`no "Read the stores" button: ${String(e).split('\n')[0]}`));
const drawn = await page.waitForFunction(() => document.querySelectorAll('#live-art [data-live-tile] img[src^="data:image/"]').length >= 2, null, { timeout: WAIT_MS, polling: 50 }).then(() => true, () => false);
if (!drawn) problems.push(`live thumbnails not drawn (${(await text('#live-art')).slice(0, 300)})`);
const live = await text('#live-art');
for (const want of ['Live on the stores', 'App Store iPhone 6.9"', 'not pulled yet', 'App Store: 3 images']) if (!live.includes(want)) problems.push(`live art: no "${want}" (${live.slice(0, 300)})`);
await page.locator('#live-art button[role="tab"]', { hasText: 'Google Play' }).click({ timeout: WAIT_MS }).catch(e => problems.push(`no Google Play store switch: ${String(e).split('\n')[0]}`));
if (!(await shows(page, '#live-art', 'yours differs')) || !(await shows(page, '#live-art', 'same as the store'))) problems.push('live art: no same / differs state on the Play set');
if (thumbs.some(u => !/^https:\/\/(is1-ssl\.mzstatic\.com|lh3\.googleusercontent\.com)\//.test(u))) problems.push(`a thumbnail from another host: ${thumbs.join(', ')}`);
else notes.push(`live: ${thumbs.length} thumbnails as data: URLs, same / differs / not pulled per image`);
// Pull from stores: the confirm dialog with the dry run (answered Cancel)
await page.locator('#live-art button', { hasText: 'Pull from stores' }).click({ timeout: WAIT_MS }).catch(e => problems.push(`no "Pull from stores" button: ${String(e).split('\n')[0]}`));
await settle(page);
const pull = writes.find(x => x.method === 'art.pull');
if (!pull) problems.push('Pull from stores did not go through the confirm dialog (write_with_confirm art.pull)');
else notes.push(`pull: dialog with the dry run (${pull.dry!.split('\n').pop()})`);
// Replace with a file: the pulled render's lightbox, the picker, the real art.replace dry run
const tile = page.locator('.art-rows .art-tile button.art-pic').first();
await tile.click({ timeout: WAIT_MS }).catch(async e => problems.push(`no rendered tile to open: ${String(e).split('\n')[0]} (${(await text('#tabpanel')).slice(0, 300)})`));
await page.locator('[data-action="replace-image"]').click({ timeout: WAIT_MS }).catch(e => problems.push(`no "Replace with a file" in the lightbox: ${String(e).split('\n')[0]}`));
await settle(page);
const rep = writes.find(x => x.method === 'art.replace');
if (!rep) problems.push('Replace with a file did not reach the confirm dialog (write_with_confirm art.replace)');
else {
const args = rep.args as { from?: string; to?: string };
if (args.to !== 'store/renders/en/play/01.png' || args.from !== picked) problems.push(`art.replace args: ${JSON.stringify(args)}`);
if (!/"new":\{"w":540,"h":960/.test(rep.dry ?? '')) problems.push(`art.replace dry run: ${rep.dry?.slice(0, 300)}`);
else notes.push('replace: picked file -> store/renders/en/play/01.png, the sidecar dry run 540x960 -> 540x960 (dialog: Cancel)');
}
if (readFileSync(join(dir, 'store/renders/en/play/01.png')).compare(shot) !== 0) problems.push('the render changed although the dialog was cancelled');
for (const c of (await state(page)).crash) problems.push(`error panel ${c}`);
} 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: 1, notes };
}
async function launch(): Promise<{ browser: Browser; kill: () => Promise<void> }> {
const { chromium } = await import('playwright');
const opts = { headless: true, timeout: 60_000 };
let server: Awaited<ReturnType<typeof chromium.launchServer>> | 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<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();
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<CaseResult>)[] = [];
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));
for (const v of ['api-disabled', 'scan-findings', 'scan-failed'] as const) if (!ONLY.length || ONLY.includes('errors') || ONLY.includes(`errors-${v}`)) cases.push(() => runErrorCase(browser, sidecar, v));
if (!ONLY.length || ONLY.includes('auto-read')) cases.unshift(() => runAutoReadCase(browser));
if (!ONLY.length || ONLY.includes('editor')) cases.push(() => runEditorCase(browser, sidecar));
if (!ONLY.length || ONLY.includes('art')) cases.push(() => runLiveArtCase(browser, sidecar));
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);
}