console fill apple-app-privacy: fit the Data Collection dialog to the real console
A real run (sanitized debug dump) showed why the walk stopped after Next:
the data types step is the same "Data Collection" dialog (URL unchanged),
and each checkbox's accessible name is its label run together with its
help text ("NameIncluding first or last name"). Some labels also differ
from Apple's help page ("Purchases", "Other Data").
- labelNames: the exact name, then the label as a prefix that ends where
the help text starts (no lowercase letter follows), so "Name" never
matches "Phone Number...", "Head" never "Health...", "Contact" never
"Contacts...". Used for the data types and the purposes.
TYPE_ALIASES maps Purchase History / Other Data Types to the console's
labels.
- Dialog per step: a section's dialog is also found as the one visible
dialog with a heading of that name.
- Every click and check is bounded (ACTION_MS, 10 s; the scan test now
also rejects clicks without a timeout).
- The flow fixture mirrors the real dialog's structure (dialog/h2 title,
group h3s, input name = Apple's enum, <label> with label + help <p>s,
Save disabled until a type is ticked); no data from the dump. A test
checks every Apple type matches exactly one real-style checkbox.
- Pitfall 15.
Publish is still never clicked; unknown steps still get the watchdog dump.
This commit is contained in:
parent
8b669f5c86
commit
59150c1186
7 changed files with 135 additions and 41 deletions
|
|
@ -25,7 +25,7 @@ import { dirname, join } from 'node:path';
|
|||
import type { ShipConfig } from '../lib/config';
|
||||
import type { FormId } from '../lib/console-answers';
|
||||
import { hasToken, type Name, NAV_BASES, type NavBases, type NavStep, navPlan, playDeveloperId, switchAllowed } from '../lib/console-forms';
|
||||
import { accName, findAny, type Locator, navClick, navigate, type NavResult, type Page, poll, realRole, safeClick, signInPage, SignInSeen, type Stop, vis } from './fill';
|
||||
import { ACTION_MS, accName, findAny, type Locator, navClick, navigate, type NavResult, type Page, poll, realRole, safeClick, signInPage, SignInSeen, type Stop, vis } from './fill';
|
||||
|
||||
// ---- the cache ----
|
||||
|
||||
|
|
@ -182,7 +182,7 @@ async function accountId(page: Page, a: PlayAccount, listed: string[], ms: numbe
|
|||
const row = page.locator(`[data-storebound-account="${a.key}"]`);
|
||||
const role = await realRole(row).catch(() => '');
|
||||
if (!switchAllowed(a.name, ['link', 'menuitem', 'menuitemradio', 'option', 'radio', 'row', 'button'].includes(role) ? role : 'row', listed)) return null;
|
||||
await row.click();
|
||||
await row.click({ timeout: ACTION_MS });
|
||||
return poll(ms, async () => playDeveloperId(page.url()), stop);
|
||||
}
|
||||
|
||||
|
|
@ -231,7 +231,7 @@ export async function switchTeam(page: Page, name: string, listed: string[], ms:
|
|||
if (it.current) { await closeMenu(page); return true; }
|
||||
const real = await accName(it.loc), role = await realRole(it.loc);
|
||||
if (!switchAllowed(real, role, listed)) { await closeMenu(page); return false; }
|
||||
await it.loc.click();
|
||||
await it.loc.click({ timeout: ACTION_MS });
|
||||
await page.waitForLoadState('domcontentloaded', { timeout: ms }).catch(() => { /* same page, or slow: read what is there */ });
|
||||
await Bun.sleep(300);
|
||||
return true;
|
||||
|
|
|
|||
|
|
@ -40,6 +40,8 @@ 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). */
|
||||
|
|
@ -111,8 +113,8 @@ export async function accName(loc: Locator): Promise<string> {
|
|||
*/
|
||||
export async function safeClick(loc: Locator, name: string, action: 'click' | 'check' | 'uncheck' = 'click'): Promise<boolean> {
|
||||
if (forbiddenControl(name)) { warn(TAG, `not clicking "${name}": storebound never presses save, submit, publish, next or similar`); return false; }
|
||||
if (action === 'click') await loc.click();
|
||||
else await loc.setChecked(action === 'check');
|
||||
if (action === 'click') await loc.click({ timeout: ACTION_MS });
|
||||
else await loc.setChecked(action === 'check', { timeout: ACTION_MS });
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -194,7 +196,13 @@ export async function fillPage(page: Page, def: FormDef, answers: Answer[], done
|
|||
if (done.has(i)) continue;
|
||||
let root: Locator = page;
|
||||
if (sec.dialog) {
|
||||
const d = await find(page, 'dialog', sec.dialog);
|
||||
let d = await find(page, 'dialog', sec.dialog);
|
||||
// a dialog titled otherwise (one dialog per step, the title elsewhere): the one visible dialog with such a heading
|
||||
if (d.state === 'none') for (const n of sec.dialog) {
|
||||
const l = vis(page.getByRole('dialog').filter({ has: page.getByRole('heading', nameOpts(n)) }));
|
||||
const c = await l.count();
|
||||
if (c) { d = c === 1 ? { state: 'ok', loc: l } : { state: 'ambiguous' }; break; }
|
||||
}
|
||||
if (d.state === 'none') continue;
|
||||
if (d.state !== 'ok') return { section: i, title: sec.title, filled: [], highlighted: [], missing: [`dialog ${sec.title}`], fallback: true };
|
||||
root = d.loc;
|
||||
|
|
@ -276,7 +284,7 @@ export async function realRole(loc: Locator): Promise<string> {
|
|||
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();
|
||||
await loc.click({ timeout: ACTION_MS });
|
||||
return { clicked: true, name };
|
||||
}
|
||||
|
||||
|
|
@ -324,7 +332,7 @@ export async function flowClick(loc: Locator, form: FormId, dataTypes: string[],
|
|||
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();
|
||||
await loc.click({ timeout: ACTION_MS });
|
||||
say(`clicked "${name}" (${flowClass(form, name, role, where)})`);
|
||||
onClick?.(name);
|
||||
return true;
|
||||
|
|
|
|||
|
|
@ -58,6 +58,24 @@ const IARC_NAMES: Record<string, Name[]> = {
|
|||
unrestrictedInternet: [/unrestricted (internet|web)/i, /web browser/i],
|
||||
};
|
||||
|
||||
/**
|
||||
* App Store Connect's labels where they differ from the names of Apple's help page (App Privacy > Data Collection,
|
||||
* read from a real console in October 2026): "Purchases" for Purchase History, "Other Data" for Other Data Types.
|
||||
*/
|
||||
export const TYPE_ALIASES: Record<string, string[]> = { 'Purchase History': ['Purchases'], 'Other Data Types': ['Other Data'] };
|
||||
|
||||
/**
|
||||
* Candidate names of a checkbox labelled `label`: the exact name, then the name that STARTS with the label and goes on
|
||||
* with its help text without a separator (the real Data Collection dialog: "NameIncluding first or last name",
|
||||
* "Email AddressIncluding but not limited to ..."). The label must end where the help text starts (no lowercase
|
||||
* letter follows), so "Contact" never matches "Contacts..."; two labels on one checkbox are told apart by find()
|
||||
* (ambiguous) and by the exact names first.
|
||||
*/
|
||||
export function labelNames(label: string, aliases: string[] = []): Name[] {
|
||||
const all = [label, ...aliases];
|
||||
return [...all, ...all.map(l => new RegExp(`^${esc(l)}(?![a-z\u00e0-\u024f])`))];
|
||||
}
|
||||
|
||||
/** Field definitions for the answers of one form (App Privacy has one section per collected data type). */
|
||||
export function formDef(id: FormId, answers: Answer[]): FormDef {
|
||||
const has = (aid: string) => answers.some(a => a.id === aid);
|
||||
|
|
@ -84,13 +102,13 @@ export function formDef(id: FormId, answers: Answer[]): FormDef {
|
|||
{ id: 'collects', kind: 'radio', options: yesNo([/^Yes, we collect data/i], [/^No, we do not collect data/i]) },
|
||||
] }];
|
||||
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: [a.id.slice(5)] })) });
|
||||
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
|
||||
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: [p] })) });
|
||||
sections.push({ title: `${n}: purposes`, dialog, type: n, 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]) }] });
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue