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:
parent
f2d7062bcb
commit
9ba8471002
16 changed files with 390 additions and 178 deletions
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue