Play console steps without an API as agent plans: play-store-settings (App category; play listing cannot set it), play-app-content (every card in order, one owner batch) and its cards (privacy policy, ads, sign in details, advertising ID, government, financial, health), play-production-countries and play-production-release; IARC plan with the Terms of Use agreement, follow-ups (console.contentRating.details, products[].randomRewards, social) and the Summary's final Save; config play.category / countries / managedPublishing, console.appContent; check, validate, play status and play release reminders; inPageLoop helper and material checkbox clicks; App Privacy published signal; first IAPs via apple iap-submit --with-version. Pitfalls 27, 28

This commit is contained in:
CRTkafa 2026-10-12 10:16:34 +08:00
parent dbd9f2973c
commit b7bfe89151
37 changed files with 1624 additions and 121 deletions

View file

@ -122,6 +122,7 @@ async function resolve(root: Locator, f: FieldDef): Promise<Resolved> {
// App Store Connect labels "User Access" with a plain heading, not an ARIA group: then the options are looked up
// in the whole dialog (their own names, e.g. "Full Access", are unique there)
if (g.state === 'ok') { scope = g.loc; seen = true; }
else if (f.scoped) return { field: f, ok: false, seen: false };
}
const options: Record<string, Locator> = {};
let ok = true;
@ -181,7 +182,11 @@ export async function highlight(loc: Locator, text = CHECK): Promise<void> {
async function setField(r: Resolved, v: string | boolean): Promise<boolean> {
const f = r.field;
if (f.kind === 'text') { await r.loc.fill(String(v)); return true; }
if (f.kind === 'checkbox') return safeClick(r.loc, await accName(r.loc), v ? 'check' : 'uncheck');
if (f.kind === 'checkbox') {
const name = await accName(r.loc);
if (await inMaterial(r.loc, 'material-checkbox')) return forbiddenTick(name) ? safeClick(r.loc, name, 'check') : materialToggle(r.loc, v === true);
try { return await safeClick(r.loc, name, v ? 'check' : 'uncheck'); } catch (e) { if (!await inMaterial(r.loc, 'material-checkbox')) throw e; return materialToggle(r.loc, v === true); }
}
if (f.kind === 'radio') {
const o = r.options?.[String(v)];
if (!o) return false;
@ -209,8 +214,24 @@ async function setField(r: Resolved, v: string | boolean): Promise<boolean> {
return true;
}
/** The radio sits in Play Console's <material-radio> (or is a [role=radio] itself). */
const inMaterialRadio = (o: Locator): Promise<boolean> => o.evaluate((el: any) => !!el.closest('material-radio')).catch(() => false);
/** The control sits in one of Play Console's material elements (<material-radio>, <material-checkbox>). */
const inMaterial = (o: Locator, tag: string): Promise<boolean> => o.evaluate((el: any, t: string) => !!el.closest(t), tag).catch(() => false);
const inMaterialRadio = (o: Locator): Promise<boolean> => inMaterial(o, 'material-radio');
/**
* Play Console's <material-checkbox> (the same model problem as material-radio, seen on App content and the IARC
* questionnaire, 2026-10-12): a DOM click on the inner [role=checkbox] when its aria-checked differs from `want`.
* The name gate (agreements) runs before. true when the box ends up as wanted.
*/
export async function materialToggle(o: Locator, want: boolean): Promise<boolean> {
return o.evaluate((el: any, w: boolean) => {
const host = el.closest('material-checkbox');
const inner = el.getAttribute('role') === 'checkbox' ? el : host?.querySelector('[role="checkbox"]') ?? el;
const state = () => (inner.hasAttribute('aria-checked') ? inner.getAttribute('aria-checked') === 'true' : el.checked === true);
if (state() !== w) inner.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: window }));
return state() === w;
}, want).catch(() => false);
}
/**
* Play Console's <material-radio> (verified 2026-10-12 on Create app): setting the native input, or a click that does
@ -1038,7 +1059,9 @@ export async function fillSession(b: ConsoleBrowser, o: FillSessionOpts): Promis
progress(nav?.ok ? 'the form' : 'the form (opened by you)');
if (o.debugEveryStep && !b.isClosed()) await dump(active() ?? b.page, 'start');
const last = def.sections.length - 1;
// the IARC follow-ups (optional sections) appear only after a Yes: the form ends with its last required section
const required = def.sections.flatMap((x, i) => (x.optional ? [] : [i]));
const last = required.at(-1) ?? 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 types = [...new Set(def.sections.map(x => x.type).filter((t): t is string => !!t))];
@ -1056,7 +1079,7 @@ export async function fillSession(b: ConsoleBrowser, o: FillSessionOpts): Promis
/** App Privacy openers clicked (each at most twice: a click that opens nothing is a UI change). */
const opened = new Map<string, number>();
let askedCollects = false;
const finished = () => !pendingAdvance && !waitFor && (done.size === def.sections.length || (!privacy && done.has(last)));
const finished = () => !pendingAdvance && !waitFor && (required.every(i => done.has(i)) || (!privacy && done.has(last)));
/** The dialog of the last filled section when it is still visible, else null. */
const anchorDialog = async (): Promise<Locator | null> => {