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 { ShipConfig } from '../lib/config';
|
||||||
import type { FormId } from '../lib/console-answers';
|
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 { 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 ----
|
// ---- 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 row = page.locator(`[data-storebound-account="${a.key}"]`);
|
||||||
const role = await realRole(row).catch(() => '');
|
const role = await realRole(row).catch(() => '');
|
||||||
if (!switchAllowed(a.name, ['link', 'menuitem', 'menuitemradio', 'option', 'radio', 'row', 'button'].includes(role) ? role : 'row', listed)) return null;
|
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);
|
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; }
|
if (it.current) { await closeMenu(page); return true; }
|
||||||
const real = await accName(it.loc), role = await realRole(it.loc);
|
const real = await accName(it.loc), role = await realRole(it.loc);
|
||||||
if (!switchAllowed(real, role, listed)) { await closeMenu(page); return false; }
|
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 page.waitForLoadState('domcontentloaded', { timeout: ms }).catch(() => { /* same page, or slow: read what is there */ });
|
||||||
await Bun.sleep(300);
|
await Bun.sleep(300);
|
||||||
return true;
|
return true;
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,8 @@ export type Page = any;
|
||||||
export type Locator = any;
|
export type Locator = any;
|
||||||
|
|
||||||
const TAG = 'console';
|
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';
|
const CHECK = 'storebound: check this';
|
||||||
|
|
||||||
/** Locators with only the visible matches (hidden earlier pages stay in many consoles' DOM). */
|
/** 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> {
|
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 (forbiddenControl(name)) { warn(TAG, `not clicking "${name}": storebound never presses save, submit, publish, next or similar`); return false; }
|
||||||
if (action === 'click') await loc.click();
|
if (action === 'click') await loc.click({ timeout: ACTION_MS });
|
||||||
else await loc.setChecked(action === 'check');
|
else await loc.setChecked(action === 'check', { timeout: ACTION_MS });
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -194,7 +196,13 @@ export async function fillPage(page: Page, def: FormDef, answers: Answer[], done
|
||||||
if (done.has(i)) continue;
|
if (done.has(i)) continue;
|
||||||
let root: Locator = page;
|
let root: Locator = page;
|
||||||
if (sec.dialog) {
|
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 === 'none') continue;
|
||||||
if (d.state !== 'ok') return { section: i, title: sec.title, filled: [], highlighted: [], missing: [`dialog ${sec.title}`], fallback: true };
|
if (d.state !== 'ok') return { section: i, title: sec.title, filled: [], highlighted: [], missing: [`dialog ${sec.title}`], fallback: true };
|
||||||
root = d.loc;
|
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 }> {
|
export async function navClick(loc: Locator, createApp = false): Promise<{ clicked: boolean; name: string }> {
|
||||||
const name = await accName(loc), role = await realRole(loc);
|
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 }; }
|
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 };
|
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 name = await accName(loc), role = await realRole(loc);
|
||||||
const where = await flowWhere(loc, dataTypes);
|
const where = await flowWhere(loc, dataTypes);
|
||||||
if (!flowAllowed(form, name, role, where)) { warn(TAG, `not clicking "${name}": only the owner presses it`); return false; }
|
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)})`);
|
say(`clicked "${name}" (${flowClass(form, name, role, where)})`);
|
||||||
onClick?.(name);
|
onClick?.(name);
|
||||||
return true;
|
return true;
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,24 @@ const IARC_NAMES: Record<string, Name[]> = {
|
||||||
unrestrictedInternet: [/unrestricted (internet|web)/i, /web browser/i],
|
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). */
|
/** 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 {
|
export function formDef(id: FormId, answers: Answer[]): FormDef {
|
||||||
const has = (aid: string) => answers.some(a => a.id === aid);
|
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]) },
|
{ 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:'));
|
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)
|
// 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
|
// 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)}`))) {
|
for (const a of types.filter(t => t.value === true && has(`linked:${t.id.slice(5)}`))) {
|
||||||
const n = a.id.slice(5);
|
const n = a.id.slice(5);
|
||||||
const dialog = [n, new RegExp(`^${esc(n)}\\b`, 'i')];
|
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}: 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]) }] });
|
sections.push({ title: `${n}: tracking`, dialog, type: n, page: true, fields: [{ id: `tracking:${n}`, kind: 'radio', options: yesNo([/^Yes,.*\btracking/i], [/^No,.*\btracking/i]) }] });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ fails without the fix.
|
||||||
|
|
||||||
| # | date | lesson | guard |
|
| # | date | lesson | guard |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
|
| 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). |
|
| 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). |
|
||||||
| 13 | 2026-10 | App Privacy purposes were over-mapped: Play "advertising" became both Third-Party Advertising and Developer's Advertising or Marketing (Device ID, Coarse Location, Product Interaction, Performance Data). | `applePurpose` maps one Play purpose to one Apple purpose (advertising: third-party only); Developer's Advertising only from `appPrivacy.types`; `check` compares `appPrivacy.types` with Info.plist / PrivacyInfo.xcprivacy (`tests/app-privacy.test.ts`). |
|
| 13 | 2026-10 | App Privacy purposes were over-mapped: Play "advertising" became both Third-Party Advertising and Developer's Advertising or Marketing (Device ID, Coarse Location, Product Interaction, Performance Data). | `applePurpose` maps one Play purpose to one Apple purpose (advertising: third-party only); Developer's Advertising only from `appPrivacy.types`; `check` compares `appPrivacy.types` with Info.plist / PrivacyInfo.xcprivacy (`tests/app-privacy.test.ts`). |
|
||||||
| 12 | 2026-10 | A new Play app has no API: Play Console's "Create app" is the only way, its page-level button carries the same words as the form's final submit, and Free cannot become Paid later. | `console fill play-app-record`: `createAppAllowed` lets the opener through only on the app list, never in a form or dialog (EN + TR); declarations never ticked; Free / Paid outlined as an owner check (`tests/console-fill.test.ts`). |
|
| 12 | 2026-10 | A new Play app has no API: Play Console's "Create app" is the only way, its page-level button carries the same words as the form's final submit, and Free cannot become Paid later. | `console fill play-app-record`: `createAppAllowed` lets the opener through only on the app list, never in a form or dialog (EN + TR); declarations never ticked; Free / Paid outlined as an owner check (`tests/console-fill.test.ts`). |
|
||||||
|
|
|
||||||
|
|
@ -135,6 +135,14 @@ Each entry: **symptom** → cause → what to do → what guards it now. The ful
|
||||||
progress ends the run with exit 1), `cli/console/debug.ts`, `tests/console-flow.test.ts` (unknown-page fixture and a
|
progress ends the run with exit 1), `cli/console/debug.ts`, `tests/console-flow.test.ts` (unknown-page fixture and a
|
||||||
scan for waits without a deadline).
|
scan for waits without a deadline).
|
||||||
|
|
||||||
|
## 15. App Privacy data types not found in the real dialog
|
||||||
|
- **Symptom:** after Get Started and Next, `console fill apple-app-privacy` finds no data type checkbox.
|
||||||
|
- **Cause:** the real "Data Collection" dialog names each checkbox by its label run together with its help text
|
||||||
|
("NameIncluding first or last name"), and some labels differ from Apple's help page ("Purchases", "Other Data").
|
||||||
|
- **Do:** nothing by hand: storebound matches the label as a prefix. If the console changes again, the watchdog
|
||||||
|
(pitfall 14) writes a debug dump: re-run with `--debug-dump` and attach it.
|
||||||
|
- **Guard:** `labelNames` and `TYPE_ALIASES` (`cli/lib/console-forms.ts`), `tests/console-flow.test.ts`.
|
||||||
|
|
||||||
## Adding a lesson
|
## 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
|
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).
|
that fails without the fix (see CONTRIBUTING.md).
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,8 @@ import { fillSession } from '../cli/console/fill';
|
||||||
import type { ConsoleBrowser } from '../cli/console/session';
|
import type { ConsoleBrowser } from '../cli/console/session';
|
||||||
import { APPLE_DATA_TYPE_IDS, type AppleDataTypeId, type AppleTypeAnswer } from '../cli/lib/app-privacy';
|
import { APPLE_DATA_TYPE_IDS, type AppleDataTypeId, type AppleTypeAnswer } from '../cli/lib/app-privacy';
|
||||||
import type { ShipConfig } from '../cli/lib/config';
|
import type { ShipConfig } from '../cli/lib/config';
|
||||||
import { type FlowWhere, flowAllowed, flowClass, FORM_BUTTONS } from '../cli/lib/console-forms';
|
import { type FlowWhere, flowAllowed, flowClass, FORM_BUTTONS, labelNames, 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';
|
import { AFTER_ALL_MS, AFTER_EACH_MS, launchTestBrowser, logLines, testScope } from './helpers/browser';
|
||||||
|
|
||||||
const FIX = join(import.meta.dir, 'fixtures', 'console');
|
const FIX = join(import.meta.dir, 'fixtures', 'console');
|
||||||
|
|
@ -66,6 +67,26 @@ const NAV_YOURSELF = /\b(Next|Continue|Get Started|Edit|Back|Set Up|Start|Manage
|
||||||
const navAsked = (lines: string[]) => lines.filter(l => NAV_YOURSELF.test(l));
|
const navAsked = (lines: string[]) => lines.filter(l => NAV_YOURSELF.test(l));
|
||||||
const PUBLISH = 'review the summary, then press Publish yourself';
|
const PUBLISH = 'review the summary, then press Publish yourself';
|
||||||
|
|
||||||
|
describe('App Privacy data types as the real Data Collection dialog names them (label + help text, no separator)', () => {
|
||||||
|
// the labels in the order of a real dialog (October 2026); each name is the label run together with its help text
|
||||||
|
const REAL = ['Name', 'Email Address', 'Phone Number', 'Physical Address', 'Other User Contact Info', 'Health', 'Fitness', 'Payment Info', 'Credit Info',
|
||||||
|
'Other Financial Info', 'Precise Location', 'Coarse Location', 'Sensitive Info', 'Contacts', 'Emails or Text Messages', 'Photos or Videos', 'Audio Data',
|
||||||
|
'Gameplay Content', 'Customer Support', 'Other User Content', 'Browsing History', 'Search History', 'User ID', 'Device ID', 'Purchases',
|
||||||
|
'Product Interaction', 'Advertising Data', 'Other Usage Data', 'Crash Data', 'Performance Data', 'Other Diagnostic Data', 'Environment Scanning', 'Hands',
|
||||||
|
'Head', 'Other Data'];
|
||||||
|
const names = REAL.map(l => `${l}Such as a placeholder help text`);
|
||||||
|
test('every Apple data type matches exactly one checkbox; "Name" never matches "Phone Number...", "Head" never "Health..."', () => {
|
||||||
|
for (const [, label] of Object.values(APPLE_PRIVACY_TYPES)) {
|
||||||
|
const cands = labelNames(label, TYPE_ALIASES[label]);
|
||||||
|
// what find() does: the first candidate name that matches anything decides, and it must match one checkbox
|
||||||
|
const hits = cands.map(c => names.filter(n => (typeof c === 'string' ? n === c : c.test(n)))).find(h => h.length) ?? [];
|
||||||
|
expect({ label, hits: hits.length }).toEqual({ label, hits: 1 });
|
||||||
|
expect(hits[0]!.startsWith(TYPE_ALIASES[label]?.[0] ?? label)).toBe(true);
|
||||||
|
}
|
||||||
|
expect(labelNames('Contact').some(c => typeof c !== 'string' && c.test('ContactsSuch as a list'))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('button classes (FORM_BUTTONS, flowClass, flowAllowed)', () => {
|
describe('button classes (FORM_BUTTONS, flowClass, flowAllowed)', () => {
|
||||||
const at = (w: Partial<FlowWhere> = {}): FlowWhere => ({ privacyPage: true, inDialog: false, inDataTypes: false, dataTypes: ['Device ID'], ...w });
|
const at = (w: Partial<FlowWhere> = {}): FlowWhere => ({ privacyPage: true, inDialog: false, inDataTypes: false, dataTypes: ['Device ID'], ...w });
|
||||||
test('App Privacy: openers, Next and the in-dialog Save are clicked; Publish never', () => {
|
test('App Privacy: openers, Next and the in-dialog Save are clicked; Publish never', () => {
|
||||||
|
|
@ -299,12 +320,13 @@ describe('every wait of the console engine is bounded (code scan)', () => {
|
||||||
}
|
}
|
||||||
expect(bad).toEqual([]);
|
expect(bad).toEqual([]);
|
||||||
});
|
});
|
||||||
test('no Playwright wait without a timeout, and the window\'s close is awaited only after a bounded wait or a close()', () => {
|
test('no Playwright wait or click without a timeout, and the window\'s close is awaited only after a bounded wait or a close()', () => {
|
||||||
const bad: string[] = [];
|
const bad: string[] = [];
|
||||||
for (const f of files) {
|
for (const f of files) {
|
||||||
const lines = readFileSync(join(SRC, f), 'utf8').split('\n');
|
const lines = readFileSync(join(SRC, f), 'utf8').split('\n');
|
||||||
lines.forEach((l, i) => {
|
lines.forEach((l, i) => {
|
||||||
if (/\.waitFor(Selector|URL|Event|LoadState|Function)?\(/.test(l) && !/timeout/.test(l)) bad.push(`${f}:${i + 1} ${l.trim()}`);
|
if (/\.waitFor(Selector|URL|Event|LoadState|Function)?\(/.test(l) && !/timeout/.test(l)) bad.push(`${f}:${i + 1} ${l.trim()}`);
|
||||||
|
if (/\.(click|setChecked|check|uncheck)\(/.test(l) && !/timeout|ACTION_MS/.test(l) && !/^\s*(\/\/|\*)/.test(l)) bad.push(`${f}:${i + 1} ${l.trim()}`);
|
||||||
if (/await [\w.]+\.closed\s*;/.test(l) && !lines.slice(Math.max(0, i - 3), i).some(p => /close\(\)/.test(p))) bad.push(`${f}:${i + 1} ${l.trim()}`);
|
if (/await [\w.]+\.closed\s*;/.test(l) && !lines.slice(Math.max(0, i - 3), i).some(p => /close\(\)/.test(p))) bad.push(`${f}:${i + 1} ${l.trim()}`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,40 +1,77 @@
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<!-- MODELED, not recorded: App Store Connect > App Privacy as a whole questionnaire, written from Apple's help page
|
<!-- App Store Connect > App Privacy as a whole questionnaire. The "Data Collection" dialog mirrors the STRUCTURE of a
|
||||||
"Manage app privacy" (Get Started; "Yes, we collect data" > Next > data types > Save; each data type in the Data
|
real console page (sanitized debug dump, October 2026; no data copied): one <dialog role="dialog"> titled by an
|
||||||
Types section opens its questions: purposes > Next > linked to the user > Next > tracking > Save; Edit next to Data
|
<h2><span>Data Collection</span></h2>; step 1 "Yes, we collect data" / "No, ..." radios (name isAnyDataCollected)
|
||||||
Types changes the list; Publish at the top right publishes, with a confirmation). ?existing=1: answers exist (no
|
with Next; step 2 group headings (Contact Info, Identifiers ...) and one checkbox per data type whose <label> holds
|
||||||
Get Started; Edit next to Data Types). Decoys: Publish, See Details, and the Edit next to the Privacy Policy URL.
|
two <p>: the type label and its help text, so the accessible name is both run together ("NameDescription ...");
|
||||||
window.__saves counts the draft saves. Placeholder data only. -->
|
the input's name is Apple's enum (NAME, PURCHASE_HISTORY ...); labels as the console shows them ("Purchases",
|
||||||
<html lang="en"><head><meta charset="utf-8"><title>App Privacy - App Store Connect</title><script src="/_common.js"></script></head>
|
"Other Data"); Back / Cancel / Save, Save disabled until a type is ticked. The rest is MODELED from Apple's help
|
||||||
|
page "Manage app privacy" (each data type in the Data Types section opens its questions: purposes > Next > linked
|
||||||
|
to the user > Next > tracking > Save; Edit next to Data Types changes the list; Publish publishes, with a
|
||||||
|
confirmation); the purpose checkboxes also carry their help text in the name. ?existing=1: answers exist (no Get
|
||||||
|
Started; Edit next to Data Types). Decoys: Publish, See Details, the Edit next to the Privacy Policy URL.
|
||||||
|
window.__saves counts the draft saves. Help texts are placeholders. -->
|
||||||
|
<html lang="en"><head><meta charset="utf-8"><title>App Store Connect</title><script src="/_common.js"></script></head>
|
||||||
<body>
|
<body>
|
||||||
<h1>App Privacy</h1>
|
<h1>App Privacy</h1>
|
||||||
<button type="button" id="publish">Publish</button>
|
<button type="button" id="publish">Publish</button>
|
||||||
<section><h2>Privacy Policy</h2><p>https://example.com/privacy</p><button type="button">Edit</button></section>
|
<section><h2>Privacy Policy</h2><p>https://example.com/privacy</p><button type="button">Edit</button></section>
|
||||||
<p id="intro"><button type="button" id="start">Get Started</button></p>
|
<p id="intro"><button type="button" id="start"><span>Get Started</span></button></p>
|
||||||
<section id="dt" hidden><h2>Data Types</h2><button type="button" id="edit">Edit</button><ul id="list"></ul></section>
|
<section id="dt" hidden><h2>Data Types</h2><button type="button" id="edit">Edit</button><ul id="list"></ul></section>
|
||||||
<section><h2>Product Page Preview</h2><button type="button">See Details</button></section>
|
<section><h2>Product Page Preview</h2><button type="button">See Details</button></section>
|
||||||
|
|
||||||
<div role="dialog" aria-label="Data Collection" id="collect" hidden>
|
<dialog role="dialog" aria-modal="true" aria-labelledby="modal-header" id="collect">
|
||||||
<div id="c1">
|
<div role="document">
|
||||||
<label><input type="radio" name="c" value="yes"> Yes, we collect data from this app</label>
|
<h2 id="modal-header"><span>Data Collection</span></h2>
|
||||||
<label><input type="radio" name="c" value="no"> No, we do not collect data from this app</label>
|
<div id="c1">
|
||||||
<button type="button">Cancel</button> <button type="button" id="c1next">Next</button> <button type="button" id="c1save" hidden>Save</button>
|
<div><input type="radio" name="isAnyDataCollected" id="collect_true" value="yes"><div><label for="collect_true"><span><strong>Yes</strong>, we collect data from this app</span></label></div></div>
|
||||||
|
<div><input type="radio" name="isAnyDataCollected" id="collect_false" value="no"><div><label for="collect_false"><span><strong>No</strong>, we do not collect data from this app</span></label></div></div>
|
||||||
|
<div><button type="button">Cancel</button> <button type="button" id="c1next">Next</button> <button type="button" id="c1save" hidden>Save</button></div>
|
||||||
|
</div>
|
||||||
|
<div id="c2" hidden>
|
||||||
|
<p><span><p>Next, select all of the data types that you or your third-party partners collect from this app.</p></span></p>
|
||||||
|
<h3><span>Optional Disclosure</span></h3>
|
||||||
|
<div id="groups"></div>
|
||||||
|
<div><button type="button" id="c2back">Back</button> <button type="button">Cancel</button> <button type="button" id="c2save" disabled>Save</button></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="c2" hidden><div id="types"></div><button type="button" id="c2back">Back</button> <button type="button" id="c2save">Save</button></div>
|
</dialog>
|
||||||
</div>
|
|
||||||
<div id="per"></div>
|
<div id="per"></div>
|
||||||
<script>
|
<script>
|
||||||
const TYPES = ['Name', 'Email Address', 'Phone Number', 'Physical Address', 'Other User Contact Info', 'Health', 'Fitness', 'Payment Info', 'Credit Info',
|
// [group, [enum, label as the console shows it]]
|
||||||
'Other Financial Info', 'Precise Location', 'Coarse Location', 'Sensitive Info', 'Contacts', 'Emails or Text Messages', 'Photos or Videos', 'Audio Data',
|
const GROUPS = [
|
||||||
'Gameplay Content', 'Customer Support', 'Other User Content', 'Browsing History', 'Search History', 'User ID', 'Device ID', 'Purchase History',
|
['Contact Info', [['NAME', 'Name'], ['EMAIL_ADDRESS', 'Email Address'], ['PHONE_NUMBER', 'Phone Number'], ['PHYSICAL_ADDRESS', 'Physical Address'], ['OTHER_CONTACT_INFO', 'Other User Contact Info']]],
|
||||||
'Product Interaction', 'Advertising Data', 'Other Usage Data', 'Crash Data', 'Performance Data', 'Other Diagnostic Data', 'Environment Scanning', 'Hands',
|
['Health & Fitness', [['HEALTH', 'Health'], ['FITNESS', 'Fitness']]],
|
||||||
'Head', 'Other Data Types'];
|
['Financial Info', [['PAYMENT_INFORMATION', 'Payment Info'], ['CREDIT_AND_FRAUD', 'Credit Info'], ['OTHER_FINANCIAL_INFO', 'Other Financial Info']]],
|
||||||
|
['Location', [['PRECISE_LOCATION', 'Precise Location'], ['COARSE_LOCATION', 'Coarse Location']]],
|
||||||
|
['Sensitive Info', [['SENSITIVE_INFO', 'Sensitive Info']]],
|
||||||
|
['Contacts', [['CONTACTS', 'Contacts']]],
|
||||||
|
['User Content', [['EMAILS_OR_TEXT_MESSAGES', 'Emails or Text Messages'], ['PHOTOS_OR_VIDEOS', 'Photos or Videos'], ['AUDIO', 'Audio Data'], ['GAMEPLAY_CONTENT', 'Gameplay Content'], ['CUSTOMER_SUPPORT', 'Customer Support'], ['OTHER_USER_CONTENT', 'Other User Content']]],
|
||||||
|
['Browsing History', [['BROWSING_HISTORY', 'Browsing History']]],
|
||||||
|
['Search History', [['SEARCH_HISTORY', 'Search History']]],
|
||||||
|
['Identifiers', [['USER_ID', 'User ID'], ['DEVICE_ID', 'Device ID']]],
|
||||||
|
['Purchases', [['PURCHASE_HISTORY', 'Purchases']]],
|
||||||
|
['Usage Data', [['PRODUCT_INTERACTION', 'Product Interaction'], ['ADVERTISING_DATA', 'Advertising Data'], ['OTHER_USAGE_DATA', 'Other Usage Data']]],
|
||||||
|
['Diagnostics', [['CRASH_DATA', 'Crash Data'], ['PERFORMANCE_DATA', 'Performance Data'], ['OTHER_DIAGNOSTIC_DATA', 'Other Diagnostic Data']]],
|
||||||
|
['Surroundings', [['ENVIRONMENTAL_SCANNING', 'Environment Scanning']]],
|
||||||
|
['Body', [['HANDS', 'Hands'], ['HEAD_MOVEMENT', 'Head']]],
|
||||||
|
['Other Data', [['OTHER_DATA', 'Other Data']]],
|
||||||
|
];
|
||||||
const PURPOSES = ['Third-Party Advertising', "Developer's Advertising or Marketing", 'Analytics', 'Product Personalization', 'App Functionality', 'Other Purposes'];
|
const PURPOSES = ['Third-Party Advertising', "Developer's Advertising or Marketing", 'Analytics', 'Product Personalization', 'App Functionality', 'Other Purposes'];
|
||||||
const $ = id => document.getElementById(id);
|
const $ = id => document.getElementById(id);
|
||||||
window.__saves = 0;
|
window.__saves = 0;
|
||||||
for (const t of TYPES) { const l = document.createElement('label'); l.innerHTML = '<input type="checkbox"> '; l.append(t); $('types').append(l); }
|
for (const [g, types] of GROUPS) {
|
||||||
|
const h = document.createElement('h3'); h.innerHTML = `<span>${g}</span>`; $('groups').append(h);
|
||||||
|
for (const [id, label] of types) {
|
||||||
|
const d = document.createElement('div');
|
||||||
|
d.innerHTML = `<div><div><input type="checkbox" name="${id}" id="SELECT_CATEGORIES_checkbox_${id}"><div><label for="SELECT_CATEGORIES_checkbox_${id}"><p>${label}</p><p>Description of what ${label} covers</p></label></div></div></div>`;
|
||||||
|
$('groups').append(d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$('groups').addEventListener('change', () => { $('c2save').disabled = !$('groups').querySelector('input:checked'); });
|
||||||
const existing = new URLSearchParams(location.search).has('existing');
|
const existing = new URLSearchParams(location.search).has('existing');
|
||||||
const show = (el, on) => { el.hidden = !on; };
|
const show = (el, on) => { el.hidden = !on; };
|
||||||
|
const open = (on) => { if (on) $('collect').setAttribute('open', ''); else $('collect').removeAttribute('open'); };
|
||||||
function listTypes(names) {
|
function listTypes(names) {
|
||||||
$('list').innerHTML = '';
|
$('list').innerHTML = '';
|
||||||
for (const [i, t] of names.entries()) {
|
for (const [i, t] of names.entries()) {
|
||||||
|
|
@ -46,10 +83,10 @@ function listTypes(names) {
|
||||||
}
|
}
|
||||||
show($('dt'), true); show($('intro'), false);
|
show($('dt'), true); show($('intro'), false);
|
||||||
}
|
}
|
||||||
// the per-type dialog: three pages (purposes > Next > linked > Next > tracking > Save)
|
// the per-type dialog: three pages (purposes > Next > linked > Next > tracking > Save); labels with their help text
|
||||||
function openType(t) {
|
function openType(t) {
|
||||||
const d = document.createElement('div'); d.setAttribute('role', 'dialog'); d.setAttribute('aria-label', t);
|
const d = document.createElement('div'); d.setAttribute('role', 'dialog'); d.setAttribute('aria-label', t);
|
||||||
d.innerHTML = `<div data-p="1">${PURPOSES.map(p => `<label><input type="checkbox"> ${p}</label>`).join('')}<button type="button" data-go="2">Next</button></div>`
|
d.innerHTML = `<div data-p="1">${PURPOSES.map((p, i) => `<div><input type="checkbox" id="p${i}"><label for="p${i}"><p>${p}</p><p>Description of ${p}</p></label></div>`).join('')}<button type="button" data-go="2">Next</button></div>`
|
||||||
+ `<div data-p="2" hidden><label><input type="radio" name="l"> Yes, ${t} collected from this app is linked to the user's identity</label>`
|
+ `<div data-p="2" hidden><label><input type="radio" name="l"> Yes, ${t} collected from this app is linked to the user's identity</label>`
|
||||||
+ `<label><input type="radio" name="l"> No, ${t} collected from this app is not linked to the user's identity</label><button type="button" data-go="1b">Back</button> <button type="button" data-go="3">Next</button></div>`
|
+ `<label><input type="radio" name="l"> No, ${t} collected from this app is not linked to the user's identity</label><button type="button" data-go="1b">Back</button> <button type="button" data-go="3">Next</button></div>`
|
||||||
+ `<div data-p="3" hidden><label><input type="radio" name="t"> Yes, we use ${t} for tracking purposes</label>`
|
+ `<div data-p="3" hidden><label><input type="radio" name="t"> Yes, we use ${t} for tracking purposes</label>`
|
||||||
|
|
@ -62,16 +99,16 @@ function openType(t) {
|
||||||
$('per').append(d);
|
$('per').append(d);
|
||||||
}
|
}
|
||||||
if (existing) listTypes(['Device ID', 'Crash Data']);
|
if (existing) listTypes(['Device ID', 'Crash Data']);
|
||||||
$('start').addEventListener('click', () => { show($('collect'), true); show($('c1'), true); show($('c2'), false); });
|
$('start').addEventListener('click', () => { open(true); show($('c1'), true); show($('c2'), false); });
|
||||||
$('edit').addEventListener('click', () => { show($('collect'), true); show($('c1'), false); show($('c2'), true); });
|
$('edit').addEventListener('click', () => { open(true); show($('c1'), false); show($('c2'), true); });
|
||||||
// "No" saves at once; "Yes" goes on with Next
|
// "No" saves at once; "Yes" goes on with Next
|
||||||
document.querySelectorAll('input[name=c]').forEach(r => r.addEventListener('change', () => { const no = r.value === 'no' && r.checked; show($('c1next'), !no); show($('c1save'), no); }));
|
document.querySelectorAll('input[name=isAnyDataCollected]').forEach(r => r.addEventListener('change', () => { const no = r.value === 'no' && r.checked; show($('c1next'), !no); show($('c1save'), no); }));
|
||||||
$('c1next').addEventListener('click', () => { show($('c1'), false); show($('c2'), true); });
|
$('c1next').addEventListener('click', () => { show($('c1'), false); show($('c2'), true); });
|
||||||
$('c1save').addEventListener('click', () => { window.__saves++; show($('collect'), false); });
|
$('c1save').addEventListener('click', () => { window.__saves++; open(false); });
|
||||||
$('c2save').addEventListener('click', () => {
|
$('c2save').addEventListener('click', () => {
|
||||||
window.__saves++;
|
window.__saves++;
|
||||||
const picked = [...$('types').querySelectorAll('label')].filter(l => l.querySelector('input').checked).map(l => l.textContent.trim());
|
const picked = [...$('groups').querySelectorAll('input:checked')].map(i => i.parentElement.querySelector('label p').textContent.trim());
|
||||||
show($('collect'), false); listTypes(picked);
|
open(false); listTypes(picked);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
</body></html>
|
</body></html>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue