diff --git a/cli/console/fill.ts b/cli/console/fill.ts index 37becc8..b20e708 100644 --- a/cli/console/fill.ts +++ b/cli/console/fill.ts @@ -30,7 +30,8 @@ import { join } from 'node:path'; import type { Ctx } from '../lib/ctx'; import { type Answer, type ConsoleResult, consoleResult, DISCLAIMER, FORM_IDS, type FormId, fillValue, formMeta, isForm, valueText } from '../lib/console-answers'; import type { ShipConfig } from '../lib/config'; -import { ALL_APPS, APP_PRIVACY_HEADING, consoleReached, CREATE_FORM_FIELD, createAppAllowed, DATA_TYPES_HEADING, type FieldDef, FLOW_NAMES, flowAllowed, flowClass, type FlowWhere, type FormDef, formDef, FORM_OPENERS, forbiddenControl, forbiddenTick, hasToken, isAppleSignInFrame, isSignInText, isSignInUrl, type Name, navAllowed, type NavBases, type NavStep, navPlan } from '../lib/console-forms'; +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'; @@ -196,7 +197,7 @@ export interface FillStep { * 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 = new Set(), partial = false): Promise { +export async function fillPage(page: Page, def: FormDef, answers: Answer[], done: Set = new Set(), partial = false, current?: string): Promise { const byId = new Map(answers.map(a => [a.id, a])); for (const [i, sec] of def.sections.entries()) { if (done.has(i)) continue; @@ -209,6 +210,11 @@ export async function fillPage(page: Page, def: FormDef, answers: Answer[], done 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; @@ -343,14 +349,25 @@ export async function flowWhere(loc: Locator, dataTypes: string[]): Promise 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((h.textContent ?? '').trim().replace(/\s+/g, ' ')); + 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 ") 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 }; @@ -371,7 +388,7 @@ export async function flowClick(loc: Locator, form: FormId, dataTypes: string[], } /** Visible controls named like `names` (buttons and links) that sit where `ok` says; the one match, or null. */ -async function controlWhere(root: Locator, names: Name[], ok: (w: FlowWhere) => boolean, dataTypes: string[]): Promise { +async function controlWhere(root: Locator, names: Name[], ok: (w: FlowWhere) => boolean, dataTypes: string[], pick: 'one' | 'first' = 'one'): Promise { const hits: Locator[] = []; for (const role of ['button', 'link']) { for (const n of names) { @@ -380,9 +397,119 @@ async function controlWhere(root: Locator, names: Name[], ok: (w: FlowWhere) => 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 { + 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 " 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) { @@ -700,9 +827,11 @@ const mins = (ms: number): string => (ms < 60_000 ? `${Math.round(ms / 1000)} s` * 3. a step not found -> the printed steps, and storebound keeps watching: a sign-in page that goes away -> from step * 1; the owner moving on by hand (the + button, the New App menu item, the app in the list appear) -> from that step * (resumeAt); the owner opening the form -> it is filled. - * 4. fills section by section and moves on by itself: Next (App Privacy: Get Started / Edit next to Data Types, each - * data type, and the draft Save of its dialogs). An uncertain answer stops it on that page until the owner set it. - * The final button (Publish, Submit, Save, Create) and agreements stay the owner's. + * 4. fills section by section and moves on by itself: Next (App Privacy: Get Started / Edit next to Data Types, then + * "Set Up " 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; @@ -717,7 +846,15 @@ export async function fillSession(b: ConsoleBrowser, o: FillSessionOpts): Promis }; /** Something storebound knows happened; `next`: what it waits for now. */ const progress = (next: string) => { progressAt = Date.now(); lastBeat = progressAt; expecting = next; stalled = false; }; - const clicked = (name: string) => { progress(`the page after "${name}"`); if (o.debugEveryStep) { const p = active(); if (p) void Bun.sleep(tick).then(() => dump(p, `after ${name}`)).catch(() => { /* gone */ }); } }; + /** 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); @@ -841,7 +978,12 @@ export async function fillSession(b: ConsoleBrowser, o: FillSessionOpts): Promis const last = def.sections.length - 1; const privacy = form === 'apple-app-privacy'; /** App Privacy: the data types storebound answers (their entries in the Data Types section open their questions). */ - const dataTypes = [...new Set(def.sections.map(x => x.type).filter((t): t is string => !!t))]; + 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(); + 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). */ @@ -896,7 +1038,8 @@ export async function fillSession(b: ConsoleBrowser, o: FillSessionOpts): Promis opened.set(key, n + 1); await flowClick(loc, form, dataTypes, say, clicked); }; - const editTypes = () => controlWhere(p, FLOW_NAMES.edit, w => w.inDataTypes && !w.inDialog, dataTypes); + // 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); @@ -908,13 +1051,73 @@ export async function fillSession(b: ConsoleBrowser, o: FillSessionOpts): Promis 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 open(`type:${t}`, t, await controlWhere(p, [t, `Set Up ${t}`, `${t} ayarla`], w => w.inDataTypes && !w.inDialog, dataTypes)); + 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 { + 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 " (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 { + const key = `type:${t}`, n = opened.get(key) ?? 0; + const go = async (loc: Locator, how: (l: Locator) => Promise): Promise => { + 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. */ @@ -922,8 +1125,13 @@ export async function fillSession(b: ConsoleBrowser, o: FillSessionOpts): Promis stalled = true; const dbg = await import('./debug'); const d = await dump(p, `unknown ${expecting}`).catch(() => null); - const top = dbg.topButtons(d?.summary ?? await dbg.summarize(p).catch(() => null)); - say(`${expecting} is not a page storebound knows (nothing it can fill or click after ${mins(Date.now() - progressAt)}). Buttons there: ${top.length ? top.map(t => `"${t}"`).join(', ') : '(none)'}${d ? `. Debug saved to ${d.dir}` : ''}`); + // 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}`); }; @@ -972,13 +1180,13 @@ export async function fillSession(b: ConsoleBrowser, o: FillSessionOpts): Promis } } let s: FillStep; - try { s = await fillPage(p, def, pageAnswers, done); } catch { await Bun.sleep(tick); continue; } // navigating + 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); } catch { await Bun.sleep(tick); continue; } } + 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; diff --git a/cli/lib/console-forms.ts b/cli/lib/console-forms.ts index b4f1c99..fd0bfcf 100644 --- a/cli/lib/console-forms.ts +++ b/cli/lib/console-forms.ts @@ -63,6 +63,8 @@ const IARC_NAMES: Record = { * read from a real console in October 2026): "Purchases" for Purchase History, "Other Data" for Other Data Types. */ export const TYPE_ALIASES: Record = { 'Purchase History': ['Purchases'], 'Other Data Types': ['Other Data'] }; +/** A data type's name and the console's other labels for it ("Purchase History", "Purchases"). */ +export const typeLabels = (t: string): string[] => [t, ...(TYPE_ALIASES[t] ?? [])]; /** * Candidate names of a checkbox labelled `label`: the exact name, then the name that STARTS with the label and goes on @@ -103,12 +105,13 @@ export function formDef(id: FormId, answers: Answer[]): FormDef { ] }]; const types = answers.filter(a => a.id.startsWith('type:')); if (types.length) sections.push({ title: 'Data types', fields: types.map(a => ({ id: a.id, kind: 'checkbox', names: labelNames(a.id.slice(5), TYPE_ALIASES[a.id.slice(5)]) })) }); - // one data type's dialog: purposes, linked to the user, tracking; one page each (Next between, Save at the end) - // or all on one page: each is its own section, the later two are `page`s + // one data type's dialog (opened by "Set Up " in the Data Types section): purposes, linked to the user, + // tracking; one step each (Next between, Save at the end) or all on one page. Every part is a `page`: a dialog step + // with none of its fields is an unknown step (the watchdog dumps it and pauses), never a reason to stop the run for (const a of types.filter(t => t.value === true && has(`linked:${t.id.slice(5)}`))) { const n = a.id.slice(5); - const dialog = [n, new RegExp(`^${esc(n)}\\b`, 'i')]; - sections.push({ title: `${n}: purposes`, dialog, type: n, fields: APPLE_PURPOSES.map(p => ({ id: `purpose:${n}:${p}`, kind: 'checkbox' as const, names: labelNames(p) })) }); + const dialog = typeLabels(n).flatMap(l => [l, new RegExp(`^${esc(l)}\\b`, 'i')]); + sections.push({ title: `${n}: purposes`, dialog, type: n, page: true, fields: APPLE_PURPOSES.map(p => ({ id: `purpose:${n}:${p}`, kind: 'checkbox' as const, names: labelNames(p) })) }); sections.push({ title: `${n}: linked to the user`, dialog, type: n, page: true, fields: [{ id: `linked:${n}`, kind: 'radio', options: yesNo([/^Yes,.*\blinked to the user/i], [/^No,.*\blinked to the user/i]) }] }); sections.push({ title: `${n}: tracking`, dialog, type: n, page: true, fields: [{ id: `tracking:${n}`, kind: 'radio', options: yesNo([/^Yes,.*\btracking/i], [/^No,.*\btracking/i]) }] }); } @@ -364,7 +367,9 @@ export const FORM_BUTTONS: Record = { 'apple-app-privacy': [ { button: 'Get Started', class: 'navigation', where: 'App Privacy page, first time: opens the questionnaire', auto: true }, { button: 'Edit (next to Data Types)', class: 'navigation', where: 'Data Types section: opens the data type list', auto: true }, - { button: ' / Set Up ', class: 'navigation', where: 'Data Types section: opens that type\'s questions', auto: true }, + { button: 'Set Up ', class: 'navigation', where: 'Data Types section, one per collected type (a plain text with a click handler, not a button): opens that type\'s questions', auto: true }, + { button: 'Edit (next to one data type)', class: 'navigation', where: 'Data Types section, a type set up before: reopens its questions, only when its purposes differ from the config', auto: true }, + { button: '? / More information', class: 'navigation', where: 'next to each heading and data type: help only (never needed)', auto: false }, { button: 'Next / Continue', class: 'navigation', where: 'questionnaire dialogs', auto: true }, { button: 'Back', class: 'navigation', where: 'questionnaire dialogs (never needed)', auto: false }, { button: 'Save', class: 'draft-save', where: 'questionnaire dialogs: stores the answers, not public until Publish', auto: true }, @@ -434,6 +439,21 @@ export function flowClass(form: FormId, name: string, role: string, where: FlowW return null; } +/** The texts that open one data type's questions ("Set Up Coarse Location"; Turkish " ayarla"), for its labels. */ +export const setUpTexts = (t: string): string[] => typeLabels(t).flatMap(l => [`Set Up ${l}`, `${l} ayarla`]); + +/** + * The Set Up click gate: App Store Connect shows "Set Up " as a plain text (a

