desktop: Releases > Send for review (App Store submission state, builds by number, Submit / Resubmit / in-app purchases; Play versionCodes by number, Send to production, managed publishing, console plans for countries and Send changes for review), plan panel before every owner dialog with streamed progress; App info console steps for every console plan form with verify; Catalog price-change owner actions and the declined state; Data safety deletion answer; TestFlight create missing groups; Android SDK in Dependencies; upload progress in job toasts; boot check send-for-review cases on D1 and D3 state

This commit is contained in:
CRTkafa 2026-10-12 12:07:59 +08:00
parent 33eb24a3dc
commit 2b101f0fd8
22 changed files with 1527 additions and 57 deletions

View file

@ -7,12 +7,18 @@
// 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]
// Uses playwright: Google Chrome when installed (channel "chrome"), else playwright's Chromium
// (bunx playwright install chromium). Part of `bun run ci` and of `tools/desktop-release.ts mac`.
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
import { extname, join, resolve } from 'node:path';
import type { Browser, Page } from 'playwright';
import { APPLE_STATUS_READY, APPLE_STATUS_UNRESOLVED, ownerDry, PLAY_STATUS } from '../apps/desktop/src/dev/submit-fixtures';
const ROOT = resolve(import.meta.dir, '..');
const APP = join(ROOT, 'apps/desktop');
@ -90,7 +96,12 @@ export function loadFixture(name: string): HostFixture {
return JSON.parse(text) as HostFixture;
}
function host(fx: HostFixture, sidecar: Sidecar, unmocked: Set<string>, logged: string[]) {
/** 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> => {
@ -102,7 +113,16 @@ function host(fx: HostFixture, sidecar: Sidecar, unmocked: Set<string>, logged:
case 'accounts_list': return fx.accounts_list;
case 'workspace_get': return fx.workspace_get;
case 'store_apps_cached': return fx.accounts_list.map(x => ({ account: x.id, entry: null }));
case 'cache_get': return null;
case '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!;
@ -120,6 +140,13 @@ function host(fx: HostFixture, sidecar: Sidecar, unmocked: Set<string>, logged:
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);
@ -257,6 +284,134 @@ async function runCase(browser: Browser, name: string, sidecar: Sidecar, o: { br
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: 15000 }).catch(() => problems.push('React never drew the window (no data-booted)'));
await page.waitForTimeout(1200);
const wizard = page.locator('.wizard[role="dialog"]');
if (await wizard.count()) { await wizard.press('Escape').catch(() => {}); await page.waitForTimeout(300); }
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: 8000 }).catch(e => problems.push(`could not open the app: ${String(e).split('\n')[0]}`));
await page.waitForTimeout(800);
await page.locator('[role="tab"]#tab-releases').click({ timeout: 5000 }).catch(() => {});
await page.waitForSelector('[data-section="submit"]', { timeout: 8000 }).catch(() => problems.push('Releases: no send-for-review section'));
await page.waitForTimeout(600);
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: 5000 });
await page.waitForSelector('.oa-panel', { timeout: 8000 }).catch(() => problems.push('no plan panel after the submit button'));
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: 5000 }).catch(e => problems.push(`Continue: ${String(e).split('\n')[0]}`));
await page.waitForTimeout(500);
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 page.waitForTimeout(300);
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: 5000 });
await page.waitForSelector('.oa-panel', { timeout: 8000 }).catch(() => problems.push('no plan panel after Send to production'));
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 page.waitForTimeout(300);
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: 5000 });
await page.waitForSelector('.cp-panel', { timeout: 10000 }).catch(() => problems.push('no console plan panel'));
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 page.waitForTimeout(300);
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: 5000 }).catch(e => problems.push(`tab ${id}: ${String(e).split('\n')[0]}`));
await page.waitForTimeout(500);
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: 5000 }).catch(() => {});
await page.waitForTimeout(800);
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 };
}
async function launch(): Promise<Browser> {
const { chromium } = await import('playwright');
try { return await chromium.launch({ channel: 'chrome', headless: true }); } catch { /* no Google Chrome */ }
@ -274,6 +429,8 @@ export async function bootCheck(): Promise<CaseResult[]> {
try {
for (const n of names) if (!ONLY.length || ONLY.includes(n)) results.push(await runCase(browser, n, sidecar));
if (!ONLY.length || ONLY.includes('missing-bundle')) results.push(await runCase(browser, 'missing-bundle', sidecar, { breakBundle: true }));
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}`)) results.push(await runSubmitCase(browser, sidecar, base, v));
} finally {
await browser.close();
sidecar.stop();