Google binds its session cookies to the device (Device Bound Session Credentials) and signs out sessions whose cookies look stolen, which can sign the owner out of their real Chrome. The default is now storebound's own profile per store; storebound starts Chrome itself (--user-data-dir, --remote-debugging-port=0, no --enable-automation, navigator.webdriver false) and attaches over CDP. --use-chrome-session google|apple is an explicit opt-in that warns. /signin/rejected is said plainly.
1250 lines
74 KiB
TypeScript
1250 lines
74 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 { 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, type FieldDef, FLOW_NAMES, flowAllowed, flowClass, type FlowWhere, type FormDef, formDef, FORM_OPENERS, forbiddenControl, forbiddenTick, hasToken, isAppleSignInFrame, isSignInText, isSignInUrl, type Name, navAllowed, type NavBases, type NavStep, navPlan, 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';
|
|
|
|
/* 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> {
|
|
// 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);
|
|
}
|
|
|
|
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, current?: string): 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; }
|
|
}
|
|
// 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 };
|
|
}
|
|
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 };
|
|
}
|
|
|
|
/** 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>;
|
|
|
|
/**
|
|
* 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>] [--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(', ')})`);
|
|
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: 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, 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 Bun.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 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 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 && (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);
|
|
};
|
|
// 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. Answer it in the browser and go on in the dialog; storebound fills the next step it knows (it stops after ${mins(idleMs)} without progress)`);
|
|
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; }
|
|
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 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, false, currentType); } catch { await Bun.sleep(tick); continue; } // navigating
|
|
if (s.section !== null) {
|
|
// a page still loading can miss a field for a moment: only a miss that lasts (3 rounds, at least 1.5 s) is a UI change
|
|
if (s.fallback && (++misses < 3 || Date.now() - firstMiss < 1500)) { if (misses === 1) firstMiss = Date.now(); await Bun.sleep(tick); continue; }
|
|
misses = 0;
|
|
// the miss lasts: fill every field that was found, leave the missing ones to the owner (only all-missing is a UI change)
|
|
if (s.fallback) { try { s = await fillPage(p, def, pageAnswers, done, true, currentType); } catch { await Bun.sleep(tick); continue; } }
|
|
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 } : {}) };
|
|
}
|