in a div with a click handler, + * no button or link role), so the role-based gate cannot see it. It is clicked only when its whole text is exactly + * "Set Up " + one of the data types storebound answers (or their console labels), on the App Privacy page, in the Data + * Types section and outside any dialog. Nothing else is clicked by text (the "?" help buttons, Publish never). + */ +export function setUpAllowed(text: string, where: FlowWhere): boolean { + const n = norm(text).toLocaleLowerCase('tr'); + if (!where.privacyPage || !where.inDataTypes || where.inDialog || forbiddenControl(n)) return false; + return where.dataTypes.some(t => setUpTexts(t).some(x => x.toLocaleLowerCase('tr') === n)); +} + /** The forms whose draft save storebound clicks (the owner agreed): nothing there is public or sent before the final button. */ export const AUTO_DRAFT_SAVE: FormId[] = ['apple-app-privacy', 'play-content-rating']; diff --git a/docs/pitfalls.md b/docs/pitfalls.md index f1a1744..ceeffc7 100644 --- a/docs/pitfalls.md +++ b/docs/pitfalls.md @@ -9,6 +9,7 @@ fails without the fix. | # | date | lesson | guard | |---|---|---|---| +| 17 | 2026-10 | After the data types were saved, the real App Privacy page shows each type's "Set Up " as a plain `

