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:
parent
dbd9f2973c
commit
b7bfe89151
37 changed files with 1624 additions and 121 deletions
|
|
@ -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> => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue