1457 lines
88 KiB
TypeScript
1457 lines
88 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>] [--use-chrome-session google|apple]
|
|
// 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 (signInReason: the top-level URL, the main frame's form, an Apple sign-in frame; never a Google widget
|
|
// frame) -> 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): storebound's own profile (~/.storebound/console-profile/<store>, or --profile /
|
|
// STOREBOUND_CONSOLE_PROFILE) in a Chrome storebound starts without automation switches; the owner's running Chrome
|
|
// when it has remote debugging (--cdp); a copy of the owner's Chrome sign-in only with --use-chrome-session google|apple
|
|
// (opt-in: Google binds its cookies to the device and can sign the owner out of the real Chrome).
|
|
// Google's /signin/rejected page is said plainly (GOOGLE_REJECTED).
|
|
// 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 { FORM_AREAS, refuseTemplate } from '../lib/template-answers';
|
|
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 { APPLE_PRIVACY_TYPES } from '../lib/app-privacy';
|
|
import { ALL_APPS, APP_PRIVACY_HEADING, consoleReached, CREATE_FORM_FIELD, createAppAllowed, DATA_TYPES_HEADING, DECLARATION_SAVE_FORMS, type FieldDef, FLOW_NAMES, flowAllowed, flowClass, type FlowWhere, type FormDef, formDef, FORM_OPENERS, forbiddenControl, quoteTolerant, forbiddenTick, hasToken, isAppleSignInFrame, isSignInText, isSignInUrl, type Name, navAllowed, type NavBases, type NavStep, navPlan, type SectionDef, setUpAllowed, setUpTexts, typeLabels } from '../lib/console-forms';
|
|
import { log, printJson, warn } from '../lib/log';
|
|
import { openAcross, withAccountIds } from './accounts';
|
|
import { appleAppId, withAppleAppId } from './app-id';
|
|
import { bareUrl } from './debug';
|
|
import { type ConsoleBrowser, GOOGLE_REJECTED, googleRejected, openConsoleBrowser, parseUseChromeSession } from './session';
|
|
import * as rt from '../lib/rt';
|
|
|
|
/* 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' ? (/['"\u2018\u2019\u201c\u201d]/.test(n) ? { name: quoteTolerant(n) } : { 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 role === 'checkbox' || role === 'radio' ? byParentText(root, role, names) : { state: 'none' };
|
|
}
|
|
|
|
/**
|
|
* The nearest non-empty texts around controls that has no accessible name of its own (no aria-label, aria-labelledby
|
|
* or associated <label>): its parents are walked up until one has text. App Privacy's purpose checkboxes are like
|
|
* that (verified October 2026): the label and its help text sit in a sibling, run together ("Third-Party
|
|
* AdvertisingSuch as displaying ..."). '' when the control has a name (getByRole already saw it). Read-only.
|
|
*/
|
|
export const PARENT_TEXTS = (els: any[]): string[] => els.map((el: any) => {
|
|
const norm = (t: string | null | undefined) => (t ?? '').trim().replace(/\s+/g, ' ');
|
|
if (norm(el.getAttribute('aria-label'))) return '';
|
|
const by = el.getAttribute('aria-labelledby');
|
|
if (by && norm(by.split(/\s+/).map((id: string) => el.ownerDocument.getElementById(id)?.textContent ?? '').join(' '))) return '';
|
|
if (el.labels && [...el.labels].some((l: any) => norm(l.textContent))) return '';
|
|
for (let a = el.parentElement; a && a !== el.ownerDocument.body; a = a.parentElement) { const t = norm(a.textContent); if (t) return t; }
|
|
return '';
|
|
});
|
|
|
|
/** A checkbox / radio without an accessible name, matched by its nearest non-empty parent text (PARENT_TEXTS; one
|
|
* page call for all of them). */
|
|
async function byParentText(root: Locator, role: string, names: Name[]): Promise<Found> {
|
|
const all = vis(root.locator(role === 'checkbox' ? 'input[type="checkbox"], [role="checkbox"]' : 'input[type="radio"], [role="radio"]'));
|
|
const texts: string[] = await all.evaluateAll(PARENT_TEXTS).catch(() => []);
|
|
if (!texts.some(Boolean)) return { state: 'none' };
|
|
for (const name of names) {
|
|
const re = typeof name === 'string' ? quoteTolerant(name) : name;
|
|
const hits = texts.flatMap((t, i) => (t && re.test(t) ? [i] : []));
|
|
if (hits.length === 1) return { state: 'ok', loc: all.nth(hits[0]!) };
|
|
if (hits.length > 1) return { state: 'ambiguous' };
|
|
}
|
|
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; }
|
|
else if (f.scoped) return { field: f, ok: false, seen: false };
|
|
}
|
|
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, ' ') || ((await loc.evaluateAll(PARENT_TEXTS).catch(() => [])) as string[])[0] || '';
|
|
}
|
|
|
|
/**
|
|
* 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> {
|
|
// buttons and links: the full word gate; ticks (checkbox / radio): only agreements are refused, help text is not a button label
|
|
if (action === 'click' ? forbiddenControl(name) : forbiddenTick(name)) {
|
|
warn(TAG, action === 'click' ? `not clicking "${name}": storebound never presses save, submit, publish, next or similar` : `not ticking "${name}": agreements are yours to accept`);
|
|
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);
|
|
}
|
|
|
|
/**
|
|
* Types a text field like a person: click it, select what is there, delete it, type the value key by key, read it back.
|
|
* App Store Connect's New App dialog (React; verified 2026-10-13) kept its state empty ("") after a programmatic value
|
|
* set on Name and SKU (a form_input-style set of the value), while clicking and typing worked. true when the field
|
|
* shows the value afterwards.
|
|
*/
|
|
export async function typeText(loc: Locator, v: string): Promise<boolean> {
|
|
await loc.click({ timeout: ACTION_MS });
|
|
await loc.press('ControlOrMeta+A', { timeout: ACTION_MS });
|
|
await loc.press('Backspace', { timeout: ACTION_MS });
|
|
await loc.pressSequentially(v, { delay: 15, timeout: ACTION_MS + v.length * 50 });
|
|
const now: string = await loc.inputValue({ timeout: ACTION_MS }).catch(() => v);
|
|
return now === v;
|
|
}
|
|
|
|
async function setField(r: Resolved, v: string | boolean): Promise<boolean> {
|
|
const f = r.field;
|
|
if (f.kind === 'text') return typeText(r.loc, String(v));
|
|
if (f.kind === 'checkbox') {
|
|
const name = await accName(r.loc);
|
|
if (await inMaterial(r.loc, 'material-checkbox')) return forbiddenTick(name) ? safeClick(r.loc, name, 'check') : materialToggle(r.loc, v === true);
|
|
try { return await safeClick(r.loc, name, v ? 'check' : 'uncheck'); } catch (e) { if (!await inMaterial(r.loc, 'material-checkbox')) throw e; return materialToggle(r.loc, v === true); }
|
|
}
|
|
if (f.kind === 'radio') {
|
|
const o = r.options?.[String(v)];
|
|
if (!o) return false;
|
|
const name = await accName(o);
|
|
if (forbiddenTick(name)) return safeClick(o, name, 'check');
|
|
try { if (!await safeClick(o, name, 'check')) return false; } catch (e) { if (!await inMaterialRadio(o)) throw e; }
|
|
return materialRadio(o);
|
|
}
|
|
// 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;
|
|
}
|
|
|
|
/** The control sits in one of Play Console's material elements (<material-radio>, <material-checkbox>). */
|
|
const inMaterial = (o: Locator, tag: string): Promise<boolean> => o.evaluate((el: any, t: string) => !!el.closest(t), tag).catch(() => false);
|
|
const inMaterialRadio = (o: Locator): Promise<boolean> => inMaterial(o, 'material-radio');
|
|
|
|
/**
|
|
* Play Console's <material-checkbox> (the same model problem as material-radio, seen on App content and the IARC
|
|
* questionnaire, 2026-10-12): a DOM click on the inner [role=checkbox] when its aria-checked differs from `want`.
|
|
* The name gate (agreements) runs before. true when the box ends up as wanted.
|
|
*/
|
|
export async function materialToggle(o: Locator, want: boolean): Promise<boolean> {
|
|
return o.evaluate((el: any, w: boolean) => {
|
|
const host = el.closest('material-checkbox');
|
|
const inner = el.getAttribute('role') === 'checkbox' ? el : host?.querySelector('[role="checkbox"]') ?? el;
|
|
const state = () => (inner.hasAttribute('aria-checked') ? inner.getAttribute('aria-checked') === 'true' : el.checked === true);
|
|
if (state() !== w) inner.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: window }));
|
|
return state() === w;
|
|
}, want).catch(() => false);
|
|
}
|
|
|
|
/**
|
|
* Play Console's <material-radio> (verified 2026-10-12 on Create app): setting the native input, or a click that does
|
|
* not reach the right element (a background window), leaves the Angular model empty and the form keeps showing
|
|
* "Select app or game". Only a click on the inner [role=radio] updates the model, so it gets a DOM click when it is not
|
|
* aria-checked yet. Other radios are left as they are. true when the radio is checked afterwards.
|
|
*/
|
|
export async function materialRadio(o: Locator): Promise<boolean> {
|
|
return o.evaluate((el: any) => {
|
|
const host = el.closest('material-radio');
|
|
const inner = el.getAttribute('role') === 'radio' ? el : host?.querySelector('[role="radio"]');
|
|
if (!inner) return el.checked !== false;
|
|
if (inner.getAttribute('aria-checked') !== 'true') inner.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: window }));
|
|
return inner.getAttribute('aria-checked') === 'true' || el.checked === true;
|
|
}).catch(() => false);
|
|
}
|
|
|
|
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, except one that blocks Next), its outlined controls: the flow waits
|
|
* until the owner set each one. */
|
|
pending?: Locator[][];
|
|
/** Agreements on the page that Next waits for and that are not ticked yet (the IARC Terms of Use). */
|
|
blocked?: string[];
|
|
}
|
|
|
|
/** The dialog of a section: by its names, else the one visible dialog with such a heading, else (the data type
|
|
* storebound just opened) the one visible dialog. */
|
|
async function sectionDialog(page: Page, sec: SectionDef, current?: string): Promise<Found> {
|
|
const names = sec.dialog ?? [];
|
|
let d = await find(page, 'dialog', names);
|
|
// a dialog titled otherwise (one dialog per step, the title elsewhere): the one visible dialog with such a heading
|
|
if (d.state === 'none') for (const n of names) {
|
|
const l = vis(page.getByRole('dialog').filter({ has: page.getByRole('heading', nameOpts(n)) }));
|
|
const c = await l.count();
|
|
if (c) { d = c === 1 ? { state: 'ok', loc: l } : { state: 'ambiguous' }; break; }
|
|
}
|
|
// the data type storebound just opened (App Privacy): its dialog may be titled otherwise; the one visible dialog
|
|
if (d.state === 'none' && sec.type && sec.type === current) {
|
|
const l = vis(page.getByRole('dialog'));
|
|
if (await l.count() === 1) d = { state: 'ok', loc: l };
|
|
}
|
|
return d;
|
|
}
|
|
|
|
/**
|
|
* Fills the first section of the form that is on the page and not done yet: locate every field first; any missing or
|
|
* ambiguous field -> fallback (nothing of that section is touched). Certain answers are set, the rest is highlighted
|
|
* 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, current?: string): Promise<FillStep> {
|
|
const byId = new Map(answers.map(a => [a.id, a]));
|
|
// the sections of one data type share their dialog names (one array): the dialog is looked up once per call, not
|
|
// once per section (App Privacy: 3 sections x 6 types, each lookup several page queries)
|
|
const dialogs = new Map<Name[], Found>();
|
|
// no visible dialog at all (the page between two data types): every dialog section is skipped with one query
|
|
let anyDialog: boolean | undefined;
|
|
for (const [i, sec] of def.sections.entries()) {
|
|
if (done.has(i)) continue;
|
|
let root: Locator = page;
|
|
if (sec.dialog) {
|
|
anyDialog ??= await vis(page.getByRole('dialog')).count() > 0;
|
|
if (!anyDialog) continue;
|
|
let d = dialogs.get(sec.dialog);
|
|
if (!d) { d = await sectionDialog(page, sec, current); dialogs.set(sec.dialog, d); }
|
|
if (d.state === 'none') continue;
|
|
if (d.state !== 'ok') return { section: i, title: sec.title, filled: [], highlighted: [], missing: [`dialog ${sec.title}`], fallback: true };
|
|
root = d.loc;
|
|
}
|
|
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[][] = [], blocked: string[] = [];
|
|
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);
|
|
// an agreement Next waits for (the IARC Terms of Use, verified 2026-10-13): the flow waits until the owner ticked it
|
|
if (r.field.blocksNext && r.loc && !await r.loc.isChecked({ timeout: ACTION_MS }).catch(() => false)) { pending.push([r.loc]); blocked.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, ...(blocked.length ? { blocked } : {}) };
|
|
}
|
|
return { section: null, filled: [], highlighted: [], missing: [], fallback: false };
|
|
}
|
|
|
|
/** Why a page counts as a sign-in page (signInReason): the rule that fired and the top-level URL (query dropped). */
|
|
export interface SignInWhy { rule: string; url: string }
|
|
|
|
/** Reads, in one frame, whether a password field and which sign-in texts are visible (element texts only, never values). */
|
|
async function signInForm(f: any, inFrame: boolean): Promise<{ pw: boolean; text: string | null }> {
|
|
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 };
|
|
});
|
|
return { pw: r.pw, text: r.texts.find(t => isSignInText(t, inFrame))?.trim().replace(/\s+/g, ' ') ?? null };
|
|
}
|
|
|
|
/**
|
|
* Why the page is a sign-in page, or null when it is not. Read-only, and only these rules count:
|
|
* 1. the TOP-LEVEL URL is a sign-in page (accounts.google.com, Google /signin or ServiceLogin, idmsa.apple.com /
|
|
* appleid.apple.com, App Store Connect /login: isSignInUrl);
|
|
* 2. the main frame shows a visible password field, or a visible text / label / placeholder only a sign-in form shows
|
|
* (isSignInText: "Email or phone", "Sign in with Passkey" ...);
|
|
* 3. a visible Apple sign-in frame (idmsa.apple.com / appleid.apple.com: Apple's form lives in one and starts with no
|
|
* password field) that shows a password field or a sign-in text.
|
|
* Any other frame never counts: Google pages (Play Console's home, its account chooser) embed accounts.google.com /
|
|
* ogs.google.com frames for the account widget and cookie rotation while the owner IS signed in. The header's avatar,
|
|
* name and email are not sign-in texts either. Only element texts are read (never values, cookies or storage).
|
|
*/
|
|
export async function signInReason(page: Page): Promise<SignInWhy | null> {
|
|
const top: string = page.url();
|
|
const url = bareUrl(top) || top;
|
|
if (isSignInUrl(top)) return { rule: 'the top-level URL is a sign-in page', url };
|
|
try {
|
|
const m = await signInForm(page.mainFrame(), false);
|
|
if (m.pw) return { rule: 'a visible password field in the page', url };
|
|
if (m.text) return { rule: `a sign-in form in the page ("${m.text}")`, url };
|
|
} catch { /* the page navigates */ }
|
|
for (const f of page.frames() as any[]) {
|
|
if (f === page.mainFrame() || !isAppleSignInFrame(f.url())) continue;
|
|
try {
|
|
const el = await f.frameElement();
|
|
if (!await el.isVisible()) continue;
|
|
const r = await signInForm(f, true);
|
|
if (r.pw || r.text) return { rule: `an Apple sign-in frame (${bareUrl(f.url())})${r.text ? ` with "${r.text}"` : ' with a password field'}`, url };
|
|
} catch { /* a frame that navigates or went away */ }
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** A sign-in page (signInReason found a rule). */
|
|
export async function signInPage(page: Page): Promise<boolean> {
|
|
return !!await signInReason(page);
|
|
}
|
|
|
|
// ---- 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');
|
|
const norm = (h: any) => (h.textContent ?? '').trim().replace(/\s+/g, ' ');
|
|
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(norm(h));
|
|
break;
|
|
}
|
|
// or the last visible heading before the control (outside dialogs) is "Data Types": the real page puts the heading
|
|
// and its Edit in one box and the data types ("Set Up <type>") in the next one
|
|
if (!inDataTypes) {
|
|
let last: any = null;
|
|
for (const h of el.ownerDocument.querySelectorAll('h1, h2, h3, h4, h5, h6, [role="heading"]')) {
|
|
if (h.closest('[role="dialog"], dialog') || !h.getClientRects().length || h.contains(el)) continue;
|
|
if (h.compareDocumentPosition(el) & 4) last = h; else break;
|
|
}
|
|
inDataTypes = !!last && names.includes(norm(last));
|
|
}
|
|
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[], pick: 'one' | 'first' = 'one'): 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); }
|
|
}
|
|
}
|
|
if (pick === 'first' && hits.length > 1) {
|
|
// the first in document order (Edit next to the Data Types heading comes before the Edit of any set-up type)
|
|
return firstInOrder(hits);
|
|
}
|
|
return hits.length === 1 ? hits[0]! : null;
|
|
}
|
|
|
|
async function firstInOrder(hits: Locator[]): Promise<Locator | null> {
|
|
let best: Locator | null = null;
|
|
for (const h of hits) {
|
|
if (!best) { best = h; continue; }
|
|
const earlier = await h.evaluate((a: any, b: any) => !!(a.compareDocumentPosition(b) & 4), await best.elementHandle({ timeout: ACTION_MS })).catch(() => false);
|
|
if (earlier) best = h;
|
|
}
|
|
return best;
|
|
}
|
|
|
|
// ---- App Privacy's Data Types section: "Set Up <type>" is a plain text (a <p> in a div with a click handler) ----
|
|
|
|
const textOf = async (loc: Locator): Promise<string> => ((await loc.evaluate((el: any) => el.textContent ?? '')) as string).trim().replace(/\s+/g, ' ');
|
|
|
|
/** "Set Up <type>" (or a console label of it) by exact text in the Data Types section, outside dialogs: the one match or null. */
|
|
async function setUpControl(page: Page, t: string, dataTypes: string[]): Promise<Locator | null> {
|
|
const hits: Locator[] = [];
|
|
for (const text of setUpTexts(t)) {
|
|
const all = vis(page.getByText(text, { exact: true }));
|
|
const c = await all.count();
|
|
for (let i = 0; i < c; i++) {
|
|
const l = all.nth(i);
|
|
if (setUpAllowed(await textOf(l), await flowWhere(l, dataTypes))) hits.push(l);
|
|
}
|
|
}
|
|
return hits.length === 1 ? hits[0]! : null;
|
|
}
|
|
|
|
/**
|
|
* The Set Up click: the element's whole text must be "Set Up <a data type storebound answers>" in the Data Types
|
|
* section of the App Privacy page, outside any dialog (setUpAllowed). Logged like every flow click.
|
|
*/
|
|
export async function setUpClick(loc: Locator, dataTypes: string[], say: (m: string) => void, onClick?: (name: string) => void): Promise<boolean> {
|
|
const text = await textOf(loc);
|
|
if (!setUpAllowed(text, await flowWhere(loc, dataTypes))) { warn(TAG, `not clicking "${text}": only "Set Up <data type>" in the Data Types section is clicked by its text`); return false; }
|
|
await loc.click({ timeout: ACTION_MS });
|
|
say(`clicked "${text}" (navigation)`);
|
|
onClick?.(text);
|
|
return true;
|
|
}
|
|
|
|
/** Every App Privacy type label the console shows (Apple's names and the console's own) -> Apple's name. */
|
|
const TYPE_OF_LABEL = new Map<string, string>(Object.values(APPLE_PRIVACY_TYPES).flatMap(([, n]) => typeLabels(n).map(l => [l, n] as [string, string])));
|
|
|
|
/** The leaf texts of the Data Types section (after its heading, up to the next heading, outside dialogs). Read-only. */
|
|
async function dataTypesTexts(page: Page): Promise<string[]> {
|
|
const dt = DATA_TYPES_HEADING.filter((n): n is string => typeof n === 'string');
|
|
return page.evaluate((names: string[]) => {
|
|
const shown = (e: any) => !!e.getClientRects().length;
|
|
const norm = (e: any) => (e.textContent ?? '').trim().replace(/\s+/g, ' ');
|
|
const hs = [...document.querySelectorAll('h1, h2, h3, h4, h5, h6, [role="heading"]')].filter(h => !h.closest('[role="dialog"], dialog') && shown(h));
|
|
const at = hs.findIndex(h => names.includes(norm(h)));
|
|
if (at < 0) return [];
|
|
const start = hs[at]!, end = hs[at + 1] ?? null;
|
|
const out: string[] = [];
|
|
for (const e of document.querySelectorAll('body *')) {
|
|
if (!(start.compareDocumentPosition(e) & 4) || start.contains(e)) continue;
|
|
if (end && !(e.compareDocumentPosition(end) & 4)) break;
|
|
if (e.children.length || e.closest('[role="dialog"], dialog') || !shown(e)) continue;
|
|
const t = norm(e);
|
|
if (t) out.push(t);
|
|
}
|
|
return out;
|
|
}, dt);
|
|
}
|
|
|
|
/** The data types the Data Types section lists (by their label or their "Set Up <type>"), as Apple's names. Read-only. */
|
|
export async function listedTypes(page: Page): Promise<string[]> {
|
|
const out = new Set<string>();
|
|
for (const t of await dataTypesTexts(page)) {
|
|
const name = TYPE_OF_LABEL.get(t.replace(/^Set Up /, '').replace(/ ayarla$/, ''));
|
|
if (name) out.add(name);
|
|
}
|
|
return [...out];
|
|
}
|
|
|
|
/**
|
|
* A data type set up before (its label in the Data Types section, no "Set Up"): the text of its row and its Edit
|
|
* (marked data-storebound-row-edit for the click; only when the row has exactly one Edit). Reads texts only.
|
|
*/
|
|
async function typeRow(page: Page, t: string): Promise<{ text: string; edit: Locator | null } | null> {
|
|
const dt = DATA_TYPES_HEADING.filter((n): n is string => typeof n === 'string');
|
|
const r: { text: string; edit: boolean } | null = await page.evaluate(([names, mine, all]: [string[], string[], string[]]) => {
|
|
const shown = (e: any) => !!e.getClientRects().length;
|
|
const norm = (e: any) => (e.textContent ?? '').trim().replace(/\s+/g, ' ');
|
|
document.querySelectorAll('[data-storebound-row-edit]').forEach(e => e.removeAttribute('data-storebound-row-edit'));
|
|
const hs = [...document.querySelectorAll('h1, h2, h3, h4, h5, h6, [role="heading"]')].filter(h => !h.closest('[role="dialog"], dialog') && shown(h));
|
|
const head = hs.find(h => names.includes(norm(h)));
|
|
if (!head) return null;
|
|
const leaves = [...document.querySelectorAll('body *')].filter(e => !e.children.length && !!(head.compareDocumentPosition(e) & 4) && !e.closest('[role="dialog"], dialog') && shown(e));
|
|
const leaf = mine.map(m => leaves.find(e => norm(e) === m)).find(Boolean);
|
|
if (!leaf) return null;
|
|
const other = (e: any) => { const x = norm(e).replace(/^Set Up /, ''); return all.includes(x) && !mine.includes(x); };
|
|
let row: any = leaf;
|
|
for (let a = leaf.parentElement; a && a !== document.body; a = a.parentElement) {
|
|
if (a.contains(head) || leaves.some(e => a.contains(e) && other(e))) break;
|
|
row = a;
|
|
}
|
|
const edits = [...row.querySelectorAll('button, a, [role="button"]')].filter((e: any) => /^(Edit|Düzenle)$/.test(norm(e)) && shown(e));
|
|
if (edits.length === 1) edits[0]!.setAttribute('data-storebound-row-edit', '1');
|
|
return { text: (row.innerText ?? norm(row)).replace(/\s+/g, ' '), edit: edits.length === 1 };
|
|
}, [dt, typeLabels(t), [...TYPE_OF_LABEL.keys()]]);
|
|
if (!r) return null;
|
|
return { text: r.text, edit: r.edit ? vis(page.locator('[data-storebound-row-edit="1"]')) : 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>;
|
|
|
|
/**
|
|
* The engine's internal waits that tests shorten (engineWaits); production keeps these defaults.
|
|
* pollMs the pause between two poll rounds
|
|
* quietMs 0 (production): a poll waits its whole time for something that is not there. Above 0 (tests, on local
|
|
* fixture pages): a poll also ends, as "not found", once the page has finished loading and its DOM has not
|
|
* changed for quietMs (a MutationObserver the poll puts into the page). A missing step then costs about
|
|
* quietMs instead of the whole step timeout, and a step that is there is still waited for as long as needed.
|
|
* settleMs the pause after a click that loads another page in place (switching the App Store Connect team)
|
|
* missMs fillSession: how long a field must stay missing (and at least 3 rounds) before it counts as a UI change
|
|
*/
|
|
export const WAITS = { pollMs: 200, quietMs: 0, settleMs: 300, missMs: 1500 };
|
|
/** Tests: sets engine waits (WAITS); returns the function that restores the previous ones. */
|
|
export function engineWaits(w: Partial<typeof WAITS>): () => void {
|
|
const before = { ...WAITS };
|
|
Object.assign(WAITS, w);
|
|
return () => { Object.assign(WAITS, before); };
|
|
}
|
|
|
|
/** How long the page has been loaded and unchanged (ms; 0 while it loads or is leaving for another page). Puts the
|
|
* observer in on the first call. */
|
|
async function quietFor(page: Page): Promise<number> {
|
|
return page.evaluate(() => {
|
|
const w = window as any;
|
|
if (!w.__storeboundQuiet) {
|
|
const q = { at: performance.now(), leaving: false };
|
|
w.__storeboundQuiet = q;
|
|
new MutationObserver(() => { q.at = performance.now(); }).observe(document, { subtree: true, childList: true, attributes: true, characterData: true });
|
|
// a click or script that starts a navigation: the old page stays "complete" until the new one commits
|
|
addEventListener('beforeunload', () => { q.leaving = true; });
|
|
}
|
|
const q = w.__storeboundQuiet;
|
|
return document.readyState === 'complete' && !q.leaving ? performance.now() - q.at : 0;
|
|
}).catch(() => 0);
|
|
}
|
|
|
|
/**
|
|
* Polls `fn` until it returns a value or the time is up (errors while the page navigates count as "not yet"). `stop`
|
|
* is checked first on every round: a sign-in page ends the wait at once (SignInSeen). With `page` and WAITS.quietMs
|
|
* (tests), a loaded page that stays unchanged for quietMs ends it early too (not found).
|
|
*/
|
|
export async function poll<T>(ms: number, fn: () => Promise<T | null>, stop?: Stop, page?: Page): Promise<T | null> {
|
|
const end = Date.now() + ms;
|
|
for (;;) {
|
|
if (stop && await stop().catch(() => false)) throw new SignInSeen();
|
|
try { const r = await fn(); if (r !== null) return r; } catch { /* navigating */ }
|
|
if (Date.now() >= end) return null;
|
|
if (page && WAITS.quietMs > 0 && await quietFor(page) >= WAITS.quietMs) return null;
|
|
await rt.sleep(WAITS.pollMs);
|
|
}
|
|
}
|
|
|
|
/** 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, page);
|
|
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, page);
|
|
}
|
|
|
|
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, page);
|
|
}
|
|
|
|
/** 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, page);
|
|
if (!f) { if (s.optional) { say(' (not on this page, skipped)'); continue; } return fail(s, 'not found'); }
|
|
if (f.state !== 'ok') return fail(s, 'more than one match');
|
|
const c = await navClick(f.loc, !!s.createApp);
|
|
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, page);
|
|
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;
|
|
}
|
|
|
|
/** Where the calling agent picks up when storebound's own fill cannot: its browser tool, with the plan and the read-back. */
|
|
export function agentPart(form: string): string {
|
|
return `the calling agent with its browser tool (\`storebound console plan ${form}\` has the steps and answers, \`storebound console verify ${form}\` checks the page)`;
|
|
}
|
|
|
|
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) the config does not prove ([check] / [owner]): ask the owner for them in one batch, put them in storebound.config.ts, run again.\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>] [--use-chrome-session google|apple] [--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(', ')})`);
|
|
refuseTemplate(config, FORM_AREAS[form] ?? [], `console fill ${form}`);
|
|
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') } : {}),
|
|
useChromeSession: parseUseChromeSession(args.get('use-chrome-session') ?? (args.has('use-chrome-session') ? true : undefined)),
|
|
});
|
|
// Ctrl+C / SIGTERM / SIGHUP close the browser gracefully (Chromium writes its cookies on a clean exit, so the sign-in
|
|
// is kept for the next run) and remove the temporary profile of copy mode: cli/console/chrome-guard.ts. Every other
|
|
// exit path (an error, a timeout) closes it in `finally`: a window left without its run becomes an orphan.
|
|
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 {
|
|
if (!b.isClosed()) await b.close();
|
|
b.cleanup();
|
|
}
|
|
}
|
|
|
|
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, then
|
|
* "Set Up <type>" for each collected type, a plain text clicked by its exact text (setUpClick), and the draft Save
|
|
* of its dialog). An uncertain answer stops it on that page until the owner set it; a type dialog step it does not
|
|
* know gets a debug dump and a pause naming the type (the watchdog). The final button (Publish, Submit, Save,
|
|
* Create), the "?" help buttons 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; };
|
|
/** App Privacy: the data type whose dialog storebound opened last (named in the log, the watchdog and the dumps). */
|
|
let currentType: string | undefined;
|
|
const clicked = (name: string) => {
|
|
const t = currentType;
|
|
// the type's last step was saved: what comes next is the App Privacy page again
|
|
const closing = !!t && def.sections.every((x, i) => x.type !== t || done.has(i));
|
|
progress(t && !closing ? `the "${t}" dialog step after "${name}"` : `the page after "${t ? `${t}: ` : ''}${name}"`);
|
|
if (o.debugEveryStep) { const p = active(); if (p) void rt.sleep(tick).then(() => dump(p, t && !/^Set Up /.test(name) ? `${t} after ${name}` : `after ${name}`)).catch(() => { /* gone */ }); }
|
|
};
|
|
const meta = formMeta(form, config), res = consoleResult(form, config);
|
|
const active = (): Page | undefined => b.pages().at(-1);
|
|
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');
|
|
};
|
|
|
|
const signInWhy = (p: Page) => signInReason(p).catch(() => null);
|
|
let lastWhy = '', firstWait = true, signInDumps = 0;
|
|
/** Says which rule made the page a sign-in page (again only when the rule or the URL changes). */
|
|
const sayWhy = (w: SignInWhy) => {
|
|
const k = `${w.rule}@${w.url}`;
|
|
if (k !== lastWhy) { lastWhy = k; say(`sign-in detected because: ${w.rule} at ${w.url}`); }
|
|
};
|
|
const consoleName = meta.store === 'apple' ? 'App Store Connect' : 'Play Console';
|
|
|
|
/**
|
|
* Waits until the console is open and no sign-in page shows (two rounds in a row). false: window closed or time up.
|
|
* Never silent: the rule that made the page a sign-in page is said; every heartbeat says the page and what it is;
|
|
* the watchdog dumps the page once when the sign-in has not finished within stepMs; --debug-dump also dumps on
|
|
* entering the wait and at each heartbeat (at most 3 sign-in dumps per run). A copied Chrome session that lands on a
|
|
* sign-in URL right after the launch is said plainly (it did not carry over).
|
|
*/
|
|
async function signedIn(): Promise<boolean> {
|
|
const start = Date.now(), end = start + signInMs, first = firstWait;
|
|
firstWait = false;
|
|
let told = false, ok = 0, beat = start, watched = false, entered = false, rejected = false;
|
|
const signInDump = async (p: Page | undefined, label: string) => {
|
|
if (!o.debugEveryStep || !p || signInDumps >= 3) return;
|
|
signInDumps++;
|
|
await dump(p, label).catch(() => null);
|
|
};
|
|
while (!b.isClosed() && Date.now() < end) {
|
|
const p = active();
|
|
const w = p ? await signInWhy(p) : null;
|
|
if (p && !rejected && googleRejected(p.url())) { rejected = true; say(GOOGLE_REJECTED); }
|
|
const state = (): string => !p ? 'no tab open' : `${bareUrl(p.url()) || p.url()}: ${w ? `a sign-in page (${w.rule})` : reached(p) ? 'the console' : 'neither a sign-in page nor the console yet'}`;
|
|
// the wait really starts: a sign-in page, or a page that is still not the console after a few rounds
|
|
if (p && !entered && (w || (!reached(p) && Date.now() - start >= 3 * tick))) { entered = true; await signInDump(p, 'sign-in wait'); }
|
|
if (Date.now() - beat >= heartbeatMs) {
|
|
beat = Date.now();
|
|
say(`still waiting for the sign-in, ${mins(Date.now() - start)} (${state()})`);
|
|
await signInDump(p, 'sign-in wait');
|
|
}
|
|
if (p && !watched && Date.now() - start >= stepMs) {
|
|
watched = true;
|
|
const d = await dump(p, 'sign-in not finished').catch(() => null);
|
|
say(`the sign-in has not finished after ${mins(Date.now() - start)}: ${state()}${d ? `. Debug saved to ${d.dir}` : ''}`);
|
|
}
|
|
if (p && w) {
|
|
ok = 0;
|
|
sayWhy(w);
|
|
if (!told) {
|
|
told = true;
|
|
if (first && b.mode === 'copy' && isSignInUrl(p.url())) say(`your copied Chrome session for ${consoleName} did not carry over (expired or tied to your own Chrome); sign in once in this window, or run without --use-chrome-session`);
|
|
signInHint();
|
|
}
|
|
} else if (p && reached(p)) { if (++ok >= 2) { if (told) say('signed in'); lastWhy = ''; return true; } }
|
|
else ok = 0;
|
|
await rt.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}). Opening it is left to ${agentPart(form)}; 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');
|
|
|
|
// the IARC follow-ups (optional sections) appear only after a Yes: the form ends with its last required section
|
|
const required = def.sections.flatMap((x, i) => (x.optional ? [] : [i]));
|
|
const last = required.at(-1) ?? 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 types = [...new Set(def.sections.map(x => x.type).filter((t): t is string => !!t))];
|
|
/** The same with the console's own labels ("Purchases" for Purchase History): what the click gates accept. */
|
|
const dataTypes = [...new Set(types.flatMap(typeLabels))];
|
|
/** Rounds a set-up type has been seen without its "Set Up" (a re-render must not count as "set up before"). */
|
|
const setUpSeen = new Map<string, number>();
|
|
const typeDone = (t: string) => def.sections.forEach((x, i) => { if (x.type === t) done.add(i); });
|
|
/** 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 && (required.every(i => done.has(i)) || (!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);
|
|
};
|
|
/** An open App Privacy dialog whose current step has no input at all (text only, plus Back / Cancel / Next). */
|
|
let infoRounds = 0;
|
|
const infoOnlyStep = async (p: Page): Promise<Locator | null> => {
|
|
// the last filled field sits on a step that is hidden now, so the dialog is found on the page itself
|
|
const d = await anchorDialog() ?? await (async () => { const v = vis(p.getByRole('dialog')); return (await v.count().catch(() => 0)) === 1 ? v : null; })();
|
|
if (!d) return null;
|
|
const inputs = await d.locator('input:not([type=hidden]), select, textarea, [role=checkbox], [role=radio], [role=switch], [role=textbox], [role=combobox]').filter({ visible: true }).count().catch(() => 1);
|
|
return inputs === 0 ? d : null;
|
|
};
|
|
/** 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. Opening it is left to ${agentPart(form)}; storebound fills it when it appears`); }
|
|
if (n >= 2) return;
|
|
opened.set(key, n + 1);
|
|
await flowClick(loc, form, dataTypes, say, clicked);
|
|
};
|
|
// the first Edit of the section (next to its heading); a type set up before has its own Edit further down
|
|
const editTypes = () => controlWhere(p, FLOW_NAMES.edit, w => w.inDataTypes && !w.inDialog, dataTypes, 'first');
|
|
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);
|
|
const typesAt = def.sections.findIndex(x => x.title === 'Data types');
|
|
if (typesAt >= 0 && await typesSaved(p)) {
|
|
done.add(typesAt);
|
|
progress('the first data type');
|
|
say('the Data Types section lists exactly the collected types of the config: storebound sets up each one');
|
|
return;
|
|
}
|
|
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 setUpType(p, t);
|
|
}
|
|
|
|
/** The Data Types section lists exactly the collected types of the config (all certain): their list needs no Edit. */
|
|
async function typesSaved(p: Page): Promise<boolean> {
|
|
const ans = pageAnswers.filter(a => a.id.startsWith('type:'));
|
|
if (!ans.length || !ans.every(a => a.certain)) return false;
|
|
const want = ans.filter(a => a.value === true).map(a => a.id.slice(5)).sort();
|
|
const listed = (await listedTypes(p)).sort();
|
|
return want.length > 0 && listed.join('|') === want.join('|');
|
|
}
|
|
|
|
/**
|
|
* One collected data type, the App Privacy page with no dialog open: "Set Up <type>" (a plain text: clicked by its
|
|
* exact text, setUpClick), else a button / link named like the type, else the type was set up before: its Edit only
|
|
* when the purposes its row shows differ from the config; otherwise it is left as it is (logged).
|
|
*/
|
|
async function setUpType(p: Page, t: string): Promise<void> {
|
|
const key = `type:${t}`, n = opened.get(key) ?? 0;
|
|
const go = async (loc: Locator, how: (l: Locator) => Promise<boolean>): Promise<void> => {
|
|
if (n === 2) { opened.set(key, 3); warn(TAG, `${t}: its questions did not open after two clicks: the console UI may have changed. Open them in the browser; storebound fills them when they appear`); }
|
|
if (n >= 2) return;
|
|
opened.set(key, n + 1);
|
|
currentType = t;
|
|
if (!await how(loc)) currentType = undefined;
|
|
};
|
|
const setUp = await setUpControl(p, t, dataTypes);
|
|
if (setUp) return go(setUp, l => setUpClick(l, dataTypes, say, clicked));
|
|
const named = await controlWhere(p, [...typeLabels(t), ...setUpTexts(t)], w => w.inDataTypes && !w.inDialog, dataTypes);
|
|
if (named) return go(named, l => flowClick(l, form, dataTypes, say, clicked));
|
|
const row = await typeRow(p, t);
|
|
if (!row) { setUpSeen.delete(t); return; }
|
|
// twice in a row: a page that is re-rendering can show the label a moment before its "Set Up"
|
|
const seen = (setUpSeen.get(t) ?? 0) + 1;
|
|
setUpSeen.set(t, seen);
|
|
if (seen < 2) return;
|
|
if (n > 0) { // storebound opened it, its dialog closed before every step was filled: the owner finished it
|
|
typeDone(t);
|
|
progress(`the next data type after "${t}"`);
|
|
say(`${t} was finished in the browser: storebound goes on with the next data type`);
|
|
return;
|
|
}
|
|
const purposes = pageAnswers.filter(a => a.id.startsWith(`purpose:${t}:`));
|
|
const shows = (a: Answer) => row.text.includes(a.id.slice(`purpose:${t}:`.length));
|
|
const readable = purposes.some(shows);
|
|
const same = readable && purposes.every(a => a.certain && (a.value === true) === shows(a));
|
|
if (readable && !same && purposes.every(a => a.certain) && row.edit) {
|
|
say(`${t} was set up before with other purposes than the config: storebound opens it with its Edit`);
|
|
return go(row.edit, l => flowClick(l, form, dataTypes, say, clicked));
|
|
}
|
|
typeDone(t);
|
|
progress(`the next data type after "${t}"`);
|
|
say(same ? `${t} was set up before with the purposes of the config: left as it is`
|
|
: `${t} was set up before; storebound cannot compare its answers on this page and leaves it as it is: check it in the summary before Publish`);
|
|
}
|
|
|
|
/** 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);
|
|
// an open dialog: its own buttons (the page behind it has many more)
|
|
const dl = vis(p.getByRole('dialog'));
|
|
const inDialog = await dl.count().catch(() => 0) === 1;
|
|
const own: string[] = inDialog ? await dl.getByRole('button').filter({ visible: true }).evaluateAll((els: any[]) => els.map(e => (e.getAttribute('aria-label') || e.textContent || '').trim().replace(/\s+/g, ' ')).filter(Boolean).slice(0, 10)).catch(() => []) : [];
|
|
const top = own.length ? own : 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 ${own.length ? 'in the dialog' : 'there'}: ${top.length ? top.map(t => `"${t}"`).join(', ') : '(none)'}${d ? `. Debug saved to ${d.dir}` : ''}`);
|
|
if (currentType && inDialog) say(`paused on "${currentType}": this step of its dialog is new to storebound. It is left to ${agentPart(form)}: answer it and go on in the dialog; storebound fills the next step it knows (it stops after ${mins(idleMs)} without progress)`);
|
|
say(`this part is left to ${agentPart(form)}; 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 rt.sleep(tick); continue; }
|
|
const w = await signInReason(p).catch(() => ({ rule: 'the page could not be read', url: bareUrl(p.url()) }));
|
|
if (w) {
|
|
sayWhy(w);
|
|
if (!signedOut && plan.length) signInHint();
|
|
signedOut = true;
|
|
await rt.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, false, currentType); } catch { await rt.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 WAITS.missMs) is a UI change
|
|
if (s.fallback && (++misses < 3 || Date.now() - firstMiss < WAITS.missMs)) { if (misses === 1) firstMiss = Date.now(); await rt.sleep(tick); continue; }
|
|
misses = 0;
|
|
// the miss lasts: fill every field that was found, leave the missing ones to the owner (only all-missing is a UI change)
|
|
if (s.fallback) { try { s = await fillPage(p, def, pageAnswers, done, true, currentType); } catch { await rt.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): filling ${s.missing.length === 1 ? 'it' : 'them'} is left to ${agentPart(form)}`);
|
|
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; the rest is left to ${agentPart(form)}, following 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('; ')}` : ''}`);
|
|
// Play Create app: the package name's read-only "Check availability" (verified 2026-10-12)
|
|
if (form === 'play-app-record' && s.filled.includes('packageName')) {
|
|
const check = await controlWhere(p, FLOW_NAMES.checkAvailability, () => true, dataTypes).catch(() => null);
|
|
if (check && await flowClick(check, form, dataTypes, say).catch(() => false)) {
|
|
const ok = await poll(5000, async () => ((await vis(p.getByText(/package name (is )?available/i)).count()) > 0 ? true : null), undefined, p).catch(() => null);
|
|
say(ok ? 'App package name: "Package name available"' : 'App package name: Play Console did not say "Package name available": check the message next to the field');
|
|
}
|
|
}
|
|
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);
|
|
if (need.length) 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)`);
|
|
if (s.blocked?.length) say(`agreement needed on "${s.title}": ${s.blocked.map(id => `"${labels.get(id) ?? id}"`).join(' and ')} is yours to tick (outlined in red; storebound never ticks an agreement; the page waits for it). storebound goes on by itself once it is ticked`);
|
|
}
|
|
continue; // more of the form may be on this page
|
|
}
|
|
if (waitFor) {
|
|
if (!await answered(waitFor.pending)) { await rt.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 rt.sleep(tick); continue; }
|
|
}
|
|
// an App Privacy dialog step with nothing to answer (the "Let's define two important terms" page between linked and
|
|
// tracking): no inputs, only Back / Cancel / Next -> Next is navigation
|
|
// seen twice in a row, so a step still rendering its inputs is never skipped
|
|
const info = privacy ? await infoOnlyStep(p) : null;
|
|
if (!info) infoRounds = 0;
|
|
if (info && (infoRounds += 1) >= 2) {
|
|
infoRounds = 0;
|
|
const next = await controlWhere(info, FLOW_NAMES.next, () => true, dataTypes);
|
|
if (next && await flowClick(next, form, dataTypes, say, clicked)) { say('an information-only step of the dialog: storebound pressed Next'); progress('the next step of the dialog'); await rt.sleep(tick); continue; }
|
|
}
|
|
if (privacy) await privacyStep(p).catch(() => { /* navigating */ });
|
|
await rt.sleep(tick);
|
|
}
|
|
/** The declaration's Save (the one visible button named exactly Save / Kaydet, no Next left on the page), polled at
|
|
* most stepMs: the content rating Summary renders after the last Next. true when it was pressed. */
|
|
async function declarationSave(p: Page): Promise<boolean> {
|
|
const btn = await poll(stepMs, async () => {
|
|
if (await nextControl(p).catch(() => null)) return null;
|
|
return controlWhere(p, FLOW_NAMES.save, w => !w.inDialog, dataTypes).catch(() => null);
|
|
}, undefined, p).catch(() => null);
|
|
if (!btn) return false;
|
|
return flowClick(btn, form, dataTypes, say, clicked).catch(() => false);
|
|
}
|
|
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 } : {}) };
|
|
}
|
|
// a Play App content declaration (content rating Summary, target audience): its Save is the agent's (verified
|
|
// 2026-10-13: nothing goes to Google before the owner's "Send changes for review" in Publishing overview)
|
|
const declaration = DECLARATION_SAVE_FORMS.includes(form);
|
|
const saved = declaration && !b.isClosed() && !fallback ? await declarationSave(active() ?? b.page) : false;
|
|
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.'
|
|
: declaration ? (saved
|
|
? `storebound pressed Save: the declaration is stored, nothing is sent yet. Once every App content card is done, press ${meta.stopBefore} in Publishing overview yourself. Close the window when done.`
|
|
: `storebound did not find the declaration's Save on this page: press Save (it only stores the declaration), then ${meta.stopBefore} in Publishing overview once every App content card is done. 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, rt.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 } : {}) };
|
|
}
|