` in a div with a click handler (no button role, no link) and puts the types in a box after the Data Types heading's box: role-based locators and the "nearest heading" check found nothing, so no type was set up. A "?" (More information) button sits next to each type. | `setUpAllowed` / `setUpTexts` (`cli/lib/console-forms.ts`: exact text "Set Up " of a type storebound answers, in the Data Types section, outside dialogs) and `setUpClick` / `setUpType` / `flowWhere` (last heading before the control) in `cli/console/fill.ts`; the per-type dialog steps are `page` sections, so an unknown step dumps and pauses instead of ending the run; `tests/fixtures/console/apple-app-privacy-setup.html` (structure of the real page) and `tests/console-flow.test.ts`. | | 16 | 2026-10 | `console fill play-app-record` with the copied Chrome cookies printed only "still waiting for the sign-in" for 5+ minutes on a signed-in Play Console: Google pages embed `accounts.google.com` frames (the account widget, cookie rotation), and the detector counted any frame on a sign-in host. | `signInReason`: a sign-in page only by its top-level URL, a visible password / email-entry form in the main frame, or a visible Apple sign-in frame (idmsa / appleid); other frames never count. Each detection logs "sign-in detected because: at "; heartbeats name the page; a copied session that lands on a sign-in URL is said plainly ("did not carry over"); the watchdog dumps the sign-in wait once after `--step-timeout`, `--debug-dump` up to 3 times (`tests/console-fill.test.ts`, chooser / home with the widget frames, a top-level accounts.google.com page). | | 15 | 2026-10 | The real App Privacy "Data Collection" dialog names each data type checkbox by its label run together with its help text ("NameIncluding first or last name"), and labels some types differently from Apple's help page ("Purchases", "Other Data"): exact-name matching found none. | `labelNames` (exact name, then the label as a prefix that ends where the help text starts) and `TYPE_ALIASES` in `cli/lib/console-forms.ts`; the flow fixture mirrors the real dialog's structure (`tests/console-flow.test.ts`). | | 14 | 2026-10 | `console fill apple-app-privacy` clicked Next onto a real console page it did not recognise and then waited 8 hours without a single log line. | Every wait of the console engine is bounded: a step watchdog (`--step-timeout`, 30 s) writes a sanitized debug dump to `.storebound/console-debug/` and prints the page's buttons and the manual steps; a line a minute while waiting; no progress for 20 min stops the run with exit 1; `--debug-dump` dumps every step (`cli/console/debug.ts`, `tests/console-flow.test.ts`, which also scans the engine for waits without a deadline). | diff --git a/skills/console-autofill/SKILL.md b/skills/console-autofill/SKILL.md index 2793f43..9c895d6 100644 --- a/skills/console-autofill/SKILL.md +++ b/skills/console-autofill/SKILL.md @@ -107,8 +107,9 @@ It is not available over MCP. from the chooser or the menu, and never Sign Out or a deny-listed name (a studio called "Next Games" is left for the owner to pick). 6. Multi-page forms: storebound fills a page and clicks its Next itself (App Privacy: Get Started or Edit next to - Data Types, Next, the data types' Save, then each data type in the Data Types section: purposes > Next > linked > - Next > tracking > Save; content rating: Save draft, then Next). It stops on a page only when an answer there is uncertain: it logs + Data Types, Next, the data types' Save, then "Set Up " for each collected type in the Data Types section: + purposes > Next > linked > Next > tracking > Save; a dialog step it does not know: a debug dump and + `paused on ""` until the owner answered it; content rating: Save draft, then Next). It stops on a page only when an answer there is uncertain: it logs `answer needed on "": `, leaves them outlined, and goes on by itself once the owner has set each one (a box that should stay empty: click it twice). It never clicks Next past an open question. 7. On the last page storebound prints "review every field and press yourself" @@ -151,7 +152,7 @@ content", "Create and set up your app"): | form | storebound clicks (navigation) | stays with the owner, and why | |---|---|---| -| App Privacy | Get Started; Edit next to Data Types (never the Privacy Policy Edit); each data type or "Set Up "; Next; **Save inside the questionnaire dialogs** (a draft: Apple publishes the answers only after Publish) | **Publish** and its confirmation: it puts the answers on the App Store product page | +| App Privacy | Get Started; Edit next to Data Types (never the Privacy Policy Edit; skipped when the section already lists exactly the config's types); "Set Up " for each collected type (a plain text, clicked by its exact text); a type's own Edit only when its purposes differ from the config; Next; **Save inside the questionnaire dialogs** (a draft: Apple publishes the answers only after Publish) | **Publish** and its confirmation: it puts the answers on the App Store product page; the "?" help buttons are never clicked | | New App (App Store) | + and New App (Cancel only to leave a team without the bundle id) | **Create**: creates the app record for good | | Age rating (guide only) | nothing (`storebound apple appinfo` sets it by API) | Set Up / Next / Done, if the owner wants to look | | Create app (Play) | Create app on the app list (opens the form) | the two **declarations** (agreements, never ticked) and **Create app** in the form: creates the app | diff --git a/skills/store-pitfalls/SKILL.md b/skills/store-pitfalls/SKILL.md index 1655c61..8390149 100644 --- a/skills/store-pitfalls/SKILL.md +++ b/skills/store-pitfalls/SKILL.md @@ -156,6 +156,17 @@ Each entry: **symptom** → cause → what to do → what guards it now. The ful - **Guard:** `signInReason` in `cli/console/fill.ts` (top-level URL, the main frame's password / email-entry form, a visible Apple sign-in frame; no other frame counts), the sign-in watchdog dump, `tests/console-fill.test.ts`. +## 17. App Privacy data types listed but never set up +- **Symptom:** after the data types were saved, `console fill apple-app-privacy` stops on the App Privacy page with + every type showing "Set Up " and nothing clicked. +- **Cause:** the real page shows "Set Up " as plain text (a `

