" is a plain text (a in a div with a click handler) ----
const textOf = async (loc: Locator): Promise => ((await loc.evaluate((el: any) => el.textContent ?? '')) as string).trim().replace(/\s+/g, ' ');
/** "Set Up " (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 {
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 " 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 {
const text = await textOf(loc);
if (!setUpAllowed(text, await flowWhere(loc, dataTypes))) { warn(TAG, `not clicking "${text}": only "Set Up " 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(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 {
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 "), as Apple's names. Read-only. */
export async function listedTypes(page: Page): Promise {
const out = new Set();
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 {
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 {
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;
/**
* 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(ms: number, fn: () => Promise, stop?: Stop): Promise {
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/" 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, 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/, 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): Promise {
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;
}
/**
* 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 {
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 {
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 {
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