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> => {

View file

@ -7,7 +7,8 @@
// ~/.storebound/console-accounts.json) and the workspace's account ids; --lookup also asks the App Store Connect API
// for the app id by bundle id (a GET; needs the API key). Pure logic in cli/lib/console-plan.ts. MCP: console_plan.
import type { Ctx } from '../lib/ctx';
import { FORM_IDS, formMeta, isForm } from '../lib/console-answers';
import { formMeta, isForm, isPlanForm, PLAN_FORM_IDS } from '../lib/console-answers';
import { reviewDemoAccount } from '../lib/credentials';
import { consolePlan, type PlanIds, planText } from '../lib/console-plan';
import { printJson } from '../lib/log';
@ -16,18 +17,21 @@ export async function run(ctx: Ctx): Promise<void> {
const args = ctx.args, json = args.has('json');
const form = args._[0];
if (!form) {
const forms = FORM_IDS.map(id => { const m = formMeta(id, config); return { id, store: m.store, title: m.title, fillable: m.fillable }; });
const forms = PLAN_FORM_IDS.map(id => {
if (isForm(id)) { const m = formMeta(id, config); return { id, store: m.store, title: m.title, fillable: m.fillable, scripted: m.fillable }; }
return { id, store: 'play' as const, title: consolePlan(id, config).title, fillable: true, scripted: false };
});
if (json) return printJson({ forms });
console.log('storebound console plan <form> [--json] [--lookup] (then drive the page yourself; `storebound console verify <form>` checks it)\n');
for (const f of forms) console.log(` ${f.id.padEnd(22)} ${f.store.padEnd(6)} ${f.title}${f.fillable ? '' : ' (guide only)'}`);
for (const f of forms) console.log(` ${f.id.padEnd(26)} ${f.store.padEnd(6)} ${f.title}${f.fillable ? (f.scripted ? '' : ' (agent only, no console fill)') : ' (guide only)'}`);
return;
}
if (!isForm(form)) throw new Error(`console plan: unknown form "${form}" (one of ${FORM_IDS.join(', ')})`);
if (!isPlanForm(form)) throw new Error(`console plan: unknown form "${form}" (one of ${PLAN_FORM_IDS.join(', ')})`);
const { withAccountIds, readAccounts } = await import('./accounts');
const cfg = withAccountIds(config);
const pkg = cfg.play?.packageName;
const cached = pkg ? readAccounts().play[pkg] : undefined;
const ids: PlanIds = { playDeveloperId: cfg.play?.developerId ?? cached?.developerId ?? null, playAppId: cfg.play?.appId ?? cached?.appId ?? null };
const ids: PlanIds = { playDeveloperId: cfg.play?.developerId ?? cached?.developerId ?? null, playAppId: cfg.play?.appId ?? cached?.appId ?? null, demoAccount: !!reviewDemoAccount(root) };
if (form.startsWith('apple-') && !cfg.apple?.appId && cfg.apple?.bundleId) {
const { appleAppId } = await import('./app-id');
// without --lookup only the cache is read: the lookup refuses, so no request is made

View file

@ -2,12 +2,14 @@
// storebound console verify <form> [--from-text <file|->] [--json]
// apple-app-privacy: the page text of the App Privacy page (the Data Types section's per-type summaries; the App Store
// Connect API has no App Privacy endpoint). play-app-record: the JSON of the plan's capture snippet (input states) or
// the page text. play-content-rating, play-target-audience: the summary page text. apple-app-record: the App Store
// the page text. play-content-rating, play-target-audience: the summary page text. play-app-content: the plan's
// capture JSON (cards still showing "Start <Name> declaration"); play-store-settings, the other App content cards and
// play-production-countries / -release: the page text after Save. apple-app-record: the App Store
// Connect API (the app by bundle id; a GET) or, with --from-text, the page text. apple-age-rating: the API.
// Exit 1 on any mismatch, and when nothing could be matched. Pure logic in cli/lib/console-verify.ts. MCP: console_verify.
import { readFileSync } from 'node:fs';
import type { Ctx } from '../lib/ctx';
import { FORM_IDS, isForm } from '../lib/console-answers';
import { isPlanForm, PLAN_FORM_IDS } from '../lib/console-answers';
import { verifyAgeRatingApi, verifyAppRecordApi, type VerifyResult, verifyTable, verifyText } from '../lib/console-verify';
import { printJson } from '../lib/log';
@ -20,7 +22,7 @@ export async function run(ctx: Ctx): Promise<void> {
const { config, root } = ctx.loaded;
const args = ctx.args, json = args.has('json');
const form = args._[0];
if (!isForm(form)) throw new Error(`console verify <form> [--from-text <file|->]: one of ${FORM_IDS.join(', ')}`);
if (!isPlanForm(form)) throw new Error(`console verify <form> [--from-text <file|->]: one of ${PLAN_FORM_IDS.join(', ')}`);
const from = args.get('from-text'), inline = args.get('text');
let r: VerifyResult;
if (from !== undefined || inline !== undefined) {