ci speed: desktop boot check runs its cases in parallel (one browser, killed at the end; a page per case; condition waits instead of fixed sleeps: element, text or a quiet window of host calls and DOM); shots capture / render close the browser with a 3 s cap, then kill it; console engine waits injectable for tests (WAITS / engineWaits: poll rounds, a missing step ends once its loaded page stays quiet, team switch settle, field-miss grace), fillPage looks up each data type's dialog once per round and skips dialog sections when no dialog is open; browser tests run 4 at a time per file (testScope().concurrent with a scope per test, --max-concurrency in ci), 20 ms rounds; capture-web counts boots per test and runs its browser tests concurrently; console-session / -persist Chrome tests concurrent; recorded test timings smoothed (mergeTimings) so one loaded run does not skew the shard split

This commit is contained in:
CRTkafa 2026-10-13 02:20:03 +08:00
parent f2d7062bcb
commit 9ba8471002
16 changed files with 390 additions and 178 deletions

View file

@ -25,7 +25,7 @@ import { dirname, join } from 'node:path';
import type { ShipConfig } from '../lib/config';
import type { FormId } from '../lib/console-answers';
import { hasToken, type Name, NAV_BASES, type NavBases, type NavStep, navPlan, playAppIdOf, playDeveloperId, switchAllowed } from '../lib/console-forms';
import { ACTION_MS, accName, findAny, type Locator, navClick, navigate, type NavResult, type Page, poll, realRole, safeClick, signInPage, SignInSeen, type Stop, vis } from './fill';
import { ACTION_MS, accName, findAny, type Locator, navClick, navigate, WAITS, type NavResult, type Page, poll, realRole, safeClick, signInPage, SignInSeen, type Stop, vis } from './fill';
// ---- the cache ----
@ -141,7 +141,7 @@ export async function readChooser(page: Page): Promise<PlayAccount[]> {
*/
export async function playAccounts(page: Page, playBase: string, ms: number, stop?: Stop): Promise<PlayAccount[]> {
await page.goto(`${playBase.replace(/\/$/, '')}/u/0/developers`, { waitUntil: 'domcontentloaded' }).catch(() => { /* read what is there */ });
const found = await poll(ms, async () => { const r = await readChooser(page); return r.length ? r : null; }, stop);
const found = await poll(ms, async () => { const r = await readChooser(page); return r.length ? r : null; }, stop, page);
if (found) return found;
const here = playDeveloperId(page.url());
return here ? [{ id: here, name: '' }] : [];
@ -185,7 +185,7 @@ async function accountId(page: Page, a: PlayAccount, listed: string[], ms: numbe
const role = await realRole(row).catch(() => '');
if (!switchAllowed(a.name, ['link', 'menuitem', 'menuitemradio', 'option', 'radio', 'row', 'button'].includes(role) ? role : 'row', listed)) return null;
await row.click({ timeout: ACTION_MS });
return poll(ms, async () => playDeveloperId(page.url()), stop);
return poll(ms, async () => playDeveloperId(page.url()), stop, page);
}
// ---- App Store Connect: teams ----
@ -197,7 +197,7 @@ const CANCEL: Name[] = ['Cancel', 'İptal', 'Vazgeç'];
interface TeamItem { name: string; loc: Locator; role: string; current: boolean }
async function teamMenu(page: Page, ms: number, stop?: Stop): Promise<TeamItem[] | null> {
const t = await poll(ms, async () => { const f = await findAny(page, ['button'], ACCOUNT_MENU); return f.state === 'ok' ? f.loc : null; }, stop);
const t = await poll(ms, async () => { const f = await findAny(page, ['button'], ACCOUNT_MENU); return f.state === 'ok' ? f.loc : null; }, stop, page);
if (!t || !(await navClick(t)).clicked) return null;
return poll(Math.min(ms, 3000), async () => {
const menus = vis(page.getByRole('menu'));
@ -213,7 +213,7 @@ async function teamMenu(page: Page, ms: number, stop?: Stop): Promise<TeamItem[]
}
}
return out.length ? out : null;
}, stop);
}, stop, page);
}
const closeMenu = async (page: Page) => { await page.keyboard.press('Escape').catch(() => { /* gone */ }); };
@ -235,7 +235,7 @@ export async function switchTeam(page: Page, name: string, listed: string[], ms:
if (!switchAllowed(real, role, listed)) { await closeMenu(page); return false; }
await it.loc.click({ timeout: ACTION_MS });
await page.waitForLoadState('domcontentloaded', { timeout: ms }).catch(() => { /* same page, or slow: read what is there */ });
await Bun.sleep(300);
await Bun.sleep(WAITS.settleMs); // the switched team's page settles
return true;
}

