console fill: labels match straight and curly quotes (Developer’s Advertising, the user’s identity); an App Privacy dialog step with no inputs (the definitions page) is passed with Next

This commit is contained in:
CRTkafa 2026-10-12 07:52:11 +08:00
parent 0035931734
commit 18fde37b9b
4 changed files with 33 additions and 9 deletions

View file

@ -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<Locator | null> => {
// 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<number>();
@ -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);
}

View file

@ -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<string, Name[]> => ({ true: yes, false: no });
/** IARC topics -> candidate radiogroup names (modeled; see the header). */

View file

@ -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();

View file

@ -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 = `<p>${purposes.join(', ')}</p><button type="button" role="button">Edit</button>`;
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) => `<div><input type="checkbox" id="purpose_${i}"><div><label for="purpose_${i}"><p>${p}</p><p>Description of ${p}</p></label></div></div>`).join('')
+ `<div><button type="button">Cancel</button><button type="button" data-go="2">Next</button></div></div>`
+ `<div data-p="2" hidden>${step2}<div><button type="button" data-go="1">Back</button><button type="button">Cancel</button><button type="button" data-go="3">Next</button></div></div>`
+ `<div data-p="3" hidden>`
+ `<div data-p="3" hidden><p>Let’s define two important terms before continuing.</p><h5>Tracking</h5><p>Linking data collected from your app with third-party data.</p><h5>Third-Party Data</h5><p>Data about a user collected from other companies.</p><div><button type="button" data-go="2">Back</button><button type="button">Cancel</button><button type="button" data-go="4">Next</button></div></div>`
+ `<div data-p="4" hidden>`
+ radio('tracking', 'yes', `<strong>Yes</strong>, we use ${low} for tracking purposes`) + radio('tracking', 'no', `<strong>No</strong>, we do not use ${low} for tracking purposes`)
+ `<div><button type="button" data-go="2">Back</button><button type="button">Cancel</button><button type="button" data-save>Save</button></div></div></div>`;
+ `<div><button type="button" data-go="3">Back</button><button type="button">Cancel</button><button type="button" data-save>Save</button></div></div></div>`;
d.setAttribute('open', '');
d.addEventListener('click', e => {
const go = e.target.closest('[data-go]')?.getAttribute('data-go');