storebound/cli/console/fill.ts
CRT 59150c1186 console fill apple-app-privacy: fit the Data Collection dialog to the real console
A real run (sanitized debug dump) showed why the walk stopped after Next:
the data types step is the same "Data Collection" dialog (URL unchanged),
and each checkbox's accessible name is its label run together with its
help text ("NameIncluding first or last name"). Some labels also differ
from Apple's help page ("Purchases", "Other Data").

- labelNames: the exact name, then the label as a prefix that ends where
  the help text starts (no lowercase letter follows), so "Name" never
  matches "Phone Number...", "Head" never "Health...", "Contact" never
  "Contacts...". Used for the data types and the purposes.
  TYPE_ALIASES maps Purchase History / Other Data Types to the console's
  labels.
- Dialog per step: a section's dialog is also found as the one visible
  dialog with a heading of that name.
- Every click and check is bounded (ACTION_MS, 10 s; the scan test now
  also rejects clicks without a timeout).
- The flow fixture mirrors the real dialog's structure (dialog/h2 title,
  group h3s, input name = Apple's enum, <label> with label + help <p>s,
  Save disabled until a type is ticked); no data from the dump. A test
  checks every Apple type matches exactly one real-style checkbox.
- Pitfall 15.

Publish is still never clicked; unknown steps still get the watchdog dump.
2026-10-12 07:17:15 +08:00

965 lines
57 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';
/** The longest any single click or check may take (Playwright waits for the control to be enabled and stable). */
export const ACTION_MS = 10_000;
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({ timeout: ACTION_MS });
else await loc.setChecked(action === 'check', { timeout: ACTION_MS });
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) {
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; }
}
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({ timeout: ACTION_MS });
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({ timeout: ACTION_MS });
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 } : {}) };
}