View file

@ -33,7 +33,7 @@ import type { Ctx } from '../lib/ctx';
import { type Answer, type ConsoleResult, consoleResult, DISCLAIMER, FORM_IDS, type FormId, fillValue, formMeta, isForm, valueText } from '../lib/console-answers';
import type { ShipConfig } from '../lib/config';
import { APPLE_PRIVACY_TYPES } from '../lib/app-privacy';
import { ALL_APPS, APP_PRIVACY_HEADING, consoleReached, CREATE_FORM_FIELD, createAppAllowed, DATA_TYPES_HEADING, type FieldDef, FLOW_NAMES, flowAllowed, flowClass, type FlowWhere, type FormDef, formDef, FORM_OPENERS, forbiddenControl, quoteTolerant, forbiddenTick, hasToken, isAppleSignInFrame, isSignInText, isSignInUrl, type Name, navAllowed, type NavBases, type NavStep, navPlan, setUpAllowed, setUpTexts, typeLabels } from '../lib/console-forms';
import { ALL_APPS, APP_PRIVACY_HEADING, consoleReached, CREATE_FORM_FIELD, createAppAllowed, DATA_TYPES_HEADING, type FieldDef, FLOW_NAMES, flowAllowed, flowClass, type FlowWhere, type FormDef, formDef, FORM_OPENERS, forbiddenControl, quoteTolerant, forbiddenTick, hasToken, isAppleSignInFrame, isSignInText, isSignInUrl, type Name, navAllowed, type NavBases, type NavStep, navPlan, type SectionDef, setUpAllowed, setUpTexts, typeLabels } from '../lib/console-forms';
import { log, printJson, warn } from '../lib/log';
import { openAcross, withAccountIds } from './accounts';
import { appleAppId, withAppleAppId } from './app-id';
@ -269,6 +269,25 @@ export interface FillStep {
pending?: Locator[][];
}
/** The dialog of a section: by its names, else the one visible dialog with such a heading, else (the data type
* storebound just opened) the one visible dialog. */
async function sectionDialog(page: Page, sec: SectionDef, current?: string): Promise<Found> {
const names = sec.dialog ?? [];
let d = await find(page, 'dialog', names);
// a dialog titled otherwise (one dialog per step, the title elsewhere): the one visible dialog with such a heading
if (d.state === 'none') for (const n of names) {
const l = vis(page.getByRole('dialog').filter({ has: page.getByRole('heading', nameOpts(n)) }));
const c = await l.count();
if (c) { d = c === 1 ? { state: 'ok', loc: l } : { state: 'ambiguous' }; break; }
}
// the data type storebound just opened (App Privacy): its dialog may be titled otherwise; the one visible dialog
if (d.state === 'none' && sec.type && sec.type === current) {
const l = vis(page.getByRole('dialog'));
if (await l.count() === 1) d = { state: 'ok', loc: l };
}
return d;
}
/**
* Fills the first section of the form that is on the page and not done yet: locate every field first; any missing or
* ambiguous field -> fallback (nothing of that section is touched). Certain answers are set, the rest is highlighted
@ -276,22 +295,19 @@ export interface FillStep {
*/
export async function fillPage(page: Page, def: FormDef, answers: Answer[], done: Set<number> = new Set(), partial = false, current?: string): Promise<FillStep> {
const byId = new Map(answers.map(a => [a.id, a]));
// the sections of one data type share their dialog names (one array): the dialog is looked up once per call, not
// once per section (App Privacy: 3 sections x 6 types, each lookup several page queries)
const dialogs = new Map<Name[], Found>();
// no visible dialog at all (the page between two data types): every dialog section is skipped with one query
let anyDialog: boolean | undefined;
for (const [i, sec] of def.sections.entries()) {
if (done.has(i)) continue;
let root: Locator = page;
if (sec.dialog) {
let d = await find(page, 'dialog', sec.dialog);
// a dialog titled otherwise (one dialog per step, the title elsewhere): the one visible dialog with such a heading
if (d.state === 'none') for (const n of sec.dialog) {
const l = vis(page.getByRole('dialog').filter({ has: page.getByRole('heading', nameOpts(n)) }));
const c = await l.count();
if (c) { d = c === 1 ? { state: 'ok', loc: l } : { state: 'ambiguous' }; break; }
}
// the data type storebound just opened (App Privacy): its dialog may be titled otherwise; the one visible dialog
if (d.state === 'none' && sec.type && sec.type === current) {
const l = vis(page.getByRole('dialog'));
if (await l.count() === 1) d = { state: 'ok', loc: l };
}
anyDialog ??= await vis(page.getByRole('dialog')).count() > 0;
if (!anyDialog) continue;
let d = dialogs.get(sec.dialog);
if (!d) { d = await sectionDialog(page, sec, current); dialogs.set(sec.dialog, d); }
if (d.state === 'none') continue;
if (d.state !== 'ok') return { section: i, title: sec.title, filled: [], highlighted: [], missing: [`dialog ${sec.title}`], fallback: true };
root = d.loc;
@ -624,16 +640,53 @@ export class SignInSeen extends Error {}
export type Stop = () => Promise<boolean>;
/**
* Polls `fn` until it returns a value or the time is up (errors while the page navigates count as "not yet"). `stop`
* is checked first on every round: a sign-in page ends the wait at once (SignInSeen).
* The engine's internal waits that tests shorten (engineWaits); production keeps these defaults.
* pollMs the pause between two poll rounds
* quietMs 0 (production): a poll waits its whole time for something that is not there. Above 0 (tests, on local
* fixture pages): a poll also ends, as "not found", once the page has finished loading and its DOM has not
* changed for quietMs (a MutationObserver the poll puts into the page). A missing step then costs about
* quietMs instead of the whole step timeout, and a step that is there is still waited for as long as needed.
* settleMs the pause after a click that loads another page in place (switching the App Store Connect team)
* missMs fillSession: how long a field must stay missing (and at least 3 rounds) before it counts as a UI change
*/
export async function poll<T>(ms: number, fn: () => Promise<T | null>, stop?: Stop): Promise<T | null> {
export const WAITS = { pollMs: 200, quietMs: 0, settleMs: 300, missMs: 1500 };
/** Tests: sets engine waits (WAITS); returns the function that restores the previous ones. */
export function engineWaits(w: Partial<typeof WAITS>): () => void {
const before = { ...WAITS };
Object.assign(WAITS, w);
return () => { Object.assign(WAITS, before); };
}
/** How long the page has been loaded and unchanged (ms; 0 while it loads or is leaving for another page). Puts the
* observer in on the first call. */
async function quietFor(page: Page): Promise<number> {
return page.evaluate(() => {
const w = window as any;
if (!w.__storeboundQuiet) {
const q = { at: performance.now(), leaving: false };
w.__storeboundQuiet = q;
new MutationObserver(() => { q.at = performance.now(); }).observe(document, { subtree: true, childList: true, attributes: true, characterData: true });
// a click or script that starts a navigation: the old page stays "complete" until the new one commits
addEventListener('beforeunload', () => { q.leaving = true; });
}
const q = w.__storeboundQuiet;
return document.readyState === 'complete' && !q.leaving ? performance.now() - q.at : 0;
}).catch(() => 0);
}
/**
* Polls `fn` until it returns a value or the time is up (errors while the page navigates count as "not yet"). `stop`
* is checked first on every round: a sign-in page ends the wait at once (SignInSeen). With `page` and WAITS.quietMs
* (tests), a loaded page that stays unchanged for quietMs ends it early too (not found).
*/
export async function poll<T>(ms: number, fn: () => Promise<T | null>, stop?: Stop, page?: Page): Promise<T | null> {
const end = Date.now() + ms;
for (;;) {
if (stop && await stop().catch(() => false)) throw new SignInSeen();
try { const r = await fn(); if (r !== null) return r; } catch { /* navigating */ }
if (Date.now() >= end) return null;
await Bun.sleep(200);
if (page && WAITS.quietMs > 0 && await quietFor(page) >= WAITS.quietMs) return null;
await Bun.sleep(WAITS.pollMs);
}
}
@ -648,7 +701,7 @@ const opener = new RegExp(`^(?:${FORM_OPENERS.join('|')})$`);
export async function pickApp(page: Page, s: Extract<NavStep, { kind: 'pickApp' }>, ms: number, stop?: Stop): Promise<{ loc?: Locator; candidates?: string[] } | null> {
if (s.search && s.tokens[0]) {
const box = await poll(Math.min(ms, 3000), async () => { const f = await findAny(page, ['searchbox', 'textbox', 'combobox'], s.search!); return f.state === 'ok' ? f.loc : null; }, stop);
const box = await poll(Math.min(ms, 3000), async () => { const f = await findAny(page, ['searchbox', 'textbox', 'combobox'], s.search!); return f.state === 'ok' ? f.loc : null; }, stop, page);
if (box) { await box.fill(s.tokens[0]); }
}
return poll(ms, async () => {
@ -677,7 +730,7 @@ export async function pickApp(page: Page, s: Extract<NavStep, { kind: 'pickApp'
if (hits.length > 1) return { candidates: hits.map(h => h.name) };
}
return null;
}, stop);
}, stop, page);
}
async function formLink(page: Page, entry: Name[], ms: number, stop?: Stop): Promise<{ loc?: Locator; ambiguous?: boolean } | null> {
@ -704,7 +757,7 @@ async function formLink(page: Page, entry: Name[], ms: number, stop?: Stop): Pro
}
if (found.length === 1) return { loc: found[0]!.loc };
return found.length > 1 ? { ambiguous: true } : null;
}, stop);
}, stop, page);
}
/** The wait target is on the page (a heading of `level` when given). */
@ -750,7 +803,7 @@ export async function navigate(page: Page, plan: NavStep[], opts: NavOpts = {}):
try { await page.goto(s.url, { waitUntil: 'domcontentloaded' }); } catch (e) { return fail(s, `could not load ${s.url} (${(e as Error).message.split('\n')[0]})`); }
} else if (s.kind === 'click') {
if (s.optional && s.unless && (await findAny(page, s.unless.roles, s.unless.names)).state !== 'none') { say(' (not needed)'); continue; }
const f = await poll(s.optional ? Math.min(ms, 2000) : ms, async () => { const r = await findAny(page, s.roles, s.names); return r.state === 'none' ? null : r; }, stop);
const f = await poll(s.optional ? Math.min(ms, 2000) : ms, async () => { const r = await findAny(page, s.roles, s.names); return r.state === 'none' ? null : r; }, stop, page);
if (!f) { if (s.optional) { say(' (not on this page, skipped)'); continue; } return fail(s, 'not found'); }
if (f.state !== 'ok') return fail(s, 'more than one match');
const c = await navClick(f.loc, !!s.createApp);
@ -769,7 +822,7 @@ export async function navigate(page: Page, plan: NavStep[], opts: NavOpts = {}):
const c = await navClick(r.loc);
if (!c.clicked) return fail(s, `refused to click "${c.name}"`);
} else {
const f = await poll(ms, async () => ((await present(page, s)) ? true : null), stop);
const f = await poll(ms, async () => ((await present(page, s)) ? true : null), stop, page);
if (!f) return fail(s, 'did not appear');
}
} catch (e) {
@ -1277,8 +1330,8 @@ export async function fillSession(b: ConsoleBrowser, o: FillSessionOpts): Promis
let s: FillStep;
try { s = await fillPage(p, def, pageAnswers, done, false, currentType); } catch { await Bun.sleep(tick); continue; } // navigating
if (s.section !== null) {
// a page still loading can miss a field for a moment: only a miss that lasts (3 rounds, at least 1.5 s) is a UI change
if (s.fallback && (++misses < 3 || Date.now() - firstMiss < 1500)) { if (misses === 1) firstMiss = Date.now(); await Bun.sleep(tick); continue; }
// a page still loading can miss a field for a moment: only a miss that lasts (3 rounds, at least WAITS.missMs) is a UI change
if (s.fallback && (++misses < 3 || Date.now() - firstMiss < WAITS.missMs)) { if (misses === 1) firstMiss = Date.now(); await Bun.sleep(tick); continue; }
misses = 0;
// the miss lasts: fill every field that was found, leave the missing ones to the owner (only all-missing is a UI change)
if (s.fallback) { try { s = await fillPage(p, def, pageAnswers, done, true, currentType); } catch { await Bun.sleep(tick); continue; } }
@ -1297,7 +1350,7 @@ export async function fillSession(b: ConsoleBrowser, o: FillSessionOpts): Promis
if (form === 'play-app-record' && s.filled.includes('packageName')) {
const check = await controlWhere(p, FLOW_NAMES.checkAvailability, () => true, dataTypes).catch(() => null);
if (check && await flowClick(check, form, dataTypes, say).catch(() => false)) {
const ok = await poll(5000, async () => ((await vis(p.getByText(/package name (is )?available/i)).count()) > 0 ? true : null)).catch(() => null);
const ok = await poll(5000, async () => ((await vis(p.getByText(/package name (is )?available/i)).count()) > 0 ? true : null), undefined, p).catch(() => null);
say(ok ? 'App package name: "Package name available"' : 'App package name: Play Console did not say "Package name available": check the message next to the field');
}
}

View file

@ -175,6 +175,32 @@ export async function launch(root: string, opts: Record<string, unknown>, deps:
return withCandidates(kind, deps.installed ?? installedBrowsers(c), o => c.launch({ ...opts, ...o }));
}
/** How long a launched (throwaway profile) browser gets to close gracefully before it is killed. */
export const CLOSE_GRACE_MS = 3000;
/**
* Ends a browser from `launch` once the work is done: a graceful close capped at `ms`, then SIGKILL of its process.
* Google Chrome often hangs 10-30 s in its own shutdown after http pages (tests/helpers/browser.ts); a throwaway
* profile has nothing to flush, so waiting for it only costs time. The pid is read over CDP before the close. Never
* throws.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export async function closeBrowser(browser: any, ms = CLOSE_GRACE_MS): Promise<void> {
let pid: number | undefined;
try {
const s = await browser.newBrowserCDPSession();
const info = await s.send('SystemInfo.getProcessInfo') as { processInfo: { type: string; id: number }[] };
pid = info.processInfo.find(p => p.type === 'browser')?.id;
} catch { /* not Chromium, or already gone */ }
let t: ReturnType<typeof setTimeout> | undefined;
const closed = await Promise.race([
Promise.resolve().then(() => browser.close()).then(() => true, () => true),
new Promise<boolean>(r => { t = setTimeout(() => r(false), ms); }),
]);
clearTimeout(t);
if (!closed && pid) try { process.kill(pid, 'SIGKILL'); } catch { /* exited meanwhile */ }
}
/**
* A persistent browser profile (cookies of a console sign-in the owner did by hand stay there between runs; storebound
* never reads them). Used by `storebound console fill`: always a real Chrome / Chromium (kind "visible", also when a

View file

@ -29,7 +29,7 @@ import type { ShipConfig, ShotsCapture } from '../lib/config';
import type { Ctx } from '../lib/ctx';
import { need } from '../lib/ctx';
import { done, log, step, warn } from '../lib/log';
import { launch } from './browser';
import { closeBrowser, launch } from './browser';
import { checkFrameFolder } from './check';
import { adbAppLocaleArgs, adbDemoExitArgs, adbDemoModeArgs, adbForceStopArgs, adbInstallArgs, adbScreencapArgs, adbStartArgs, adbStateArgs, adbTarget,
cropArgs, cropTopFor, fill, pickSimulator, shotsFor, simBootArgs, simBootStatusArgs, simInstallArgs, simLaunchArgs, simOpenUrlArgs,
@ -138,7 +138,7 @@ export async function run(ctx: Ctx): Promise<void> {
if (todo.length) {
const browser = await launch(root, { headless: true, args: ['--mute-audio', '--ignore-gpu-blocklist', '--enable-webgl', '--enable-gpu'] });
let report;
try { report = await captureWeb(browser, cap, todo, { concurrency, dims }); } finally { await browser.close(); }
try { report = await captureWeb(browser, cap, todo, { concurrency, dims }); } finally { await closeBrowser(browser); } // capped close, then kill: Chrome can hang 10-30 s in its shutdown
report.skipped = skipped.map(j => j.frame);
writeCaptureState(root, report, cache, todo);
for (const l of summaryLines(report)) log('shots', l);

View file

@ -21,7 +21,7 @@ import { join, relative } from 'node:path';
import type { Ctx } from '../lib/ctx';
import { need } from '../lib/ctx';
import { done, log, warn } from '../lib/log';
import { launch } from './browser';
import { closeBrowser, launch } from './browser';
import { defaultConcurrency, pool, progressLine } from './capture-web';
import { shotsStyle } from '../lib/config';
import { type CopyFile, SIZES, copyText, defaultSizes, frameDims, mergeCopy, palette, renderDir, scriptFont, sheetLayout, validateCopy, variantScenes } from './lib';
@ -239,7 +239,7 @@ export async function run(ctx: Ctx): Promise<void> {
});
} finally {
for (const p of pages) await p?.page.context().close().catch(() => {});
await browser.close();
await closeBrowser(browser);
server.stop(true);
if (Object.keys(report).length) await Bun.write(join(outDir, 'report.json'), JSON.stringify(mergeReport(readReport(outDir), report), null, 1) + '\n');
}