A real `console fill apple-app-privacy` clicked Next onto a console page the engine did not recognise and logged nothing for 8 hours. - Step watchdog: after a click, or while the form is expected, no known page within --step-timeout seconds (default 30) -> "the page after "Next" is not a page storebound knows ... Buttons there: ...", a debug dump, and the manual steps. It keeps watching, but 20 min without progress closes the window and exits 1. - A heartbeat line every minute while waiting (also during sign-in); the final review wait is bounded (2 h) with a reminder every 10 min. - Debug dump (cli/console/debug.ts) in .storebound/console-debug/ <form>-<time>/: page.json (URL without query, headings, dialogs, every visible control with role and name), page.html (rebuilt from a clone: no scripts, values, hidden inputs or non-whitelisted attributes) and a small screenshot. Emails, phone numbers and the header / account area are replaced; cookies, storage and tokens are never read. --debug-dump writes one at every step for bug reports. - Guardrail tests: an unknown-page fixture (dump sanitized, bounded exit) and a scan of cli/console for while loops without a deadline and Playwright waits without a timeout (one waitForLoadState fixed). - Pitfall 14 (docs/pitfalls.md, store-pitfalls skill); the console-autofill skill says how to send a dump.
957 lines
56 KiB
TypeScript
957 lines
56 KiB
TypeScript
// AI-assisted autofill of console forms that have no API, in a visible Chromium the owner signs in to:
|
|
// storebound console fill lists the forms
|
|
// storebound console fill <form> --dry [--json] the answers and the step-by-step guidance; opens no browser
|
|
// storebound console fill <form> [--store apple|play] [--json] [--profile <dir>]
|
|
// Forms: apple-app-record, apple-age-rating (guide only: `storebound apple appinfo` sets it), apple-app-privacy,
|
|
// play-app-record (Play Console's Create app form: the Play Developer API cannot create apps), play-content-rating,
|
|
// play-target-audience. Answers come from the config only (cli/lib/console-answers.ts); anything
|
|
// it does not prove stays EMPTY and highlighted (AGENTS.md rule 5).
|
|
// Safety: storebound never types passwords, never touches sign-in pages (it waits while the owner signs in), never reads
|
|
// cookies or storage, and never clicks Save / Submit / Publish / Create / Next: every click goes through safeClick and
|
|
// its gate. After sign-in it opens the form itself (navPlan in cli/lib/console-forms.ts: Apps > + > New App, the app's
|
|
// App Privacy page, Play app list > the app > App content > Start / Manage); those clicks go through navClick, which
|
|
// applies the same gate (only a link named exactly Start / Manage / Başla / Yönet is let through). A sign-in page at
|
|
// any point (signInPage: URL, frames, texts) -> storebound waits for the owner, then starts again at step 1. A step not
|
|
// found in time -> the printed steps, and storebound keeps watching (fillSession). With several Play developer
|
|
// accounts or App Store Connect teams behind one login, the account that has the app is found and remembered
|
|
// (cli/console/accounts.ts). The owner reviews each page, clicks Next, and presses the
|
|
// final button; the command ends when the window is closed. Locators are accessible names only
|
|
// (cli/lib/console-forms.ts); a missing or ambiguous field means the UI changed: filling stops and the guidance is
|
|
// printed instead. Not exposed over MCP.
|
|
// Browser (cli/console/session.ts): the owner's running Chrome (--cdp / remote debugging), else a temporary copy of only
|
|
// their Chrome sign-in cookies (macOS; deleted at the end), else storebound's own profile
|
|
// (~/.storebound/console-profile/<store>, or --profile / STOREBOUND_CONSOLE_PROFILE / --fresh-profile).
|
|
// Sources (checked October 2026): see cli/lib/console-answers.ts; Playwright launchPersistentContext, getByRole,
|
|
// getByLabel: https://playwright.dev/docs/api/class-browsertype#browser-type-launch-persistent-context ,
|
|
// https://playwright.dev/docs/locators
|
|
import { homedir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
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 { 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, hasToken, isSignInText, isSignInUrl, type Name, navAllowed, type NavBases, type NavStep, navPlan } from '../lib/console-forms';
|
|
import { log, printJson, warn } from '../lib/log';
|
|
import { openAcross, withAccountIds } from './accounts';
|
|
import { appleAppId, withAppleAppId } from './app-id';
|
|
import { type ConsoleBrowser, openConsoleBrowser } from './session';
|
|
|
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
export type Page = any;
|
|
export type Locator = any;
|
|
|
|
const TAG = 'console';
|
|
const CHECK = 'storebound: check this';
|
|
|
|
/** Locators with only the visible matches (hidden earlier pages stay in many consoles' DOM). */
|
|
export const vis = (l: Locator): Locator => { try { return l.filter({ visible: true }); } catch { return l; } };
|
|
const nameOpts = (n: Name) => (typeof n === 'string' ? { name: n, exact: true } : { name: n });
|
|
|
|
type Found = { state: 'ok'; loc: Locator } | { state: 'none' | 'ambiguous' };
|
|
|
|
async function first(tries: Locator[]): Promise<Found> {
|
|
for (const t of tries) {
|
|
const l = vis(t);
|
|
const n = await l.count();
|
|
if (n === 1) return { state: 'ok', loc: l };
|
|
if (n > 1) return { state: 'ambiguous' };
|
|
}
|
|
return { state: 'none' };
|
|
}
|
|
|
|
async function find(root: Locator, role: string, names: Name[], label = false): Promise<Found> {
|
|
for (const n of names) {
|
|
const tries = [root.getByRole(role, nameOpts(n))];
|
|
if (label) tries.push(root.getByLabel(n, typeof n === 'string' ? { exact: true } : {}));
|
|
const f = await first(tries);
|
|
if (f.state !== 'none') return f;
|
|
}
|
|
return { state: 'none' };
|
|
}
|
|
|
|
interface Resolved { field: FieldDef; ok: boolean; seen: boolean; loc?: Locator; options?: Record<string, Locator> }
|
|
|
|
async function resolve(root: Locator, f: FieldDef): Promise<Resolved> {
|
|
if (f.kind !== 'radio') {
|
|
const role = f.kind === 'text' ? 'textbox' : f.kind === 'select' ? 'combobox' : 'checkbox';
|
|
const r = await find(root, role, f.names ?? [], f.kind !== 'checkbox');
|
|
return { field: f, ok: r.state === 'ok', seen: r.state !== 'none', ...(r.state === 'ok' ? { loc: r.loc } : {}) };
|
|
}
|
|
let scope = root, seen = false;
|
|
if (f.names?.length) {
|
|
let g = await find(root, 'radiogroup', f.names);
|
|
if (g.state === 'none') g = await find(root, 'group', f.names);
|
|
if (g.state === 'ambiguous') return { field: f, ok: false, seen: true };
|
|
// App Store Connect labels "User Access" with a plain heading, not an ARIA group: then the options are looked up
|
|
// in the whole dialog (their own names, e.g. "Full Access", are unique there)
|
|
if (g.state === 'ok') { scope = g.loc; seen = true; }
|
|
}
|
|
const options: Record<string, Locator> = {};
|
|
let ok = true;
|
|
for (const [v, names] of Object.entries(f.options ?? {})) {
|
|
const r = await find(scope, 'radio', names);
|
|
if (r.state === 'ok') { options[v] = r.loc; seen = true; } else { ok = false; if (r.state === 'ambiguous') seen = true; }
|
|
}
|
|
// loc = the group when the page has one; without it (plain heading) each option is highlighted on its own
|
|
return { field: f, ok, seen, ...(scope !== root ? { loc: scope } : {}), options };
|
|
}
|
|
|
|
/** The accessible name of a control as the page labels it (what the click gate checks). */
|
|
export async function accName(loc: Locator): Promise<string> {
|
|
return (await loc.evaluate((el: any) => {
|
|
const by = el.getAttribute('aria-labelledby');
|
|
const ref = by ? by.split(/\s+/).map((id: string) => el.ownerDocument.getElementById(id)?.textContent ?? '').join(' ') : '';
|
|
const labels = el.labels ? [...el.labels].map((l: any) => l.textContent).join(' ') : '';
|
|
return el.getAttribute('aria-label') || ref || labels || el.textContent || '';
|
|
})).trim().replace(/\s+/g, ' ');
|
|
}
|
|
|
|
/**
|
|
* The single click gate: refuses any control whose name says save, submit, publish, create, next ... (English and
|
|
* Turkish). Every click storebound makes, checkboxes and radio buttons included, goes through here.
|
|
*/
|
|
export async function safeClick(loc: Locator, name: string, action: 'click' | 'check' | 'uncheck' = 'click'): Promise<boolean> {
|
|
if (forbiddenControl(name)) { warn(TAG, `not clicking "${name}": storebound never presses save, submit, publish, next or similar`); return false; }
|
|
if (action === 'click') await loc.click();
|
|
else await loc.setChecked(action === 'check');
|
|
return true;
|
|
}
|
|
|
|
/** Outline + a small "storebound: check this" label next to the element (no SVG). */
|
|
export async function highlight(loc: Locator, text = CHECK): Promise<void> {
|
|
await loc.evaluate((el: any, t: string) => {
|
|
const box = (el.tagName === 'INPUT' && el.closest('label')) || el;
|
|
if (box.getAttribute('data-storebound-check')) return;
|
|
box.setAttribute('data-storebound-check', '1');
|
|
// the owner answering it (a click, a choice, typing) is what the flow waits for before it moves on
|
|
const answered = () => { box.setAttribute('data-storebound-answered', '1'); el.setAttribute('data-storebound-answered', '1'); };
|
|
el.addEventListener('change', answered);
|
|
el.addEventListener('input', answered);
|
|
box.style.outline = '3px solid #d93025';
|
|
box.style.outlineOffset = '2px';
|
|
const s = el.ownerDocument.createElement('span');
|
|
s.textContent = t;
|
|
s.setAttribute('data-storebound-label', '1');
|
|
s.style.cssText = 'display:inline-block;margin:2px 6px;padding:1px 6px;border-radius:4px;background:#d93025;color:#fff;font:600 12px/18px system-ui,sans-serif';
|
|
box.insertAdjacentElement('afterend', s);
|
|
}, text);
|
|
}
|
|
|
|
async function setField(r: Resolved, v: string | boolean): Promise<boolean> {
|
|
const f = r.field;
|
|
if (f.kind === 'text') { await r.loc.fill(String(v)); return true; }
|
|
if (f.kind === 'checkbox') return safeClick(r.loc, await accName(r.loc), v ? 'check' : 'uncheck');
|
|
if (f.kind === 'radio') {
|
|
const o = r.options?.[String(v)];
|
|
return o ? safeClick(o, await accName(o), 'check') : false;
|
|
}
|
|
// select: only a native <select>; a custom dropdown is left to the owner (highlighted)
|
|
const idx: number = await r.loc.evaluate((el: any, want: string) => {
|
|
if (el.tagName !== 'SELECT') return -1;
|
|
const labels: string[] = [...el.options].map((o: any) => (o.label || o.textContent || '').trim());
|
|
const exact = labels.indexOf(want);
|
|
if (exact >= 0) return exact;
|
|
const near = labels.map((l, i) => [l, i] as const).filter(([l]) => l.endsWith(` - ${want}`) || l.endsWith(`(${want})`) || l.includes(want));
|
|
if (near.length === 1) return near[0]![1];
|
|
// a Play language ("English (United States) – en-US") in a console of another UI language: by its code suffix
|
|
const code = /\s[–-]\s([A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,4})?)$/.exec(want)?.[1];
|
|
if (!code) return -1;
|
|
const byCode = labels.map((l, i) => [l, i] as const).filter(([l]) => l.endsWith(` – ${code}`) || l.endsWith(` - ${code}`));
|
|
return byCode.length === 1 ? byCode[0]![1] : -1;
|
|
}, String(v));
|
|
if (idx < 0) return false;
|
|
await r.loc.selectOption({ index: idx });
|
|
return true;
|
|
}
|
|
|
|
async function mark(r: Resolved, text = CHECK): Promise<void> {
|
|
if (r.loc) return highlight(r.loc, text);
|
|
for (const o of Object.values(r.options ?? {})) await highlight(o, text);
|
|
}
|
|
|
|
export interface FillStep {
|
|
/** Index of the section filled (or that triggered the fallback); null when no unfilled section is on the page. */
|
|
section: number | null;
|
|
title?: string;
|
|
filled: string[];
|
|
highlighted: string[];
|
|
/** Fields of the section that could not be located (UI change) -> fallback. */
|
|
missing: string[];
|
|
fallback: boolean;
|
|
/** A control of the filled section (the flow finds its dialog and its Next / Save from it). */
|
|
anchor?: Locator;
|
|
/** Per uncertain answer (agreements left out), its outlined controls: the flow waits until the owner set each one. */
|
|
pending?: Locator[][];
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
* and left empty. Never clicks Next / Save. `answers` carry the values the page gets (fillValue).
|
|
*/
|
|
export async function fillPage(page: Page, def: FormDef, answers: Answer[], done: Set<number> = new Set(), partial = false): Promise<FillStep> {
|
|
const byId = new Map(answers.map(a => [a.id, a]));
|
|
for (const [i, sec] of def.sections.entries()) {
|
|
if (done.has(i)) continue;
|
|
let root: Locator = page;
|
|
if (sec.dialog) {
|
|
const d = await find(page, 'dialog', sec.dialog);
|
|
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;
|
|
}
|
|
const rs: Resolved[] = [];
|
|
for (const f of sec.fields) rs.push(await resolve(root, f));
|
|
if ((!sec.dialog || sec.page) && !rs.some(r => r.seen)) continue;
|
|
const missing = rs.filter(r => !r.ok).map(r => r.field.id);
|
|
// only when no field of the section can be found is it a UI change; otherwise fill what was found and leave the
|
|
// missing fields to the owner (reported as "check")
|
|
// (a page still loading can also miss a field for a moment: the caller retries before it passes partial = true)
|
|
if (missing.length && (!partial || missing.length === rs.length)) return { section: i, title: sec.title, filled: [], highlighted: [], missing, fallback: true };
|
|
const filled: string[] = [], highlighted: string[] = [...missing], pending: Locator[][] = [];
|
|
let anchor: Locator | undefined;
|
|
for (const r of rs) {
|
|
if (!r.ok) continue;
|
|
anchor ??= r.loc ?? Object.values(r.options ?? {})[0];
|
|
const a = byId.get(r.field.id);
|
|
if (!a) continue;
|
|
// an agreement is never ticked, whatever the answer says: highlighted for the owner
|
|
if (r.field.agreement || a.agreement) { await mark(r, 'storebound: you agree to this, never ticked'); highlighted.push(a.id); continue; }
|
|
const set = a.certain && a.value !== null ? await setField(r, a.value) : false;
|
|
if (set) { filled.push(a.id); if (a.ownerCheck) await mark(r, 'storebound: owner check'); } else {
|
|
await mark(r); highlighted.push(a.id);
|
|
pending.push(r.loc ? [r.loc] : Object.values(r.options ?? {}));
|
|
}
|
|
}
|
|
done.add(i);
|
|
return { section: i, title: sec.title, filled, highlighted, missing, fallback: false, ...(anchor ? { anchor } : {}), pending };
|
|
}
|
|
return { section: null, filled: [], highlighted: [], missing: [], fallback: false };
|
|
}
|
|
|
|
/**
|
|
* A sign-in page, read-only: the page or any frame on a sign-in URL (App Store Connect /login, idmsa.apple.com /
|
|
* appleid.apple.com, accounts.google.com, Google /signin), a visible password field in any frame, or a visible text,
|
|
* label or placeholder only a sign-in form shows (isSignInText: "Sign in with Passkey", "Email or Phone Number" ...).
|
|
* Apple's form sits in an idmsa.apple.com frame and starts with no password field, so the URL and texts matter most.
|
|
* Only element texts are read (never values, cookies or storage).
|
|
*/
|
|
export async function signInPage(page: Page): Promise<boolean> {
|
|
if (isSignInUrl(page.url())) return true;
|
|
const frames: any[] = page.frames();
|
|
if (frames.some(f => isSignInUrl(f.url()))) return true;
|
|
for (const f of frames) {
|
|
try {
|
|
const r: { pw: boolean; texts: string[] } = await f.evaluate(() => {
|
|
const shown = (el: any) => !!(el.getClientRects().length && getComputedStyle(el).visibility !== 'hidden');
|
|
const pw = [...document.querySelectorAll('input[type="password"]')].some(shown);
|
|
const texts: string[] = [];
|
|
for (const el of document.querySelectorAll('label, button, a, h1, h2, h3, legend, span, p, div, [role="button"], input, [aria-label]')) {
|
|
if (texts.length > 3000) break;
|
|
const e = el as any;
|
|
for (const t of [e.getAttribute('aria-label'), e.getAttribute('placeholder'), e.children.length ? null : e.textContent, e.tagName === 'LABEL' ? e.textContent : null]) {
|
|
if (t && t.length <= 80 && shown(e)) texts.push(t);
|
|
}
|
|
}
|
|
return { pw, texts };
|
|
});
|
|
if (r.pw || r.texts.some(t => isSignInText(t, f !== page.mainFrame()))) return true;
|
|
} catch { /* a frame that navigates or went away */ }
|
|
}
|
|
return false;
|
|
}
|
|
|
|
|
|
// ---- navigation to the form (after sign-in) ----
|
|
|
|
/** The ARIA role the element really has (explicit role, else the implicit one for links, buttons and rows). */
|
|
export async function realRole(loc: Locator): Promise<string> {
|
|
return loc.evaluate((el: any) => el.getAttribute('role') || (el.tagName === 'A' && el.hasAttribute('href') ? 'link' : el.tagName === 'BUTTON' ? 'button' : el.tagName === 'TR' ? 'row' : el.tagName.toLowerCase()));
|
|
}
|
|
|
|
/**
|
|
* The navigation click gate: reads the element's real accessible name and role and refuses it unless navAllowed says
|
|
* so (the click gate stays absolute: never Save / Submit / Publish / Create / Send / Next ...; only a link named exactly
|
|
* Start / Manage / Başla / Yönet is let through). Every navigation click goes through here.
|
|
*/
|
|
export async function navClick(loc: Locator, createApp = false): Promise<{ clicked: boolean; name: string }> {
|
|
const name = await accName(loc), role = await realRole(loc);
|
|
if (createApp ? !await createAppHere(loc, name, role) : !navAllowed(name, role)) { warn(TAG, `not clicking "${name}" while opening the form: storebound never presses save, submit, publish, next or similar`); return { clicked: false, name }; }
|
|
await loc.click();
|
|
return { clicked: true, name };
|
|
}
|
|
|
|
/**
|
|
* The Create app opener check (createAppAllowed) with what the page shows now: the app list (an All apps / Home heading
|
|
* or an /app-list URL), no Create app form open (no App name field), and the control not inside a form or dialog.
|
|
*/
|
|
export async function createAppHere(loc: Locator, name: string, role: string): Promise<boolean> {
|
|
const page: Page = loc.page();
|
|
const inForm: boolean = await loc.evaluate((el: any) => !!el.closest('form, [role="form"], [role="dialog"], dialog'));
|
|
const formOpen = (await findAny(page, ['textbox'], CREATE_FORM_FIELD)).state !== 'none';
|
|
let appList = false;
|
|
try { appList = /\/app-list(?:[/?#]|$)/.test(new URL(page.url()).pathname); } catch { /* about:blank */ }
|
|
if (!appList) for (const n of ALL_APPS) if (await vis(page.getByRole('heading', nameOpts(n))).count() > 0) { appList = true; break; }
|
|
return createAppAllowed(name, role, { appList, formOpen, inForm });
|
|
}
|
|
|
|
// ---- walking a form: Next between pages, App Privacy's openers and its draft Save (console-forms.ts FORM_BUTTONS) ----
|
|
|
|
/** Where a control sits: in a dialog, in App Privacy's Data Types section, on the App Privacy page. Read-only. */
|
|
export async function flowWhere(loc: Locator, dataTypes: string[]): Promise<FlowWhere> {
|
|
const page: Page = loc.page();
|
|
const privacyPage = (await findAny(page, ['heading'], APP_PRIVACY_HEADING)).state !== 'none';
|
|
const dt = DATA_TYPES_HEADING.filter((n): n is string => typeof n === 'string');
|
|
const r: { inDialog: boolean; inDataTypes: boolean } = await loc.evaluate((el: any, names: string[]) => {
|
|
const inDialog = !!el.closest('[role="dialog"], dialog');
|
|
let inDataTypes = false;
|
|
// the nearest container around the control that has a heading decides: its first heading is "Data Types"
|
|
for (let a = el.parentElement; a && a !== el.ownerDocument.body; a = a.parentElement) {
|
|
const h = a.querySelector('h1, h2, h3, h4, h5, h6, [role="heading"]');
|
|
if (!h) continue;
|
|
inDataTypes = names.includes((h.textContent ?? '').trim().replace(/\s+/g, ' '));
|
|
break;
|
|
}
|
|
return { inDialog, inDataTypes };
|
|
}, dt);
|
|
return { privacyPage, dataTypes, ...r };
|
|
}
|
|
|
|
/**
|
|
* The flow click gate (flowAllowed): a navigation control of the form, or App Privacy's in-dialog Save (a draft, public
|
|
* only after the owner's Publish). Reads the real name, role and place of the control; logs the click.
|
|
*/
|
|
export async function flowClick(loc: Locator, form: FormId, dataTypes: string[], say: (m: string) => void, onClick?: (name: string) => void): Promise<boolean> {
|
|
const name = await accName(loc), role = await realRole(loc);
|
|
const where = await flowWhere(loc, dataTypes);
|
|
if (!flowAllowed(form, name, role, where)) { warn(TAG, `not clicking "${name}": only the owner presses it`); return false; }
|
|
await loc.click();
|
|
say(`clicked "${name}" (${flowClass(form, name, role, where)})`);
|
|
onClick?.(name);
|
|
return true;
|
|
}
|
|
|
|
/** Visible controls named like `names` (buttons and links) that sit where `ok` says; the one match, or null. */
|
|
async function controlWhere(root: Locator, names: Name[], ok: (w: FlowWhere) => boolean, dataTypes: string[]): Promise<Locator | null> {
|
|
const hits: Locator[] = [];
|
|
for (const role of ['button', 'link']) {
|
|
for (const n of names) {
|
|
const all = vis(root.getByRole(role, nameOpts(n)));
|
|
const c = await all.count();
|
|
for (let i = 0; i < c; i++) { const l = all.nth(i); if (ok(await flowWhere(l, dataTypes))) hits.push(l); }
|
|
}
|
|
}
|
|
return hits.length === 1 ? hits[0]! : null;
|
|
}
|
|
|
|
/** Every outlined control of each uncertain answer has been set by the owner (a click, a choice, typing). Read-only. */
|
|
async function answered(pending: Locator[][]): Promise<boolean> {
|
|
for (const field of pending) {
|
|
let any = false;
|
|
for (const l of field) {
|
|
if (await l.evaluate((el: any) => el.getAttribute('data-storebound-answered') === '1' || (el.closest('label')?.getAttribute('data-storebound-answered') === '1')).catch(() => false)) { any = true; break; }
|
|
}
|
|
if (!any) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
export interface NavResult {
|
|
ok: boolean;
|
|
/** Labels of the steps done (["already open"] when the form was already on the page). */
|
|
steps: string[];
|
|
/** The step that could not be done (not found in time, ambiguous, or refused by the gate). */
|
|
failed?: string;
|
|
why?: string;
|
|
/** pickApp: the matching apps when more than one matched (storebound stops and lists them). */
|
|
candidates?: string[];
|
|
/** A sign-in page appeared: wait for the owner, then start again at step 1. */
|
|
signIn?: boolean;
|
|
}
|
|
|
|
export async function findAny(root: Locator, roles: string[], names: Name[]): Promise<Found> {
|
|
for (const role of roles) { const f = await find(root, role, names); if (f.state !== 'none') return f; }
|
|
return { state: 'none' };
|
|
}
|
|
|
|
/** Thrown by poll when a sign-in page shows up while a navigation step waits (navigate turns it into `signIn`). */
|
|
export class SignInSeen extends Error {}
|
|
|
|
/** A check run on every poll round: true -> a sign-in page is showing. */
|
|
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).
|
|
*/
|
|
export async function poll<T>(ms: number, fn: () => Promise<T | null>, stop?: Stop): 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);
|
|
}
|
|
}
|
|
|
|
/** The app a list link opens: "apps/<id>" for an App Store Connect app URL (any sub page), else the URL without its fragment. */
|
|
export function appKey(href: string | null): string | null {
|
|
if (!href) return null;
|
|
const id = href.match(/\/apps\/(\d+)(?:[/?#]|$)/)?.[1];
|
|
return id ? `apps/${id}` : href.replace(/#.*$/, '');
|
|
}
|
|
|
|
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);
|
|
if (box) { await box.fill(s.tokens[0]); }
|
|
}
|
|
return poll(ms, async () => {
|
|
for (const role of ['row', 'link']) {
|
|
const all = vis(page.getByRole(role));
|
|
const n = await all.count();
|
|
const hits: { loc: Locator; name: string; key: string | null }[] = [];
|
|
for (let i = 0; i < n; i++) {
|
|
const loc = all.nth(i), name = await accName(loc);
|
|
// innerText keeps the cells apart ("Example Game\tcom.example.game"), the accessible name may glue them
|
|
const text: string = s.tokens.length ? await loc.evaluate((el: any) => `${el.innerText ?? ''} ${el.textContent ?? ''}`) : '';
|
|
const byToken = s.tokens.some(t => hasToken(name, t) || hasToken(text.replace(/\s+/g, ' '), t));
|
|
const byName = role === 'link' && s.name !== undefined && name === s.name;
|
|
if (!byToken && !byName) continue;
|
|
// one app shown twice (App Store Connect's grid: the card's title link and its icon link / aria-label) is ONE
|
|
// app: hits are told apart by the app they open (/apps/<id>, else the link target), not by their name
|
|
const key = appKey(await loc.evaluate((el: any) => (el.closest('a[href]') ?? el.querySelector('a[href]'))?.href ?? null).catch(() => null));
|
|
if (key && hits.some(h => h.key === key)) continue;
|
|
hits.push({ loc, name, key });
|
|
}
|
|
if (hits.length === 1) {
|
|
const row = hits[0]!.loc;
|
|
if (role === 'row') { const inner = vis(row.getByRole('link')); if (await inner.count() >= 1) return { loc: inner.first() }; }
|
|
return { loc: row };
|
|
}
|
|
if (hits.length > 1) return { candidates: hits.map(h => h.name) };
|
|
}
|
|
return null;
|
|
}, stop);
|
|
}
|
|
|
|
async function formLink(page: Page, entry: Name[], ms: number, stop?: Stop): Promise<{ loc?: Locator; ambiguous?: boolean } | null> {
|
|
return poll(ms, async () => {
|
|
const found: { loc: Locator; key: string }[] = [];
|
|
const add = async (box: Locator) => {
|
|
const l = vis(box.getByRole('link', { name: opener }));
|
|
if (await l.count() !== 1) return;
|
|
const b = await l.boundingBox();
|
|
const key = b ? `${Math.round(b.x)},${Math.round(b.y)}` : String(found.length);
|
|
if (!found.some(f => f.key === key)) found.push({ loc: l, key });
|
|
};
|
|
for (const n of entry) {
|
|
for (const role of ['region', 'listitem', 'row', 'group', 'article']) {
|
|
const boxes = vis(page.getByRole(role, nameOpts(n)));
|
|
const c = await boxes.count();
|
|
for (let i = 0; i < c; i++) await add(boxes.nth(i));
|
|
}
|
|
if (found.length) break;
|
|
const boxes = vis(page.locator('section, li, tr, article, [role="listitem"], [role="row"], [role="region"]').filter({ hasText: n }));
|
|
const c = await boxes.count();
|
|
for (let i = 0; i < c; i++) await add(boxes.nth(i));
|
|
if (found.length) break;
|
|
}
|
|
if (found.length === 1) return { loc: found[0]!.loc };
|
|
return found.length > 1 ? { ambiguous: true } : null;
|
|
}, stop);
|
|
}
|
|
|
|
/** The wait target is on the page (a heading of `level` when given). */
|
|
async function present(page: Page, s: Extract<NavStep, { kind: 'wait' }>): Promise<boolean> {
|
|
if (s.level === undefined) return (await findAny(page, s.roles, s.names)).state !== 'none';
|
|
for (const n of s.names) if (await vis(page.getByRole('heading', { ...nameOpts(n), level: s.level })).count() > 0) return true;
|
|
return false;
|
|
}
|
|
|
|
export interface NavOpts {
|
|
timeoutMs?: number;
|
|
say?: (m: string) => void;
|
|
/** Start at this step (0-based; resumeAt picks it after the owner moved on by hand). */
|
|
from?: number;
|
|
/** The sign-in check (default signInPage), run before every step, after each step's click or load, and on every poll round. */
|
|
signIn?: (page: Page) => Promise<boolean>;
|
|
}
|
|
|
|
/**
|
|
* Opens the form step by step (navPlan). Logs every step; stops at the first step that is not found within
|
|
* `timeoutMs`, is ambiguous, or is refused by the click gate, and says which. A sign-in page at any point (before a
|
|
* step, after its click or load, or while it waits) stops at once with `signIn: true`: the caller waits for the owner
|
|
* to sign in and starts again at step 1. Never throws for a missing step.
|
|
*/
|
|
export async function navigate(page: Page, plan: NavStep[], opts: NavOpts = {}): Promise<NavResult> {
|
|
const ms = opts.timeoutMs ?? 20_000, say = opts.say ?? ((m: string) => log(TAG, m));
|
|
const isSignIn = opts.signIn ?? signInPage;
|
|
const stop: Stop = () => isSignIn(page).catch(() => false);
|
|
const steps: string[] = [];
|
|
const target = plan.at(-1);
|
|
if (target?.kind === 'wait' && await present(page, target).catch(() => false)) {
|
|
say(`the form is already open (${target.label})`);
|
|
return { ok: true, steps: ['already open'] };
|
|
}
|
|
const fail = (s: NavStep, why: string, candidates?: string[]): NavResult => ({ ok: false, steps, failed: s.label, why, ...(candidates ? { candidates } : {}) });
|
|
const signedOut = (s: NavStep): NavResult => ({ ok: false, steps, failed: s.label, why: 'a sign-in page appeared', signIn: true });
|
|
for (let i = Math.max(0, opts.from ?? 0); i < plan.length; i++) {
|
|
const s = plan[i]!;
|
|
if (await stop()) return signedOut(s);
|
|
say(`opening the form, step ${i + 1}/${plan.length}: ${s.label}`);
|
|
try {
|
|
if (s.kind === 'goto') {
|
|
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);
|
|
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);
|
|
if (!c.clicked) return fail(s, `refused to click "${c.name}"`);
|
|
} else if (s.kind === 'pickApp') {
|
|
if (!s.tokens.length && !s.name) return fail(s, 'no bundle id, package name or app name in the config');
|
|
const r = await pickApp(page, s, ms, stop);
|
|
if (!r) return fail(s, `no app matching ${[...s.tokens, ...(s.name ? [`"${s.name}"`] : [])].join(' / ')} in the list`);
|
|
if (r.candidates) return fail(s, 'more than one app matches', r.candidates);
|
|
const c = await navClick(r.loc);
|
|
if (!c.clicked) return fail(s, `refused to click "${c.name}"`);
|
|
} else if (s.kind === 'formLink') {
|
|
const r = await formLink(page, s.entry, ms, stop);
|
|
if (!r) return fail(s, 'no Start or Manage link next to it');
|
|
if (r.ambiguous) return fail(s, 'more than one Start or Manage link matches');
|
|
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);
|
|
if (!f) return fail(s, 'did not appear');
|
|
}
|
|
} catch (e) {
|
|
if (e instanceof SignInSeen) return signedOut(s);
|
|
throw e;
|
|
}
|
|
if (s.kind !== 'wait' && await stop()) return signedOut(s);
|
|
steps.push(s.label);
|
|
}
|
|
return { ok: true, steps };
|
|
}
|
|
|
|
/**
|
|
* Where navigation can pick up on the page as it is now (the owner moved on by hand): the furthest step whose target is
|
|
* visible (the form itself, the New App menu item, the + button, the app in the list ...), 0-based, or null. Never
|
|
* the initial page load (a `goto`): storebound does not take the owner away from where they are. Read-only.
|
|
*/
|
|
export async function resumeAt(page: Page, plan: NavStep[]): Promise<number | null> {
|
|
for (let i = plan.length - 1; i >= 0; i--) {
|
|
const s = plan[i]!;
|
|
try {
|
|
if (s.kind === 'wait') { if (await present(page, s)) return i; }
|
|
else if (s.kind === 'click') { if ((await findAny(page, s.roles, s.names)).state === 'ok') return i; }
|
|
else if (s.kind === 'pickApp') {
|
|
const { search: _search, ...look } = s; // look only: never types into the search box here
|
|
if ((await pickApp(page, look, 0))?.loc) return i;
|
|
} else if (s.kind === 'formLink') { if ((await formLink(page, s.entry, 0))?.loc) return i; }
|
|
} catch { /* navigating */ }
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function printText(res: ConsoleResult): void {
|
|
console.log(`${res.title}\n${res.url}\n`);
|
|
for (const a of res.answers) {
|
|
const tag = a.agreement ? '[you] ' : a.ownerCheck ? '[owner]' : a.certain ? '[ok] ' : '[check]';
|
|
console.log(` ${tag} ${a.label}: ${a.agreement ? '(you tick it; storebound never does)' : valueText(a)} (${a.source})${(a.certain && !a.ownerCheck) || !a.note ? '' : `\n ${a.note}`}`);
|
|
}
|
|
console.log(`\n${res.uncertain} answer(s) to check yourself.\n\nSteps:\n${res.guidance.map(g => ` - ${g}`).join('\n')}`);
|
|
}
|
|
|
|
export async function run(ctx: Ctx): Promise<void> {
|
|
const { config, root } = ctx.loaded;
|
|
const args = ctx.args, json = args.has('json'), dry = args.has('dry');
|
|
const form = args._[0];
|
|
if (!form) {
|
|
const forms = FORM_IDS.map(id => { const m = formMeta(id, config); return { id, store: m.store, title: m.title, fillable: m.fillable }; });
|
|
if (json) return printJson({ forms });
|
|
console.log('storebound console fill <form> [--store apple|play] [--dry] [--json] [--profile <dir>] [--step-timeout <seconds>] [--debug-dump]\n');
|
|
for (const f of forms) console.log(` ${f.id.padEnd(22)} ${f.store.padEnd(6)} ${f.title}${f.fillable ? '' : ' (guide only)'}`);
|
|
return;
|
|
}
|
|
if (!isForm(form)) throw new Error(`console fill: unknown form "${form}" (one of ${FORM_IDS.join(', ')})`);
|
|
const meta = formMeta(form, config);
|
|
const store = args.get('store');
|
|
if (store && store !== 'apple' && store !== 'play') throw new Error('--store: apple | play');
|
|
if (store && store !== meta.store) throw new Error(`console fill: ${form} is a ${meta.store} form, not ${store}`);
|
|
const res = consoleResult(form, config);
|
|
if (dry || !meta.fillable) {
|
|
if (json) return printJson(dry ? res : { ...res, filled: 0, highlighted: 0, fallback: false, stoppedAt: null });
|
|
return printText(res);
|
|
}
|
|
|
|
const say = (m: string) => (json ? console.error(`[${TAG}] ${m}`) : log(TAG, m));
|
|
for (const d of DISCLAIMER) say(d);
|
|
const startUrl = args.get('start-url');
|
|
if (startUrl && !/^http:\/\/(127\.0\.0\.1|localhost)(:\d+)?\//.test(startUrl)) throw new Error('--start-url: only a local test page (http://127.0.0.1:<port>/...)');
|
|
const ownProfile = args.has('profile') || !!process.env.STOREBOUND_CONSOLE_PROFILE;
|
|
const dir = args.get('profile') ?? process.env.STOREBOUND_CONSOLE_PROFILE ?? join(homedir(), '.storebound', 'console-profile', meta.store);
|
|
const b = await openConsoleBrowser(root, {
|
|
store: meta.store, profileDir: dir, say, fresh: args.has('fresh-profile') || ownProfile,
|
|
...(args.get('cdp') ? { cdp: args.get('cdp') } : {}), ...(args.get('chrome-profile') ? { chromeProfile: args.get('chrome-profile') } : {}),
|
|
});
|
|
// Ctrl+C: close the browser gracefully (Chromium writes its cookies on a clean exit, so the sign-in is kept for the
|
|
// next run), then remove the temporary profile of copy mode; that also happens on every other exit path
|
|
let stopping = false;
|
|
const bye = () => { if (stopping) return; stopping = true; void b.close().finally(() => { b.cleanup(); process.exit(130); }); };
|
|
process.on('SIGINT', bye); process.on('SIGTERM', bye);
|
|
try {
|
|
// App Privacy opens by deep link when the app id is known (config, cache or the API by bundle id), else by the list
|
|
const cfg = form === 'apple-app-privacy' && !startUrl ? withAppleAppId(config, await appleAppId(root, config, { say })) : config;
|
|
const step = args.get('step-timeout');
|
|
if (step !== undefined && !(Number(step) > 0)) throw new Error('--step-timeout: seconds (a number above 0)');
|
|
const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\..*$/, '').replace('T', '-');
|
|
const out = await fillSession(b, {
|
|
form, config: cfg, say, ...(startUrl ? { startUrl } : {}), ...(step !== undefined ? { stepTimeoutMs: Number(step) * 1000 } : {}),
|
|
debugDir: join(root, '.storebound', 'console-debug', `${form}-${stamp}`), debugEveryStep: args.has('debug-dump'),
|
|
});
|
|
if (out.debugDir) say(`debug dump: ${out.debugDir} (page.json, page.html, screen.jpg per step; no values, cookies or tokens; look at screen.jpg before you attach it to a bug report)`);
|
|
if (out.timedOut) process.exitCode = 1;
|
|
if (json) printJson({ ...res, ...out, stoppedAt: meta.stopBefore, browser: b.mode });
|
|
} finally { b.cleanup(); process.off('SIGINT', bye); process.off('SIGTERM', bye); }
|
|
}
|
|
|
|
export interface FillSessionOpts {
|
|
form: FormId;
|
|
config: ShipConfig;
|
|
say: (m: string) => void;
|
|
/** Tests only: a local page instead of the console (no navigation plan). */
|
|
startUrl?: string;
|
|
/** Tests only: the navigation plan's URL bases (127.0.0.1 or routed fixtures). */
|
|
bases?: NavBases;
|
|
/** Timings (tests shorten them): one watch round, the wait for each navigation step, the wait for sign-in. */
|
|
tickMs?: number;
|
|
navTimeoutMs?: number;
|
|
signInWaitMs?: number;
|
|
/** Tests only: the account cache file (default ~/.storebound/console-accounts.json) and the env ids are read from. */
|
|
accountsFile?: string;
|
|
env?: Record<string, string | undefined>;
|
|
/** Watchdog (see fillSession): no known page within stepTimeoutMs after a click or while the form is expected -> a
|
|
* diagnostic dump and the manual steps; no progress at all for idleLimitMs -> the run stops (timedOut). A line every
|
|
* heartbeatMs while waiting; the owner's final review is waited for at most finalWaitMs. */
|
|
stepTimeoutMs?: number;
|
|
idleLimitMs?: number;
|
|
heartbeatMs?: number;
|
|
finalWaitMs?: number;
|
|
/** Where diagnostics go (.storebound/console-debug/<form>-<time>); none: no files, the log still says what is there. */
|
|
debugDir?: string;
|
|
/** --debug-dump: a dump at every step (the form open, every filled page, every click), not only on a stall. */
|
|
debugEveryStep?: boolean;
|
|
}
|
|
|
|
/** Watchdog defaults: 30 s for a page to show up, 20 min without any progress, a line a minute, 2 h for the review. */
|
|
export const WATCH = { stepMs: 30_000, idleMs: 20 * 60_000, heartbeatMs: 60_000, finalMs: 2 * 3600_000 };
|
|
const mins = (ms: number): string => (ms < 60_000 ? `${Math.round(ms / 1000)} s` : `${Math.floor(ms / 60_000)} min ${Math.round((ms % 60_000) / 1000)} s`);
|
|
|
|
/**
|
|
* One fill run in an open browser, until the owner closes the window:
|
|
* 1. sign-in: while a sign-in page shows (signInPage), the owner signs in and storebound waits (up to 10 minutes; it
|
|
* never types or reads anything there). Signed in = the console URL, no sign-in page, for two rounds in a row.
|
|
* 2. opens the form (navigate). A sign-in page during navigation -> wait for sign-in again, then from step 1.
|
|
* 3. a step not found -> the printed steps, and storebound keeps watching: a sign-in page that goes away -> from step
|
|
* 1; the owner moving on by hand (the + button, the New App menu item, the app in the list appear) -> from that step
|
|
* (resumeAt); the owner opening the form -> it is filled.
|
|
* 4. fills section by section and moves on by itself: Next (App Privacy: Get Started / Edit next to Data Types, each
|
|
* data type, and the draft Save of its dialogs). An uncertain answer stops it on that page until the owner set it.
|
|
* The final button (Publish, Submit, Save, Create) and agreements stay the owner's.
|
|
*/
|
|
export async function fillSession(b: ConsoleBrowser, o: FillSessionOpts): Promise<{ filled: number; highlighted: number; fallback: boolean; timedOut?: boolean; debugDir?: string }> {
|
|
const { form, config, say, startUrl } = o;
|
|
const tick = o.tickMs ?? 1000, navMs = o.navTimeoutMs ?? 20_000, signInMs = o.signInWaitMs ?? 10 * 60_000;
|
|
const stepMs = o.stepTimeoutMs ?? WATCH.stepMs, idleMs = o.idleLimitMs ?? WATCH.idleMs, heartbeatMs = o.heartbeatMs ?? WATCH.heartbeatMs, finalMs = o.finalWaitMs ?? WATCH.finalMs;
|
|
// ---- the watchdog: what storebound expects next, since when; a dump of the page when it does not come ----
|
|
let expecting = 'the form', progressAt = Date.now(), lastBeat = Date.now(), stalled = false, dumps = 0, timedOut = false;
|
|
const dump = async (p: Page, label: string) => {
|
|
if (!o.debugDir) return null;
|
|
const { dumpPage } = await import('./debug');
|
|
return dumpPage(p, o.debugDir, ++dumps, label);
|
|
};
|
|
/** Something storebound knows happened; `next`: what it waits for now. */
|
|
const progress = (next: string) => { progressAt = Date.now(); lastBeat = progressAt; expecting = next; stalled = false; };
|
|
const clicked = (name: string) => { progress(`the page after "${name}"`); if (o.debugEveryStep) { const p = active(); if (p) void Bun.sleep(tick).then(() => dump(p, `after ${name}`)).catch(() => { /* gone */ }); } };
|
|
const meta = formMeta(form, config), res = consoleResult(form, config);
|
|
const active = (): Page | undefined => b.pages().at(-1);
|
|
const isSignIn = (p: Page) => signInPage(p).catch(() => false);
|
|
const reached = (p: Page) => consoleReached(meta.store, p.url(), startUrl);
|
|
let hinted = false;
|
|
const signInHint = () => {
|
|
say('sign in in the browser window; storebound waits (up to 10 minutes) and never types or reads anything on the sign-in page');
|
|
if (hinted) return;
|
|
hinted = true;
|
|
say(meta.store === 'apple'
|
|
? 'tick "Keep me signed in" and choose "Trust" for this browser, so the next run starts signed in'
|
|
: 'leave "Stay signed in" on, so the next run starts signed in');
|
|
};
|
|
|
|
/** Waits until the console is open and no sign-in page shows (two rounds in a row). false: window closed or time up. */
|
|
async function signedIn(): Promise<boolean> {
|
|
const end = Date.now() + signInMs;
|
|
let told = false, ok = 0, beat = Date.now();
|
|
const start = Date.now();
|
|
while (!b.isClosed() && Date.now() < end) {
|
|
if (Date.now() - beat >= heartbeatMs) { beat = Date.now(); say(`still waiting for the sign-in, ${mins(Date.now() - start)}`); }
|
|
const p = active();
|
|
if (p && await isSignIn(p)) { ok = 0; if (!told) { signInHint(); told = true; } }
|
|
else if (p && reached(p)) { if (++ok >= 2) { if (told) say('signed in'); return true; } }
|
|
else ok = 0;
|
|
await Bun.sleep(tick);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
let plan = startUrl ? [] : navPlan(form, withAccountIds(config, o.env), o.bases);
|
|
const broken = (e: Error): NavResult => ({ ok: false, steps: [], failed: 'navigation', why: e.message.split('\n')[0] });
|
|
/**
|
|
* From step 1 in the account that has the app (openAcross: the remembered / configured developer account or team,
|
|
* else every one the login can see), or from `from` in the account that is open. A sign-in page -> wait for sign-in,
|
|
* then again from step 1 (at most 5 times).
|
|
*/
|
|
async function openForm(from: number): Promise<NavResult> {
|
|
let r: NavResult = { ok: false, steps: [], failed: 'navigation', why: 'the window was closed' };
|
|
for (let n = 0; n < 5 && !b.isClosed(); n++) {
|
|
const p = active() ?? b.page;
|
|
if (from === 0) {
|
|
const a = await openAcross(p, { form, config, say, navMs, ...(o.bases ? { bases: o.bases } : {}), ...(o.accountsFile ? { file: o.accountsFile } : {}), ...(o.env ? { env: o.env } : {}) })
|
|
.catch((e: Error) => ({ nav: broken(e), plan }));
|
|
r = a.nav; if (a.plan.length) plan = a.plan;
|
|
} else r = await navigate(p, plan, { say, from, timeoutMs: navMs }).catch(broken);
|
|
if (!r.signIn) return r;
|
|
say(`a sign-in page appeared at step "${r.failed}"`);
|
|
if (!await signedIn()) return r;
|
|
say('signed in: opening the form again from step 1');
|
|
from = 0;
|
|
}
|
|
return r;
|
|
}
|
|
let tried = '';
|
|
const report = (r: NavResult, first: boolean) => {
|
|
if (r.ok) return; // the form (App Privacy: its page) is open; the flow below goes on from there
|
|
if (r.signIn) return;
|
|
// refused / ambiguous: not retried on this page; "not found" / "did not appear" is retried when the step shows up
|
|
if (!/^(not found|did not appear)$/.test(r.why ?? '')) tried = `${(active() ?? b.page).url()}#${plan.findIndex(s => s.label === r.failed)}`;
|
|
warn(TAG, `could not open the form by itself at step "${r.failed}" (${r.why}). Open it yourself; storebound keeps watching and fills it when it appears:`);
|
|
if (r.candidates) say(r.failed === 'the developer account' ? `developer accounts: ${r.candidates.join(' | ')} (set play.developerAccount or play.developerId to pick one)`
|
|
: `apps that match: ${r.candidates.join(' | ')} (set apple.appId / play.packageName in the config to pick one)`);
|
|
if (first) for (const s of meta.open) say(`open the form: ${s}`);
|
|
};
|
|
|
|
for (const n of meta.notes ?? []) say(n);
|
|
await b.page.goto(startUrl ?? meta.url).catch(() => { /* the owner can navigate */ });
|
|
await signedIn();
|
|
|
|
const def = formDef(form, res.answers);
|
|
const pageAnswers = res.answers.map(a => ({ ...a, value: fillValue(form, a, config) }));
|
|
const labels = new Map(res.answers.map(a => [a.id, a.label]));
|
|
const done = new Set<number>();
|
|
let filled = 0, highlighted = 0, fallback = false;
|
|
let nav: NavResult | null = plan.length && !b.isClosed() ? await openForm(0) : null;
|
|
if (nav) report(nav, true);
|
|
else for (const s of meta.open) say(`open the form: ${s}`);
|
|
progress(nav?.ok ? 'the form' : 'the form (opened by you)');
|
|
if (o.debugEveryStep && !b.isClosed()) await dump(active() ?? b.page, 'start');
|
|
|
|
const last = def.sections.length - 1;
|
|
const privacy = form === 'apple-app-privacy';
|
|
/** App Privacy: the data types storebound answers (their entries in the Data Types section open their questions). */
|
|
const dataTypes = [...new Set(def.sections.map(x => x.type).filter((t): t is string => !!t))];
|
|
/** The page was filled: its Next (App Privacy: Next, else the dialog's draft Save) is clicked when nothing waits. */
|
|
let pendingAdvance = false;
|
|
/** A control of the last filled section (its dialog / page holds the Next or Save to press). */
|
|
let anchor: Locator | undefined;
|
|
/** Uncertain answers on the page: storebound stops here until the owner set them (never clicks Next past them). */
|
|
let waitFor: { title: string; pending: Locator[][] } | null = null;
|
|
/** App Privacy openers clicked (each at most twice: a click that opens nothing is a UI change). */
|
|
const opened = new Map<string, number>();
|
|
let askedCollects = false;
|
|
const finished = () => !pendingAdvance && !waitFor && (done.size === def.sections.length || (!privacy && done.has(last)));
|
|
|
|
/** The dialog of the last filled section when it is still visible, else null. */
|
|
const anchorDialog = async (): Promise<Locator | null> => {
|
|
if (!anchor) return null;
|
|
const d = vis(anchor.locator('xpath=ancestor-or-self::*[@role="dialog" or local-name()="dialog"][1]'));
|
|
return (await d.count().catch(() => 0)) === 1 ? d : null;
|
|
};
|
|
/** The Next of the page (App Privacy: of the questionnaire dialog). */
|
|
const nextControl = async (p: Page): Promise<Locator | null> => {
|
|
const d = await anchorDialog();
|
|
if (privacy && !d) return null;
|
|
return controlWhere(d ?? p, FLOW_NAMES.next, w => !privacy || w.inDialog, dataTypes);
|
|
};
|
|
/** The section filled last, and the content rating pages whose Save draft was clicked. */
|
|
let filledAt: number | null = null;
|
|
const drafted = new Set<number>();
|
|
/** Moves on after a filled page: (content rating: Save draft, then) Next, else (App Privacy) the dialog's draft Save.
|
|
* true / 'draft' when something was clicked ('draft': Next still to come). */
|
|
async function advance(p: Page): Promise<boolean | 'draft'> {
|
|
// the content rating: Save draft first (once per page), then Next
|
|
if (form === 'play-content-rating' && filledAt !== null && !drafted.has(filledAt)) {
|
|
drafted.add(filledAt);
|
|
const draft = await controlWhere(p, FLOW_NAMES.saveDraft, () => true, dataTypes);
|
|
if (draft && await flowClick(draft, form, dataTypes, say, clicked)) return 'draft';
|
|
}
|
|
const next = await nextControl(p);
|
|
if (next) return flowClick(next, form, dataTypes, say, clicked);
|
|
const d = privacy ? await anchorDialog() : null;
|
|
if (!d) return false;
|
|
const save = await controlWhere(d, FLOW_NAMES.save, w => w.inDialog && w.privacyPage, dataTypes);
|
|
return save ? flowClick(save, form, dataTypes, say, clicked) : false;
|
|
}
|
|
/** App Privacy with no dialog open: opens the next part (Get Started, Edit next to Data Types, the next data type). */
|
|
async function privacyStep(p: Page): Promise<void> {
|
|
if (await vis(p.getByRole('dialog')).count() > 0) return; // a dialog is open: it is filled, or the owner works in it
|
|
if ((await findAny(p, ['heading'], APP_PRIVACY_HEADING)).state === 'none') return;
|
|
const open = async (key: string, what: string, loc: Locator | null): Promise<void> => {
|
|
if (!loc) return;
|
|
const n = opened.get(key) ?? 0;
|
|
if (n === 2) { opened.set(key, 3); warn(TAG, `${what} did not open after two clicks: the console UI may have changed. Open it yourself; storebound fills it when it appears`); }
|
|
if (n >= 2) return;
|
|
opened.set(key, n + 1);
|
|
await flowClick(loc, form, dataTypes, say, clicked);
|
|
};
|
|
const editTypes = () => controlWhere(p, FLOW_NAMES.edit, w => w.inDataTypes && !w.inDialog, dataTypes);
|
|
if (!done.has(0)) {
|
|
const start = await controlWhere(p, FLOW_NAMES.getStarted, w => w.privacyPage && !w.inDialog, dataTypes);
|
|
if (start) return open('get-started', 'Get Started', start);
|
|
const edit = await editTypes();
|
|
if (!edit) return;
|
|
const c = pageAnswers.find(x => x.id === 'collects');
|
|
if (!(c?.certain && c.value === true)) {
|
|
if (!askedCollects) { askedCollects = true; say(`answer needed: ${c?.label ?? 'Data collection'} (the page lists data types, the config does not prove the answer is yes)`); }
|
|
return;
|
|
}
|
|
done.add(0);
|
|
say('Data collection was answered before (the page lists data types): storebound opens the data types with Edit');
|
|
return open('edit', 'Edit next to Data Types', edit);
|
|
}
|
|
const typesAt = def.sections.findIndex(x => x.title === 'Data types');
|
|
if (typesAt >= 0 && !done.has(typesAt)) return open('edit-types', 'Edit next to Data Types', await editTypes());
|
|
const t = def.sections.find((x, i) => x.type && !done.has(i))?.type;
|
|
if (t) return open(`type:${t}`, t, await controlWhere(p, [t, `Set Up ${t}`, `${t} ayarla`], w => w.inDataTypes && !w.inDialog, dataTypes));
|
|
}
|
|
|
|
/** A page storebound does not know: what is there (top buttons), the dump, and the manual steps. Once per wait. */
|
|
const stall = async (p: Page) => {
|
|
stalled = true;
|
|
const dbg = await import('./debug');
|
|
const d = await dump(p, `unknown ${expecting}`).catch(() => null);
|
|
const top = dbg.topButtons(d?.summary ?? await dbg.summarize(p).catch(() => null));
|
|
say(`${expecting} is not a page storebound knows (nothing it can fill or click after ${mins(Date.now() - progressAt)}). Buttons there: ${top.length ? top.map(t => `"${t}"`).join(', ') : '(none)'}${d ? `. Debug saved to ${d.dir}` : ''}`);
|
|
say(`do this part yourself; storebound keeps watching and goes on when it sees a page it knows (it stops after ${mins(idleMs)} without progress):`);
|
|
for (const g of res.guidance) say(` ${g}`);
|
|
};
|
|
/** Every round: a heartbeat line, the step watchdog (not while the owner answers or signs in), the idle limit. */
|
|
const watch = async (p: Page | undefined): Promise<boolean> => {
|
|
const now = Date.now();
|
|
const owner = waitFor ? `your answer on "${waitFor.title}"` : signedOut ? 'the sign-in' : null;
|
|
if (now - lastBeat >= heartbeatMs) { lastBeat = now; say(`still waiting for ${owner ?? expecting}, ${mins(now - progressAt)}`); }
|
|
if (p && !owner && !stalled && now - progressAt >= stepMs) await stall(p);
|
|
if (now - progressAt >= idleMs) { timedOut = true; return false; }
|
|
return true;
|
|
};
|
|
|
|
let signedOut = false, misses = 0, firstMiss = 0;
|
|
while (!b.isClosed() && !finished()) {
|
|
const p = active();
|
|
if (!await watch(p)) break;
|
|
if (!p) { await Bun.sleep(tick); continue; }
|
|
if (await signInPage(p).catch(() => true)) {
|
|
if (!signedOut && plan.length) signInHint();
|
|
signedOut = true;
|
|
await Bun.sleep(tick); continue;
|
|
}
|
|
// sign-in finished while watching: open the form again from step 1 (nothing filled yet)
|
|
if (signedOut) {
|
|
signedOut = false;
|
|
if (plan.length && !done.size && !nav?.ok && await signedIn()) {
|
|
say('signed in: opening the form again from step 1');
|
|
tried = ''; nav = await openForm(0); report(nav, false);
|
|
}
|
|
continue;
|
|
}
|
|
// after the printed steps: keep watching; when the owner moved on (or the page finished loading), pick up there
|
|
if (plan.length && nav && !nav.ok && !done.size) {
|
|
const at = await resumeAt(p, plan);
|
|
const key = `${p.url()}#${at}`;
|
|
if (at !== null && key !== tried) {
|
|
tried = key;
|
|
say(`the page changed: storebound opens the form from step ${at + 1}/${plan.length}`);
|
|
nav = await openForm(at); report(nav, false);
|
|
progress(nav.ok ? 'the form' : 'the form (opened by you)');
|
|
if (nav.ok) say('the form is open');
|
|
continue;
|
|
}
|
|
}
|
|
let s: FillStep;
|
|
try { s = await fillPage(p, def, pageAnswers, done); } 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; }
|
|
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); } catch { await Bun.sleep(tick); continue; } }
|
|
if (!s.fallback && s.missing.length) warn(TAG, `${s.title}: could not find ${s.missing.map(id => labels.get(id) ?? id).join(', ')} on the page (the console UI may have changed): fill ${s.missing.length === 1 ? 'it' : 'them'} yourself`);
|
|
if (s.fallback) {
|
|
fallback = true;
|
|
warn(TAG, `the page does not match what storebound expects (${s.missing.join(', ')}): the console UI changed. Nothing more is filled; follow these steps:`);
|
|
for (const g of res.guidance) say(` ${g}`);
|
|
break;
|
|
}
|
|
filled += s.filled.length; highlighted += s.highlighted.length;
|
|
progress(`the next part after "${s.title}"`);
|
|
if (o.debugEveryStep) await dump(p, `filled ${s.title}`);
|
|
say(`${s.title}: filled ${s.filled.length}, check ${s.highlighted.length}${s.highlighted.length ? `: ${s.highlighted.map(id => labels.get(id) ?? id).join('; ')}` : ''}`);
|
|
anchor = s.anchor; filledAt = s.section;
|
|
// the last page of a Play / App Store form without a Next ends the run: its button is the owner's (Save, Submit, Create)
|
|
const ends = !privacy && s.section === last && !await nextControl(p).catch(() => null);
|
|
pendingAdvance = !ends;
|
|
const open = ends ? [] : (s.pending ?? []);
|
|
waitFor = open.length ? { title: s.title ?? '', pending: open } : null;
|
|
if (waitFor) {
|
|
const need = s.highlighted.filter(id => !pageAnswers.find(a => a.id === id)?.agreement);
|
|
say(`answer needed on "${s.title}": ${need.map(id => labels.get(id) ?? id).join('; ')}. Answer ${need.length === 1 ? 'it' : 'each one'} in the browser (outlined in red); storebound goes on by itself once every outlined answer is set (a box that stays empty: click it twice)`);
|
|
}
|
|
continue; // more of the form may be on this page
|
|
}
|
|
if (waitFor) {
|
|
if (!await answered(waitFor.pending)) { await Bun.sleep(tick); continue; }
|
|
say(`"${waitFor.title}" answered: storebound goes on`);
|
|
progress(`the next part after "${waitFor.title}"`);
|
|
waitFor = null;
|
|
}
|
|
if (pendingAdvance) {
|
|
pendingAdvance = false;
|
|
const moved = await advance(p).catch(() => false);
|
|
if (moved === 'draft') pendingAdvance = true; // Next follows the saved draft
|
|
if (moved) { await Bun.sleep(tick); continue; }
|
|
}
|
|
if (privacy) await privacyStep(p).catch(() => { /* navigating */ });
|
|
await Bun.sleep(tick);
|
|
}
|
|
if (timedOut) {
|
|
say(`nothing storebound knows happened for ${mins(idleMs)}: it stops here and closes the window${o.debugDir && dumps ? ` (debug: ${o.debugDir})` : ''}. Answers saved as drafts stay; run it again when the page is fixed.`);
|
|
await b.close();
|
|
return { filled, highlighted, fallback, timedOut: true, ...(o.debugDir && dumps ? { debugDir: o.debugDir } : {}) };
|
|
}
|
|
if (!b.isClosed() && !fallback) say(privacy
|
|
? 'review the summary, then press Publish yourself. storebound saved the answers as a draft (Save in each dialog); nothing is public until you press Publish. Close the window when done.'
|
|
: `review every field and press ${meta.stopBefore} yourself. storebound never saves, submits or publishes. Close the window when done.`);
|
|
else if (!b.isClosed()) say('close the browser window when done');
|
|
// the owner reviews and presses the final button, then closes the window: a reminder every 10 heartbeats, at most finalMs
|
|
const end = Date.now() + finalMs;
|
|
while (!b.isClosed() && Date.now() < end) {
|
|
await Promise.race([b.closed, Bun.sleep(Math.min(10 * heartbeatMs, Math.max(0, end - Date.now())))]);
|
|
if (!b.isClosed() && Date.now() < end) say('still open: storebound waits for you to review, press the final button and close the window');
|
|
}
|
|
if (!b.isClosed()) { say(`the window has been open for ${mins(finalMs)} after the fill: storebound closes it now`); await b.close(); }
|
|
await b.closed;
|
|
return { filled, highlighted, fallback, ...(o.debugDir && dumps ? { debugDir: o.debugDir } : {}) };
|
|
}
|