From 18fde37b9b02cad3b165f765a423e4662318731d Mon Sep 17 00:00:00 2001 From: CRT Date: Mon, 12 Oct 2026 07:52:11 +0800 Subject: [PATCH] =?UTF-8?q?console=20fill:=20labels=20match=20straight=20a?= =?UTF-8?q?nd=20curly=20quotes=20(Developer=E2=80=99s=20Advertising,=20the?= =?UTF-8?q?=20user=E2=80=99s=20identity);=20an=20App=20Privacy=20dialog=20?= =?UTF-8?q?step=20with=20no=20inputs=20(the=20definitions=20page)=20is=20p?= =?UTF-8?q?assed=20with=20Next?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cli/console/fill.ts | 23 +++++++++++++++++-- cli/lib/console-forms.ts | 6 ++++- tests/console-flow.test.ts | 4 ++-- .../console/apple-app-privacy-setup.html | 9 ++++---- 4 files changed, 33 insertions(+), 9 deletions(-) diff --git a/cli/console/fill.ts b/cli/console/fill.ts index df4e2fb..22bd3fa 100644 --- a/cli/console/fill.ts +++ b/cli/console/fill.ts @@ -33,7 +33,7 @@ import type { Ctx } from '../lib/ctx'; import { type Answer, type ConsoleResult, consoleResult, DISCLAIMER, FORM_IDS, type FormId, fillValue, formMeta, isForm, valueText } from '../lib/console-answers'; import type { ShipConfig } from '../lib/config'; import { APPLE_PRIVACY_TYPES } from '../lib/app-privacy'; -import { ALL_APPS, APP_PRIVACY_HEADING, consoleReached, CREATE_FORM_FIELD, createAppAllowed, DATA_TYPES_HEADING, type FieldDef, FLOW_NAMES, flowAllowed, flowClass, type FlowWhere, type FormDef, formDef, FORM_OPENERS, forbiddenControl, forbiddenTick, hasToken, isAppleSignInFrame, isSignInText, isSignInUrl, type Name, navAllowed, type NavBases, type NavStep, navPlan, setUpAllowed, setUpTexts, typeLabels } from '../lib/console-forms'; +import { 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, quoteTolerant, 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'; @@ -51,7 +51,7 @@ const CHECK = 'storebound: check this'; /** Locators with only the visible matches (hidden earlier pages stay in many consoles' DOM). */ export const vis = (l: Locator): Locator => { try { return l.filter({ visible: true }); } catch { return l; } }; -const nameOpts = (n: Name) => (typeof n === 'string' ? { name: n, exact: true } : { name: n }); +const nameOpts = (n: Name) => (typeof n === 'string' ? (/['"\u2018\u2019\u201c\u201d]/.test(n) ? { name: quoteTolerant(n) } : { name: n, exact: true }) : { name: n }); type Found = { state: 'ok'; loc: Locator } | { state: 'none' | 'ambiguous' }; @@ -1011,6 +1011,15 @@ export async function fillSession(b: ConsoleBrowser, o: FillSessionOpts): Promis if (privacy && !d) return null; return controlWhere(d ?? p, FLOW_NAMES.next, w => !privacy || w.inDialog, dataTypes); }; + /** An open App Privacy dialog whose current step has no input at all (text only, plus Back / Cancel / Next). */ + let infoRounds = 0; + const infoOnlyStep = async (p: Page): Promise => { + // the last filled field sits on a step that is hidden now, so the dialog is found on the page itself + const d = await anchorDialog() ?? await (async () => { const v = vis(p.getByRole('dialog')); return (await v.count().catch(() => 0)) === 1 ? v : null; })(); + if (!d) return null; + const inputs = await d.locator('input:not([type=hidden]), select, textarea, [role=checkbox], [role=radio], [role=switch], [role=textbox], [role=combobox]').filter({ visible: true }).count().catch(() => 1); + return inputs === 0 ? d : null; + }; /** The section filled last, and the content rating pages whose Save draft was clicked. */ let filledAt: number | null = null; const drafted = new Set(); @@ -1226,6 +1235,16 @@ export async function fillSession(b: ConsoleBrowser, o: FillSessionOpts): Promis if (moved === 'draft') pendingAdvance = true; // Next follows the saved draft if (moved) { await Bun.sleep(tick); continue; } } + // an App Privacy dialog step with nothing to answer (the "Let's define two important terms" page between linked and + // tracking): no inputs, only Back / Cancel / Next -> Next is navigation + // seen twice in a row, so a step still rendering its inputs is never skipped + const info = privacy ? await infoOnlyStep(p) : null; + if (!info) infoRounds = 0; + if (info && (infoRounds += 1) >= 2) { + infoRounds = 0; + const next = await controlWhere(info, FLOW_NAMES.next, () => true, dataTypes); + if (next && await flowClick(next, form, dataTypes, say, clicked)) { say('an information-only step of the dialog: storebound pressed Next'); progress('the next step of the dialog'); await Bun.sleep(tick); continue; } + } if (privacy) await privacyStep(p).catch(() => { /* navigating */ }); await Bun.sleep(tick); } diff --git a/cli/lib/console-forms.ts b/cli/lib/console-forms.ts index fd0bfcf..742ac27 100644 --- a/cli/lib/console-forms.ts +++ b/cli/lib/console-forms.ts @@ -38,7 +38,11 @@ export interface SectionDef { export interface FormDef { id: FormId; sections: SectionDef[] } -const esc = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +// Console labels use typographic quotes ("Developer’s Advertising", "the user’s identity"): every pattern built from a +// label accepts straight and curly quotes alike. +const esc = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/['\u2018\u2019]/g, "['\u2018\u2019]").replace(/["\u201c\u201d]/g, '["\u201c\u201d]'); +/** A label as an exact-match pattern that tolerates straight vs curly quotes (for names that contain a quote). */ +export const quoteTolerant = (s: string): RegExp => new RegExp(`^${esc(s)}$`); const yesNo = (yes: Name[] = ['Yes'], no: Name[] = ['No']): Record => ({ true: yes, false: no }); /** IARC topics -> candidate radiogroup names (modeled; see the header). */ diff --git a/tests/console-flow.test.ts b/tests/console-flow.test.ts index 719d8d3..080b895 100644 --- a/tests/console-flow.test.ts +++ b/tests/console-flow.test.ts @@ -238,7 +238,7 @@ describe.skipIf(!browser)('App Privacy: each data type set up from the real page 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 === 'Next')).toHaveLength(18); 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)'); @@ -297,7 +297,7 @@ describe.skipIf(!browser)('App Privacy: each data type set up from the real page 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.filter(x => x === 'Next')).toHaveLength(2 * 3 + 1); // two types done (purposes, linked, definitions), Device ID's first step only expect(c).not.toContain('Publish'); expect(c).not.toContain('?'); await b.context.close(); diff --git a/tests/fixtures/console/apple-app-privacy-setup.html b/tests/fixtures/console/apple-app-privacy-setup.html index f56e16b..774e221 100644 --- a/tests/fixtures/console/apple-app-privacy-setup.html +++ b/tests/fixtures/console/apple-app-privacy-setup.html @@ -41,7 +41,7 @@ const ROWS = [ ['Usage Data', 'Product Interaction', 'Product Interaction'], ['Diagnostics', 'Crash Data', 'Crash 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 q = new URLSearchParams(location.search); const unknown = q.get('unknown'); const preset = Object.fromEntries(q.getAll('preset').map(v => { const [t, ps] = v.split(':'); return [t, ps ? ps.split('|') : []]; })); @@ -68,7 +68,7 @@ function setUpDone(type, purposes) { a.innerHTML = `

${purposes.join(', ')}

`; a.querySelector('button').addEventListener('click', () => openType(type)); } -// the per-type dialog: purposes > Next > linked > Next > tracking > Save +// the per-type dialog: purposes > Next > linked > Next > definitions (text only, real console) > Next > tracking > Save function openType(type) { const low = type.toLowerCase(); const d = document.createElement('dialog'); @@ -82,9 +82,10 @@ function openType(type) { + PURPOSES.map((p, i) => `
`).join('') + `
` + `` - + ``; + + `
`; d.setAttribute('open', ''); d.addEventListener('click', e => { const go = e.target.closest('[data-go]')?.getAttribute('data-go');