` in a div with a click handler: no button, no + link) in a box after the Data Types heading, so role-based lookups miss it. +- **Do:** nothing by hand: storebound clicks each "Set Up " by its exact text (only for types it answers, never + the "?" help buttons or Publish) and fills that type's dialog step by step. A dialog step it does not know: it writes + a debug dump, says `paused on ""`, and goes on once the owner answered that step. +- **Guard:** `setUpAllowed` (`cli/lib/console-forms.ts`), `setUpClick` / `setUpType` (`cli/console/fill.ts`), + `tests/console-flow.test.ts` with `tests/fixtures/console/apple-app-privacy-setup.html`. + ## Adding a lesson Every bug found in real use gets an entry in `docs/pitfalls.md` and here, and, where possible, a check or a test that fails without the fix (see CONTRIBUTING.md). diff --git a/tests/console-flow.test.ts b/tests/console-flow.test.ts index 41e202a..719d8d3 100644 --- a/tests/console-flow.test.ts +++ b/tests/console-flow.test.ts @@ -11,7 +11,7 @@ import { fillSession } from '../cli/console/fill'; import type { ConsoleBrowser } from '../cli/console/session'; import { APPLE_DATA_TYPE_IDS, type AppleDataTypeId, type AppleTypeAnswer } from '../cli/lib/app-privacy'; import type { ShipConfig } from '../cli/lib/config'; -import { type FlowWhere, flowAllowed, flowClass, FORM_BUTTONS, labelNames, TYPE_ALIASES } from '../cli/lib/console-forms'; +import { type FlowWhere, flowAllowed, flowClass, FORM_BUTTONS, labelNames, setUpAllowed, setUpTexts, TYPE_ALIASES } from '../cli/lib/console-forms'; import { APPLE_PRIVACY_TYPES } from '../cli/lib/app-privacy'; import { AFTER_ALL_MS, AFTER_EACH_MS, launchTestBrowser, logLines, testScope } from './helpers/browser'; @@ -104,6 +104,15 @@ describe('button classes (FORM_BUTTONS, flowClass, flowAllowed)', () => { for (const n of ['Publish', 'Yayınla', 'Submit for Review']) expect(flowAllowed('apple-app-privacy', n, 'button', at({ inDialog: true, inDataTypes: true }))).toBe(false); expect(flowAllowed('apple-app-privacy', 'Get Started', 'checkbox', at())).toBe(false); }); + test('the Set Up text gate: exactly "Set Up " in the Data Types section, outside dialogs', () => { + const w = at({ inDataTypes: true, dataTypes: ['Coarse Location', 'Purchase History', 'Purchases'] }); + for (const t of ['Set Up Coarse Location', 'set up coarse location', 'Set Up Purchase History', 'Set Up Purchases']) expect(setUpAllowed(t, w)).toBe(true); + for (const t of ['?', 'More information', 'Set Up Device ID', 'Set Up Coarse Location now', 'Publish', 'Coarse Location', 'Edit']) expect(setUpAllowed(t, w)).toBe(false); + expect(setUpAllowed('Set Up Coarse Location', { ...w, inDataTypes: false })).toBe(false); + expect(setUpAllowed('Set Up Coarse Location', { ...w, inDialog: true })).toBe(false); + expect(setUpAllowed('Set Up Coarse Location', { ...w, privacyPage: false })).toBe(false); + expect(setUpTexts('Purchase History')).toContain('Set Up Purchases'); + }); test('other forms: Next is navigation; the content rating\'s Save draft is clicked; Save, Submit, Create, Done stay the owner\'s', () => { const w = at({ privacyPage: false }); for (const f of ['play-content-rating', 'play-target-audience', 'apple-app-record', 'play-app-record'] as const) { @@ -148,14 +157,15 @@ describe.skipIf(!browser)('App Privacy: the whole questionnaire (fixture apple-a expect((await run).fallback).toBe(false); }, TEST_MS); - test('answers exist: Edit next to Data Types (never the Privacy Policy Edit), then every type', async () => { + test('answers exist and the list differs from the config: Edit next to Data Types (never the Privacy Policy Edit), then every type', async () => { const b = await local(); const log = logLines(); - const run = scope.run(fillSession(b, { form: 'apple-app-privacy', config: privacy(), startUrl: base + 'apple-app-privacy-flow.html?existing=1', ...fast(log) })); + const config = privacy({ COARSE_LOCATION: { collected: true, purposes: ['ANALYTICS'], linked: false, tracking: false } }); + const run = scope.run(fillSession(b, { form: 'apple-app-privacy', config, startUrl: base + 'apple-app-privacy-flow.html?existing=1', ...fast(log) })); await log.wait('the Publish line', l => l.some(m => m.startsWith(PUBLISH))); expect((await clicks(b)).filter(x => x === 'Edit')).toHaveLength(1); expect(log.lines.some(m => m.startsWith('Data collection was answered before'))).toBe(true); - expect(await b.page.evaluate(() => (window as any).__saves)).toBe(3); + expect(await b.page.evaluate(() => (window as any).__saves)).toBe(4); // the list + three types expect(await clicks(b)).not.toContain('Publish'); expect(navAsked(log.lines)).toEqual([]); await b.context.close(); @@ -197,6 +207,122 @@ describe.skipIf(!browser)('App Privacy: the whole questionnaire (fixture apple-a }, TEST_MS); }); +/** Six collected types as on the real page (Purchase History shows as "Purchases"), every answer certain. */ +const SIX: Partial> = { + COARSE_LOCATION: { collected: true, purposes: ['ANALYTICS', 'APP_FUNCTIONALITY'], linked: false, tracking: false }, + EMAILS_OR_TEXT_MESSAGES: { collected: true, purposes: ['APP_FUNCTIONALITY'], linked: true, tracking: false }, + DEVICE_ID: { collected: true, purposes: ['THIRD_PARTY_ADVERTISING', 'ANALYTICS'], linked: false, tracking: true }, + PURCHASE_HISTORY: { collected: true, purposes: ['APP_FUNCTIONALITY'], linked: true, tracking: false }, + PRODUCT_INTERACTION: { collected: true, purposes: ['ANALYTICS', 'PRODUCT_PERSONALIZATION'], linked: false, tracking: false }, + CRASH_DATA: { collected: true, purposes: ['APP_FUNCTIONALITY'], linked: false, tracking: false }, +}; +const six = (over: Partial> = {}) => privacy({ CRASH_DATA: SIX.CRASH_DATA!, ...SIX, ...over }); +const SIX_NAMES = ['Coarse Location', 'Emails or Text Messages', 'Device ID', 'Purchase History', 'Product Interaction', 'Crash Data']; +const SETUP = 'apple-app-privacy-setup.html'; +const answersOf = (b: ConsoleBrowser) => b.page.evaluate(() => (window as any).__answers); + +describe.skipIf(!browser)('App Privacy: each data type set up from the real page ("Set Up " as plain text; fixture apple-app-privacy-setup.html)', () => { + test('every collected type is set up through its dialog (purposes > Next > linked > Next > tracking > Save); "?", Publish and the Edits never clicked; every step dumped', async () => { + const dir = mkdtempSync(join(tmpdir(), 'sb-console-debug-')); + try { + const b = await local(); + const log = logLines(); + const run = scope.run(fillSession(b, { form: 'apple-app-privacy', config: six(), startUrl: base + SETUP, ...fast(log), debugDir: dir, debugEveryStep: true })); + await log.wait('the Publish line', l => l.some(m => m.startsWith(PUBLISH))); + expect(await b.page.evaluate(() => (window as any).__setups)).toEqual(SIX_NAMES.map(n => `Set Up ${n}`)); + expect(await b.page.evaluate(() => (window as any).__saves)).toBe(6); + const got = await answersOf(b); + expect(got['Device ID']).toEqual({ purposes: ['Third-Party Advertising', 'Analytics'], linked: 'no', tracking: 'yes' }); + expect(got['Purchase History']).toEqual({ purposes: ['App Functionality'], linked: 'yes', tracking: 'no' }); + expect(got['Product Interaction']).toEqual({ purposes: ['Analytics', 'Product Personalization'], linked: 'no', tracking: 'no' }); + expect(Object.keys(got).sort()).toEqual([...SIX_NAMES].sort()); + const c = await clicks(b); + for (const never of ['?', 'Publish', 'Edit', 'Cancel', 'Back']) expect(c).not.toContain(never); + expect(c.filter(x => x === 'Next')).toHaveLength(12); + expect(c.filter(x => x === 'Save')).toHaveLength(6); + expect(log.lines).toContain('the Data Types section lists exactly the collected types of the config: storebound sets up each one'); + expect(log.lines).toContain('clicked "Set Up Purchase History" (navigation)'); + expect(log.lines.some(m => m.startsWith('answer needed'))).toBe(false); + expect(navAsked(log.lines)).toEqual([]); + // --debug-dump: the start, each Set Up, each dialog step and each filled step + const names = readdirSync(dir); + for (const want of [/-after-set-up-coarse-location$/, /-coarse-location-after-next$/, /-filled-coarse-location-purposes$/, /-filled-coarse-location-linked-to-/, /-filled-crash-data-tracking$/]) + expect(names.some(n => want.test(n))).toBe(true); + const step = names.find(n => /-coarse-location-after-next$/.test(n))!; + expect(JSON.parse(readFileSync(join(dir, step, 'page.json'), 'utf8')).dialogs).toContain('Coarse Location'); + await b.context.close(); + expect((await run).fallback).toBe(false); + } finally { rmSync(dir, { recursive: true, force: true }); } + }, TEST_MS); + + test('an uncertain answer ("you decide") pauses on its step, names it, and goes on once the owner set it', async () => { + const b = await local(); + const log = logLines(); + const config = six({ PURCHASE_HISTORY: { collected: true, purposes: ['APP_FUNCTIONALITY'], tracking: false } }); // linked: the owner's + const run = scope.run(fillSession(b, { form: 'apple-app-privacy', config, startUrl: base + SETUP, ...fast(log) })); + await log.wait('the stop', l => l.some(m => m.startsWith('answer needed on "Purchase History: linked to the user": Purchase History: linked to the user\'s identity?'))); + await Bun.sleep(600); // several rounds: Next is not clicked past the open question + const yes = b.page.getByRole('radio', { name: /^Yes, purchase history .* linked/ }); + expect(await yes.isVisible()).toBe(true); + expect(await b.page.evaluate(() => (window as any).__saves)).toBe(3); + await yes.check(); // the owner answers + await log.wait('the Publish line', l => l.some(m => m.startsWith(PUBLISH))); + expect(log.lines).toContain('"Purchase History: linked to the user" answered: storebound goes on'); + expect((await answersOf(b))['Purchase History'].linked).toBe('yes'); + expect(await b.page.evaluate(() => (window as any).__saves)).toBe(6); + expect(await clicks(b)).not.toContain('Publish'); + expect(await clicks(b)).not.toContain('?'); + await b.context.close(); + await run; + }, TEST_MS); + + test('an unknown dialog step: a debug dump, a pause that names the data type, within the step timeout; nothing clicked past it', async () => { + const dir = mkdtempSync(join(tmpdir(), 'sb-console-debug-')); + try { + const b = await local(); + const log = logLines(); + const started = Date.now(); + const run = scope.run(fillSession(b, { + form: 'apple-app-privacy', config: six(), startUrl: `${base}${SETUP}?unknown=Device%20ID`, ...fast(log), + stepTimeoutMs: 1500, idleLimitMs: 60_000, heartbeatMs: 700, debugDir: dir, + })); + await log.wait('the pause', l => l.some(m => m.startsWith('paused on "Device ID"'))); + expect(Date.now() - started).toBeLessThan(30_000); + const said = log.lines.join('\n'); + expect(said).toMatch(/the "Device ID" dialog step after "Next" is not a page storebound knows .*Buttons in the dialog: "Back", "Cancel", "Next"/); + expect(said).toContain(`Debug saved to ${dir}`); + const sub = readdirSync(dir).find(n => /unknown/.test(n))!; + const pj = JSON.parse(readFileSync(join(dir, sub, 'page.json'), 'utf8')); + expect(pj.dialogs).toContain('Device ID'); + expect(pj.controls).toContainEqual({ role: 'switch', name: 'Account identity', checked: false }); + await Bun.sleep(500); + const c = await clicks(b); + expect(c.filter(x => x === 'Next')).toHaveLength(2 * 2 + 1); // two types done, Device ID's first step only + expect(c).not.toContain('Publish'); + expect(c).not.toContain('?'); + await b.context.close(); + expect((await run).fallback).toBe(false); + } finally { rmSync(dir, { recursive: true, force: true }); } + }, TEST_MS); + + test('types set up before: the same purposes are left as they are, other purposes are opened with their Edit', async () => { + const b = await local(); + const log = logLines(); + const url = `${base}${SETUP}?preset=${encodeURIComponent('Crash Data:App Functionality')}&preset=${encodeURIComponent('Device ID:Analytics')}`; + const run = scope.run(fillSession(b, { form: 'apple-app-privacy', config: six(), startUrl: url, ...fast(log) })); + await log.wait('the Publish line', l => l.some(m => m.startsWith(PUBLISH))); + expect(log.lines).toContain('Crash Data was set up before with the purposes of the config: left as it is'); + expect(log.lines).toContain('Device ID was set up before with other purposes than the config: storebound opens it with its Edit'); + const got = await answersOf(b); + expect(got['Device ID'].purposes).toEqual(['Third-Party Advertising', 'Analytics']); + expect(got['Crash Data']).toBeUndefined(); + expect((await clicks(b)).filter(x => x === 'Edit')).toHaveLength(1); // Device ID's own, never the section's + expect(await clicks(b)).not.toContain('Publish'); + await b.context.close(); + await run; + }, TEST_MS); +}); + describe.skipIf(!browser)('the other forms: navigation by itself, final buttons and draft saves left to the owner', () => { test('content rating: Save draft and Next by itself on each page, a stop at the uncertain questions until answered, the Summary; Submit never clicked', async () => { const b = await local(); @@ -265,7 +391,7 @@ describe.skipIf(!browser)('watchdog: an unknown page after a click never means a const said = log.lines.join('\n'); expect(said).toContain('clicked "Next" (navigation)'); expect(said).toMatch(/still waiting for the page after "Next", \d+ s/); - expect(said).toMatch(/the page after "Next" is not a page storebound knows .*Buttons there: .*"Done"/); + expect(said).toMatch(/the page after "Next" is not a page storebound knows .*Buttons in the dialog: .*"Done"/); expect(said).toContain(`Debug saved to ${dir}`); expect(said).toContain('do this part yourself'); expect(said).toContain('it stops here'); diff --git a/tests/fixtures/console/apple-app-privacy-setup.html b/tests/fixtures/console/apple-app-privacy-setup.html new file mode 100644 index 0000000..f56e16b --- /dev/null +++ b/tests/fixtures/console/apple-app-privacy-setup.html @@ -0,0 +1,105 @@ + + +App Store Connect + +

+
+

App Privacy

+
+
+

Privacy Policy

+
+

https://example.com/privacy

+
+
+

Data Types

+
+
+
+ +