console: Play App content card Saves are the agent's (declaration-save; Send changes for review, Submit, Publish, Restart review stay forbidden), one owner batch per plan with the IARC Terms tick before the run, console plan play-app-content --all, New App fields typed not set, play.appId from the Create app redirect (console verify --url --write) and storebound play app-id across app list pages

This commit is contained in:
CRTkafa 2026-10-13 05:43:56 +08:00
parent 1366e7e231
commit f741c15829
29 changed files with 1029 additions and 165 deletions

View file

@ -30,6 +30,32 @@ Fixes from a real first release of a second game (Capacitor 8 monorepo, lessons
("your-…", `<…>`, "xxx", "/absolute/path/to/…", "example") count as missing in every credential read; `check`
warns about them and `doctor` lists each credential as set, missing or placeholder.
Console facts from a second real game (2026-10-13): the owner now comes back once for Play's App content instead of
once per card.
- Play App content card Saves are the agent's: saving a declaration (privacy policy, ads, app access, advertising ID,
government, financial, health, target audience, the content rating questionnaire and Summary, data safety) is not
public; Google gets it only with the next "Send changes for review" in Publishing overview, which stays the
owner's. New button class `declaration-save`; `declarationSaveAllowed` lets exactly Save / Kaydet through on an App
content card page; `publishingControl` refuses "Send changes for review", "Submit N changes for review", Submit,
Publish and "Restart review" everywhere. The plans' final button is "Send changes for review"; `console fill`
presses the content rating Summary's and the target audience's Save itself.
- `console plan` prints an owner batch first (`ownerBatch`): every owner step of the plan in ONE list, in the order the
agent needs them (open answers, the IARC Terms of Use box with its exact place before the run, Send changes for
review after). `console plan play-app-content --all` (MCP `all: true`) walks every card inline in order and ends
with that one owner list.
- IARC questionnaire: page 1's Next does nothing until the IARC Terms of Use box is ticked (an owner-only agreement).
It is in the owner batch before the run; `console fill` waits for the tick instead of pressing a dead Next.
- Sign in details: with `reviewDemoAccount` in `.storebound/credentials.json` the agent types the demo account
(never printed); only without it is it the owner's.
- New App (App Store Connect): a programmatic value set on Name and SKU left the dialog's state empty; the plan says
"type, don't set value" and `console fill` types every text field key by key.
- `play.appId` captured automatically: `console verify play-app-record --url <url after Create app>` reads the id from
the redirect and shows the `play.appId` change as a dry diff; `--write` upserts it into the config. New read-only
`storebound play app-id`: finds the app by package name in captured app list pages (10 per page, a new app is not
on page 1), then the same dry diff and `--write`.
- Pitfalls 38-41.
## 0.7.1 · 2026-10-13
- Fix: README links and images on npmjs.com resolved against npmjs.com and gave 404 (`docs/README.md`, the logo).

View file

@ -37,7 +37,7 @@ export const COMMANDS: Record<string, [() => Promise<Mod>, string]> = {
'ads apple': [() => import('./ads/apple'), 'Apple Ads (Search Ads): status | campaigns | report (spend, taps, installs, CPA per campaign / keyword, --csv); bid | budget | pause (dry run, asks first; never via MCP)'],
'web': [() => import('./web'), 'init (apple-app-site-association, assetlinks.json, Smart App Banner tag, optional landing page) | check <domain> (live files: content type, no redirects, ids match)'],
'revenuecat': [() => import('./revenuecat'), 'diff (catalog vs RevenueCat products, entitlements, default offering) | sync (dry run, asks first; never via MCP)'],
'console plan': [() => import('./console/plan'), '<form> [--json]: agent-first console mode: start URL, steps, answer sheet (certain / ask owner, console labels), never-press list, UI facts and how to verify, for an AI agent that drives the browser itself; read-only'],
'console plan': [() => import('./console/plan'), '<form> [--json] [--all]: agent-first console mode: the owner batch first, start URL, steps, answer sheet (certain / ask owner, console labels), never-press list, UI facts and how to verify, for an AI agent that drives the browser itself; read-only'],
'console verify': [() => import('./console/verify'), '<form> [--from-text <file|->] [--json]: check a filled console form against the config (page text, input states or the App Store Connect API); read-only, exit 1 on a mismatch'],
'console fill': [() => import('./console/fill'), '<form> [--store apple|play]: optional scripted fast path: autofill in a visible Chromium you sign in to yourself; stops before Save / Submit'],
'reviews': [() => import('./reviews'), 'list | draft (local reply drafts in .storebound/replies) | send (approved drafts only, asks first)'],
@ -55,6 +55,7 @@ export const COMMANDS: Record<string, [() => Promise<Mod>, string]> = {
'play status': [() => import('./play/status'), 'tracks, listings, products (read-only)'],
'play rollout': [() => import('./play/rollout'), 'staged rollout: --fraction x | --halt | --resume | --complete (asks first)'],
'play share': [() => import('./play/share'), 'internal app sharing: upload an AAB / APK, print the share link (asks first; --dry)'],
'play app-id': [() => import('./play/app-id'), '[--from-text <file|->] [--write]: the Play Console app id (play.appId) by package name: known ids, else the agent steps (app list search / pages, capture) and the dry diff; --write saves it; read-only on Play'],
'play next-version-code': [() => import('./play/next-version-code'), 'next free versionCode (prints the number)'],
'apple bundle': [() => import('./apple/bundle'), 'register the bundle id; finds the app record'],
'apple iap': [() => import('./apple/iap'), 'in-app purchases, localizations, price schedules, availability'],

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, quoteTolerant, forbiddenTick, hasToken, isAppleSignInFrame, isSignInText, isSignInUrl, type Name, navAllowed, type NavBases, type NavStep, navPlan, type SectionDef, setUpAllowed, setUpTexts, typeLabels } from '../lib/console-forms';
import { ALL_APPS, APP_PRIVACY_HEADING, consoleReached, CREATE_FORM_FIELD, createAppAllowed, DATA_TYPES_HEADING, DECLARATION_SAVE_FORMS, 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, type SectionDef, 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';
@ -180,9 +180,24 @@ export async function highlight(loc: Locator, text = CHECK): Promise<void> {
}, text);
}
/**
* Types a text field like a person: click it, select what is there, delete it, type the value key by key, read it back.
* App Store Connect's New App dialog (React; verified 2026-10-13) kept its state empty ("") after a programmatic value
* set on Name and SKU (a form_input-style set of the value), while clicking and typing worked. true when the field
* shows the value afterwards.
*/
export async function typeText(loc: Locator, v: string): Promise<boolean> {
await loc.click({ timeout: ACTION_MS });
await loc.press('ControlOrMeta+A', { timeout: ACTION_MS });
await loc.press('Backspace', { timeout: ACTION_MS });
await loc.pressSequentially(v, { delay: 15, timeout: ACTION_MS + v.length * 50 });
const now: string = await loc.inputValue({ timeout: ACTION_MS }).catch(() => v);
return now === v;
}
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 === 'text') return typeText(r.loc, String(v));
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);
@ -266,8 +281,11 @@ export interface FillStep {
fallback: boolean;
/** A control of the filled section (the flow finds its dialog and its Next / Save from it). */
anchor?: Locator;
/** Per uncertain answer (agreements left out), its outlined controls: the flow waits until the owner set each one. */
/** Per uncertain answer (agreements left out, except one that blocks Next), its outlined controls: the flow waits
* until the owner set each one. */
pending?: Locator[][];
/** Agreements on the page that Next waits for and that are not ticked yet (the IARC Terms of Use). */
blocked?: string[];
}
/** The dialog of a section: by its names, else the one visible dialog with such a heading, else (the data type
@ -321,7 +339,7 @@ export async function fillPage(page: Page, def: FormDef, answers: Answer[], done
// missing fields to the owner (reported as "check")
// (a page still loading can also miss a field for a moment: the caller retries before it passes partial = true)
if (missing.length && (!partial || missing.length === rs.length)) return { section: i, title: sec.title, filled: [], highlighted: [], missing, fallback: true };
const filled: string[] = [], highlighted: string[] = [...missing], pending: Locator[][] = [];
const filled: string[] = [], highlighted: string[] = [...missing], pending: Locator[][] = [], blocked: string[] = [];
let anchor: Locator | undefined;
for (const r of rs) {
if (!r.ok) continue;
@ -329,7 +347,12 @@ export async function fillPage(page: Page, def: FormDef, answers: Answer[], done
const a = byId.get(r.field.id);
if (!a) continue;
// an agreement is never ticked, whatever the answer says: highlighted for the owner
if (r.field.agreement || a.agreement) { await mark(r, 'storebound: you agree to this, never ticked'); highlighted.push(a.id); continue; }
if (r.field.agreement || a.agreement) {
await mark(r, 'storebound: you agree to this, never ticked'); highlighted.push(a.id);
// an agreement Next waits for (the IARC Terms of Use, verified 2026-10-13): the flow waits until the owner ticked it
if (r.field.blocksNext && r.loc && !await r.loc.isChecked({ timeout: ACTION_MS }).catch(() => false)) { pending.push([r.loc]); blocked.push(a.id); }
continue;
}
const set = a.certain && a.value !== null ? await setField(r, a.value) : false;
if (set) { filled.push(a.id); if (a.ownerCheck) await mark(r, 'storebound: owner check'); } else {
await mark(r); highlighted.push(a.id);
@ -337,7 +360,7 @@ export async function fillPage(page: Page, def: FormDef, answers: Answer[], done
}
}
done.add(i);
return { section: i, title: sec.title, filled, highlighted, missing, fallback: false, ...(anchor ? { anchor } : {}), pending };
return { section: i, title: sec.title, filled, highlighted, missing, fallback: false, ...(anchor ? { anchor } : {}), pending, ...(blocked.length ? { blocked } : {}) };
}
return { section: null, filled: [], highlighted: [], missing: [], fallback: false };
}
@ -1363,7 +1386,8 @@ export async function fillSession(b: ConsoleBrowser, o: FillSessionOpts): Promis
waitFor = open.length ? { title: s.title ?? '', pending: open } : null;
if (waitFor) {
const need = s.highlighted.filter(id => !pageAnswers.find(a => a.id === id)?.agreement);
say(`answer needed on "${s.title}": ${need.map(id => labels.get(id) ?? id).join('; ')}. Answer ${need.length === 1 ? 'it' : 'each one'} in the browser (outlined in red); storebound goes on by itself once every outlined answer is set (a box that stays empty: click it twice)`);
if (need.length) say(`answer needed on "${s.title}": ${need.map(id => labels.get(id) ?? id).join('; ')}. Answer ${need.length === 1 ? 'it' : 'each one'} in the browser (outlined in red); storebound goes on by itself once every outlined answer is set (a box that stays empty: click it twice)`);
if (s.blocked?.length) say(`agreement needed on "${s.title}": ${s.blocked.map(id => `"${labels.get(id) ?? id}"`).join(' and ')} is yours to tick (outlined in red; storebound never ticks an agreement; the page waits for it). storebound goes on by itself once it is ticked`);
}
continue; // more of the form may be on this page
}
@ -1392,14 +1416,31 @@ export async function fillSession(b: ConsoleBrowser, o: FillSessionOpts): Promis
if (privacy) await privacyStep(p).catch(() => { /* navigating */ });
await rt.sleep(tick);
}
/** The declaration's Save (the one visible button named exactly Save / Kaydet, no Next left on the page), polled at
* most stepMs: the content rating Summary renders after the last Next. true when it was pressed. */
async function declarationSave(p: Page): Promise<boolean> {
const btn = await poll(stepMs, async () => {
if (await nextControl(p).catch(() => null)) return null;
return controlWhere(p, FLOW_NAMES.save, w => !w.inDialog, dataTypes).catch(() => null);
}, undefined, p).catch(() => null);
if (!btn) return false;
return flowClick(btn, form, dataTypes, say, clicked).catch(() => false);
}
if (timedOut) {
say(`nothing storebound knows happened for ${mins(idleMs)}: it stops here and closes the window${o.debugDir && dumps ? ` (debug: ${o.debugDir})` : ''}. Answers saved as drafts stay; run it again when the page is fixed.`);
await b.close();
return { filled, highlighted, fallback, timedOut: true, ...(o.debugDir && dumps ? { debugDir: o.debugDir } : {}) };
}
// a Play App content declaration (content rating Summary, target audience): its Save is the agent's (verified
// 2026-10-13: nothing goes to Google before the owner's "Send changes for review" in Publishing overview)
const declaration = DECLARATION_SAVE_FORMS.includes(form);
const saved = declaration && !b.isClosed() && !fallback ? await declarationSave(active() ?? b.page) : false;
if (!b.isClosed() && !fallback) say(privacy
? 'review the summary, then press Publish yourself. storebound saved the answers as a draft (Save in each dialog); nothing is public until you press Publish. Close the window when done.'
: `review every field and press ${meta.stopBefore} yourself. storebound never saves, submits or publishes. Close the window when done.`);
: declaration ? (saved
? `storebound pressed Save: the declaration is stored, nothing is sent yet. Once every App content card is done, press ${meta.stopBefore} in Publishing overview yourself. Close the window when done.`
: `storebound did not find the declaration's Save on this page: press Save (it only stores the declaration), then ${meta.stopBefore} in Publishing overview once every App content card is done. Close the window when done.`)
: `review every field and press ${meta.stopBefore} yourself. storebound never saves, submits or publishes. Close the window when done.`);
else if (!b.isClosed()) say('close the browser window when done');
// the owner reviews and presses the final button, then closes the window: a reminder every 10 heartbeats, at most finalMs
const end = Date.now() + finalMs;

View file

@ -1,6 +1,7 @@
// The agent-first console mode: storebound supplies the knowledge, the calling AI agent drives the browser.
// storebound console plan lists the forms
// storebound console plan <form> [--json] [--lookup]
// storebound console plan play-app-content --all [--json] every App content card's steps inline, one owner list
// Read-only. Offline by default: the start URL, the ordered steps, the answer sheet (each answer certain or "ask
// owner", with the console's own labels), the never-press list, the known UI facts and how to verify. Deep links use
// apple.appId / play.developerId / play.appId, the ids cached by earlier runs (.storebound/console-app-ids.json,
@ -44,7 +45,8 @@ export async function run(ctx: Ctx): Promise<void> {
const { projectSignals } = await import('../lib/project-signals');
ids.signals = projectSignals(detectProject(root));
} catch { /* not scanned */ }
const plan = consolePlan(form, cfg, ids);
if (args.has('all') && form !== 'play-app-content') throw new Error('console plan: --all is for play-app-content (every App content card inline)');
const plan = consolePlan(form, cfg, ids, { all: args.has('all') });
if (json) return printJson(plan);
console.log(planText(plan));
}

View file

@ -1,17 +1,23 @@
// Checks a console form against the config after the agent (or the owner) filled it. Read-only.
// storebound console verify <form> [--from-text <file|->] [--json]
// storebound console verify play-app-record --url <url after Create app> [--write]
// 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. 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.
// play-app-record also reads the Play Console app id from the URL Create app redirects to (/app/<appId>/app-dashboard;
// --url, or the capture JSON's url) and shows the play.appId change as a dry diff; --write saves it into the config (an
// upsert; the only write of this command, and only to the local config file).
// 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 { isPlanForm, PLAN_FORM_IDS } from '../lib/console-answers';
import { verifyAgeRatingApi, verifyAppRecordApi, type VerifyResult, verifyTable, verifyText } from '../lib/console-verify';
import { printJson } from '../lib/log';
import { log, printJson } from '../lib/log';
import { appIdChange, appIdDiff, writePlayAppId } from '../lib/play-app-id';
import { basename } from 'node:path';
import * as rt from '../lib/rt';
async function readText(src: string): Promise<string> {
@ -24,7 +30,9 @@ export async function run(ctx: Ctx): Promise<void> {
const args = ctx.args, json = args.has('json');
const form = args._[0];
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');
const from = args.get('from-text'), url = args.get('url');
const inline = args.get('text') ?? (url !== undefined ? url : undefined);
if (url !== undefined && form !== 'play-app-record') throw new Error('console verify: --url is for play-app-record (the URL Create app redirects to)');
let r: VerifyResult;
if (from !== undefined || inline !== undefined) {
const text = inline ?? await readText(from!);
@ -45,6 +53,21 @@ export async function run(ctx: Ctx): Promise<void> {
r = verifyAgeRatingApi(config, (age?.data?.attributes ?? null) as Record<string, unknown> | null);
}
} else throw new Error(`console verify ${form}: pass the page text with --from-text <file|-> (run \`storebound console plan ${form}\` for what to capture)`);
if (json) printJson(r); else console.log(verifyTable(r));
// play-app-record: the app id from the redirect URL -> play.appId (dry diff; --write saves it)
let appId: { change: ReturnType<typeof appIdChange>; written: boolean } | null = null;
if (r.playAppId && config.play) {
const change = appIdChange(config, r.playAppId);
const written = args.has('write') && !args.has('dry') && !!ctx.loaded.path ? writePlayAppId(ctx.loaded.path, config, r.playAppId) : false;
appId = { change, written };
}
if (json) printJson(appId ? { ...r, appId } : r); else {
console.log(verifyTable(r));
if (appId) {
const file = basename(ctx.loaded.path || 'storebound.config.ts');
console.log(`\n${appIdDiff(appId.change, file)}`);
if (appId.written) log('console', `wrote play.appId '${appId.change.to}' into ${file}`);
else if (appId.change.changed) console.log('dry: run the same command with --write to save it');
}
}
if (!r.ok) process.exitCode = 1;
}

View file

@ -332,6 +332,7 @@
"use-chrome-session"
],
"console plan": [
"all",
"capacitor",
"cdp",
"chrome-profile",
@ -367,7 +368,9 @@
"react-native",
"sku",
"text",
"unity"
"unity",
"url",
"write"
],
"desktop": [
"channel",
@ -430,6 +433,28 @@
"against",
"threshold"
],
"play app-id": [
"capacitor",
"cdp",
"chrome-profile",
"cordova",
"cordova-android",
"cordova-ios",
"debug-dump",
"expo",
"flutter",
"fresh-profile",
"from-text",
"godot",
"profile",
"react-native",
"start-url",
"step-timeout",
"store",
"unity",
"use-chrome-session",
"write"
],
"play build": [
"allow-stale-web",
"dir",
@ -904,6 +929,7 @@
"unity",
"update-bigmac",
"upload",
"url",
"use-chrome-session",
"variant",
"variants",
@ -1082,10 +1108,12 @@
],
"console plan": [
"storebound console plan lists the forms",
"storebound console plan <form> [--json] [--lookup]"
"storebound console plan <form> [--json] [--lookup]",
"storebound console plan play-app-content --all [--json] every App content card's steps inline, one owner list"
],
"console verify": [
"storebound console verify <form> [--from-text <file|->] [--json]"
"storebound console verify <form> [--from-text <file|->] [--json]",
"storebound console verify play-app-record --url <url after Create app> [--write]"
],
"desktop": [
"storebound desktop open the app; in an app folder (storebound.config.*) it adds or selects that",
@ -1132,6 +1160,10 @@
"originality": [
"storebound originality [--against <folder>[,<folder>]] [--threshold 0.7] [--json]"
],
"play app-id": [
"storebound play app-id [--json] the id when known, else the agent's steps",
"storebound play app-id --from-text <file|-> [--write] [--json] find it in captured app list pages"
],
"play build": [
"storebound play build [--version-code N] [--version-name 1.2.0] [--flavor prod] [--dir android] [--module app]"
],

View file

@ -90,11 +90,13 @@ export function formMeta(id: FormId, config: ShipConfig): FormMeta {
'Free or paid is an owner check: a free app can never become paid later on Google Play.',
'The Developer Program Policies and US export laws boxes are agreements: storebound never ticks them. Read and tick them yourself, then press Create app.',
] };
case 'play-content-rating': return { id, store: 'play', title: 'Play Console: Content rating (IARC)', url: PLAY, stopBefore: 'Save', fillable: true,
case 'play-content-rating': return { id, store: 'play', title: 'Play Console: Content rating (IARC)', url: PLAY, stopBefore: 'Send changes for review', fillable: true,
open: [pickApp, 'Policy > App content > Content ratings > Start (app-content/content-rating-overview > Start questionnaire)'],
notes: ['The IARC Terms of Use box on page 1 is an agreement: storebound never ticks it.', 'The final button on the Summary (per-authority ratings) is "Save", not "Submit" (verified 2026-10-12): it is the owner\'s.'] };
case 'play-target-audience': return { id, store: 'play', title: 'Play Console: Target audience and content', url: PLAY, stopBefore: 'Save', fillable: true,
open: [pickApp, 'Policy > App content > Target audience and content > Start (or Manage); the Ads declaration is App content > Ads'] };
notes: ['The IARC Terms of Use box on page 1 is an agreement: storebound never ticks it. Tick it before the run (page 1, under the email address and the category): Next does nothing until it is ticked (verified 2026-10-13).',
'The Summary\'s button is "Save", not "Submit" (verified 2026-10-12). storebound presses it: it stores the rating declaration, which Google gets only with the next "Send changes for review" in Publishing overview (yours).'] };
case 'play-target-audience': return { id, store: 'play', title: 'Play Console: Target audience and content', url: PLAY, stopBefore: 'Send changes for review', fillable: true,
open: [pickApp, 'Policy > App content > Target audience and content > Start (or Manage); the Ads declaration is App content > Ads'],
notes: ['storebound presses the last page\'s Save: it stores the declaration, which Google gets only with the next "Send changes for review" in Publishing overview (yours).'] };
}
}
@ -424,7 +426,10 @@ export function guidance(meta: FormMeta, answers: Answer[]): string[] {
g.push(...meta.open.map(o => `Open: ${o}`));
for (const a of answers) g.push(`${a.label}: ${a.agreement ? 'you tick it (an agreement; storebound never does)' : valueText(a)}${a.certain && !a.ownerCheck ? '' : ` (check: ${a.note ?? 'not in the config'})`}`);
g.push(...(meta.notes ?? []).map(n => `Note: ${n}`));
g.push(meta.fillable ? `Review every field, then press ${meta.stopBefore} yourself. ${meta.id === 'apple-app-privacy' ? 'storebound saves the answers only as a draft (Save in each dialog); nothing is public before Publish.' : 'storebound never saves, submits or publishes.'}` : 'Answers marked "check" are yours to confirm in storebound.config.ts (apple.info.ageRating).');
const declaration = meta.id === 'play-content-rating' || meta.id === 'play-target-audience';
g.push(!meta.fillable ? 'Answers marked "check" are yours to confirm in storebound.config.ts (apple.info.ageRating).'
: declaration ? `storebound presses the declaration's Save itself (nothing goes to Google before it is sent for review). Once every App content card is done, press ${meta.stopBefore} in Publishing overview yourself.`
: `Review every field, then press ${meta.stopBefore} yourself. ${meta.id === 'apple-app-privacy' ? 'storebound saves the answers only as a draft (Save in each dialog); nothing is public before Publish.' : 'storebound never saves, submits or publishes.'}`);
return g;
}

View file

@ -22,6 +22,9 @@ export interface FieldDef {
options?: Record<string, Name[]>;
/** An agreement checkbox: located and highlighted, NEVER ticked (whatever the answer says). */
agreement?: boolean;
/** An agreement the page's Next waits for (the IARC Terms of Use, verified 2026-10-13): the flow waits until the owner
* ticked it instead of pressing a Next that does nothing. */
blocksNext?: boolean;
/** radio: the options count only inside the named group (no group on the page: the field is not there). The IARC
* follow-ups, whose Yes / No options would otherwise match every question of the page. */
scoped?: boolean;
@ -157,7 +160,7 @@ export function formDef(id: FormId, answers: Answer[]): FormDef {
{ title: 'Email address and category', fields: [
{ id: 'email', kind: 'text', names: ['Email address', 'Email'] },
{ id: 'category', kind: 'radio', options: { 'Game': [/^Game\b/i], 'Social or communication': [/^Social/i], 'All other app types': [/^All other app types/i, /^Utility/i] } },
{ id: 'agree:iarcTerms', kind: 'checkbox', agreement: true, names: [/IARC Terms of Use/i, /terms of use/i] },
{ id: 'agree:iarcTerms', kind: 'checkbox', agreement: true, blocksNext: true, names: [/IARC Terms of Use/i, /terms of use/i] },
] },
{ title: 'Questionnaire', fields: answers.filter(a => a.id.startsWith('q:')).map(a => ({ id: a.id, kind: 'radio' as const, names: IARC_NAMES[a.id.slice(2)], options: yesNo() })) },
// follow-ups a Yes reveals on the same page (and Miscellaneous): optional, filled when they appear
@ -191,9 +194,42 @@ const FORBIDDEN = new RegExp(`(?<![${L}])(?:(?:${EN.map(esc).join('|')})(?![${L}
/** Controls never pressed by storebound or by an agent following `console plan`, whatever the form: help panels and
* the final buttons (the agent-facing never-press list is built from this and FORM_BUTTONS). */
export const NEVER_PRESS = ['Publish', 'Submit', 'Create', 'Create app', 'Send', 'Release', 'Definitions and Examples', '?', 'More information'];
/** Play Console's publishing buttons (Publishing overview, verified 2026-10-12): in every Play plan's never-press list. */
export const PLAY_NEVER_PRESS = ['Send changes for review', 'Submit N changes for review', 'Restart review'];
/** true when storebound must not click a control with this accessible name. */
export const forbiddenControl = (name: string): boolean => FORBIDDEN.test(name.toLocaleLowerCase('tr').normalize('NFC')) || FORBIDDEN.test(name.toLowerCase());
/** true when storebound must not click a control with this accessible name (field clicks: absolute; the scoped
* exceptions are navAllowed, createAppAllowed, setUpAllowed, flowAllowed and declarationSaveAllowed). */
export const forbiddenControl = (name: string): boolean => FORBIDDEN.test(name.toLocaleLowerCase('tr').normalize('NFC')) || FORBIDDEN.test(name.toLowerCase())
|| publishingControl(name);
/**
* Buttons that send something to Google or Apple, publish, or restart a review: never pressed by storebound or an agent,
* in any form and by any exception (English and Turkish). Play's "Send changes for review", "Submit N changes for
* review" and "Restart review" (Publishing overview; verified 2026-10-12), Submit, Publish, a release or rollout.
*/
const PUBLISHING = /(?<![\p{L}])(?:(?:send (?:\d+ )?changes? for review|submit|publish|restart (?:the |your )?review|send for review|release|roll ?out)(?![\p{L}])|yayınla|yayımla|gönder|incelemeye|incelemeyi yeniden)/iu;
export const publishingControl = (name: string): boolean => PUBLISHING.test(name.normalize('NFC').replace(/\s+/g, ' '));
/** The exact names of a Play App content card's Save (English and Turkish). */
const CARD_SAVE = ['save', 'kaydet'];
/** Where a card Save sits: the page URL (an App content card is /app/<appId>/app-content/<card>, not the overview). */
export interface CardWhere { url: string }
/**
* The App content declaration Save (verified 2026-10-13): saving a card (Privacy policy, Ads, Sign in details,
* Advertising ID, Government apps, Financial features, Health apps, Target audience, the content rating questionnaire
* and its Summary, Data safety) is not public: Play sends it to Google only with the next "Send changes for review" in
* Publishing overview, which stays the owner's. So the agent (and the scripted engine) presses it: a button whose whole
* name is exactly Save / Kaydet on a Play Console App content card page. Everything that publishes or sends
* (publishingControl) stays refused here too.
*/
export function declarationSaveAllowed(name: string, role: string, where: CardWhere): boolean {
const n = name.trim().replace(/\s+/g, ' ').toLocaleLowerCase('tr');
if (role !== 'button' || !CARD_SAVE.includes(n) || publishingControl(name)) return false;
try {
const u = new URL(where.url);
return /\/developers\/\d{1,25}\/app\/\d{1,25}\/app-content\/(?!overview(?:\/|$))[a-z-]+/.test(u.pathname);
} catch { return false; }
}
/**
* Ticking a checkbox / radio never saves or sends anything, so the button word list above does not apply to them (their
@ -395,7 +431,9 @@ export function navAllowed(name: string, role: string): boolean {
// Google "Target audience and content" https://support.google.com/googleplay/android-developer/answer/9867159 ;
// Google "Create and set up your app" https://support.google.com/googleplay/android-developer/answer/9859152
export type ButtonClass = 'navigation' | 'draft-save' | 'final-submit';
// A fourth class (2026-10-13): declaration-save, an App content card's Save. It stores a declaration that Google gets
// only with the next "Send changes for review" (the owner's, in Publishing overview), so the agent presses it.
export type ButtonClass = 'navigation' | 'draft-save' | 'declaration-save' | 'final-submit';
export interface ButtonRule { button: string; class: ButtonClass; where: string; auto: boolean }
const NEXT: Name[] = ['Next', 'Continue', 'İleri', 'Sonraki', 'Devam', 'Devam et'];
@ -448,13 +486,15 @@ export const FORM_BUTTONS: Record<FormId, ButtonRule[]> = {
{ button: 'Start / Start questionnaire', class: 'navigation', where: 'App content > Content ratings (content-rating-overview): opens the questionnaire', auto: true },
{ button: 'Next', class: 'navigation', where: 'after the category, and from the questions to the Summary', auto: true },
{ button: 'Save draft / Save (questionnaire page)', class: 'draft-save', where: 'questionnaire: stores the answers as the one draft of the app; nothing goes to IARC before the Summary\'s Save', auto: true },
{ button: 'IARC Terms of Use box', class: 'final-submit', where: 'page 1: an agreement, never ticked by the agent or storebound', auto: false },
{ button: 'Save (Summary page)', class: 'final-submit', where: 'Summary (per-authority ratings): sends the questionnaire to IARC and applies the ratings (verified 2026-10-12: "Save", not "Submit")', auto: false },
{ button: 'IARC Terms of Use box', class: 'final-submit', where: 'page 1: an agreement, never ticked by the agent or storebound (the owner ticks it before the run: Next does nothing until it is ticked, verified 2026-10-13)', auto: false },
{ button: 'Save (Summary page)', class: 'declaration-save', where: 'Summary (per-authority ratings; "Save", not "Submit", verified 2026-10-12): stores the rating declaration; Google gets it only with the next Send changes for review', auto: true },
{ button: 'Send changes for review', class: 'final-submit', where: 'Publishing overview, after every App content card: sends the changes to Google (the owner\'s)', auto: false },
],
'play-target-audience': [
{ button: 'Start / Manage', class: 'navigation', where: 'App content: opens the section', auto: true },
{ button: 'Next', class: 'navigation', where: 'between Target age, App details, Ads and Store presence', auto: true },
{ button: 'Save', class: 'final-submit', where: 'the last page: stores the declaration, which Google then reviews', auto: false },
{ button: 'Save', class: 'declaration-save', where: 'the last page: stores the declaration; Google gets it only with the next Send changes for review', auto: true },
{ button: 'Send changes for review', class: 'final-submit', where: 'Publishing overview, after every App content card: sends the changes to Google (the owner\'s)', auto: false },
],
};
@ -491,10 +531,14 @@ export function flowClass(form: FormId, name: string, role: string, where: FlowW
return null;
}
if (nameIs(n, SAVE_DRAFT)) return 'draft-save';
if (nameIs(n, SAVE) && DECLARATION_SAVE_FORMS.includes(form)) return 'declaration-save';
if (nameIs(n, SAVE) || nameIs(n, DONE)) return 'final-submit';
return null;
}
/** The scripted forms whose Save is an App content declaration save (the agent's: verified 2026-10-13). */
export const DECLARATION_SAVE_FORMS: FormId[] = ['play-content-rating', 'play-target-audience'];
/** The texts that open one data type's questions ("Set Up Coarse Location"; Turkish "<type> ayarla"), for its labels. */
export const setUpTexts = (t: string): string[] => typeLabels(t).flatMap(l => [`Set Up ${l}`, `${l} ayarla`]);
@ -514,13 +558,17 @@ export function setUpAllowed(text: string, where: FlowWhere): boolean {
export const AUTO_DRAFT_SAVE: FormId[] = ['apple-app-privacy', 'play-content-rating'];
/**
* The flow click gate: a navigation control of the form, App Privacy's Save inside a questionnaire dialog, or the
* content rating's Save draft (drafts: nothing is public or sent before Publish / Submit). Everything else (Publish,
* Submit, Create, a final Save, an unknown control) stays the owner's click.
* The flow click gate: a navigation control of the form, App Privacy's Save inside a questionnaire dialog, the
* content rating's Save draft (drafts: nothing is public or sent before Publish / Submit), or a Play App content
* declaration's Save (content rating Summary, target audience: sent to Google only with the owner's Send changes for
* review). Everything else (Publish, Submit, Create, Send changes for review, Restart review, an unknown control)
* stays the owner's click.
*/
export function flowAllowed(form: FormId, name: string, role: string, where: FlowWhere): boolean {
const c = flowClass(form, name, role, where);
if (publishingControl(name)) return false;
if (c === 'navigation') return true;
if (c === 'declaration-save') return DECLARATION_SAVE_FORMS.includes(form) && !where.inDialog;
if (c !== 'draft-save' || !AUTO_DRAFT_SAVE.includes(form)) return false;
return form === 'apple-app-privacy' ? where.inDialog && where.privacyPage : true;
}

View file

@ -8,8 +8,8 @@
import { playCategoryOf, type ShipConfig } from './config';
import type { ProjectSignals } from './project-signals';
import { type Answer, type AnswerValue, consoleAnswers, type FormId, formMeta, isPlanOnly, type PlanFormId, type Store, valueText } from './console-answers';
import { APP_CONTENT_CAPTURE, APP_CONTENT_CARDS, appContentStart, PLAY_MATERIAL_FACT, playAppPage, playOnlyPart, startLabel } from './console-play-plans';
import { FORM_BUTTONS, formDef, type Name, NEVER_PRESS, playAppList, playCreateApp, setUpTexts, typeLabels } from './console-forms';
import { APP_CONTENT_CAPTURE, APP_CONTENT_CARDS, appContentStart, PLAY_MATERIAL_FACT, playAppPage, playOnlyPart, SEND_FOR_REVIEW, SEND_FOR_REVIEW_WHERE, startLabel } from './console-play-plans';
import { FORM_BUTTONS, formDef, type Name, NEVER_PRESS, PLAY_NEVER_PRESS, playAppList, playCreateApp, setUpTexts, typeLabels } from './console-forms';
import { PLAY_CREATE_CAPTURE } from './console-verify';
import { APPLE_PURPOSES } from './console-answers';
@ -50,10 +50,24 @@ export interface PlanStep {
type?: string;
}
/** One thing only the owner does (an answer no file proves, an agreement, a final button), in the order the agent needs it. */
export interface OwnerStep {
n: number;
/** before: the owner does it before the agent starts (so the run never waits mid-way); after: once the agent is done. */
when: 'before' | 'after';
kind: 'answers' | 'agreement' | 'credentials' | 'permission' | 'final';
/** What to do, in plain words. */
text: string;
/** Where exactly (the page, the control). */
where?: string;
}
export interface ConsolePlan {
form: PlanFormId;
store: Store;
title: string;
/** Every owner step of this plan in ONE batch, in the order the agent needs them (printed at the top). */
ownerBatch: OwnerStep[];
/** Where to start: a deep link when the ids are known, else the console and how to reach the form. */
start: { url: string; deepLink: boolean; reach: string[] };
steps: PlanStep[];
@ -65,7 +79,8 @@ export interface ConsolePlan {
neverPress: string[];
/** The final button the owner presses; the agent stops before it and tells the owner. */
final: string;
/** Buttons the agent may press: navigation and draft saves (nothing public, nothing sent). */
/** Buttons the agent may press: navigation, draft saves and Play App content declaration saves (nothing public,
* nothing sent: a declaration goes to Google only with the owner's Send changes for review). */
allowed: { button: string; class: string; where: string }[];
/** The rule in one line. */
rule: string;
@ -168,9 +183,11 @@ const DRIVING = [
'Use your own browser tool (Claude in Chrome, a built-in browser, Playwright). Read the page as text or an accessibility tree first; click by visible text; adapt when the page differs from these steps (the console changes without notice), but never press anything in buttons.neverPress.',
'Long flows run INSIDE the page: one Runtime.evaluate / javascript call times out at about 45 s on big Play pages (the IARC questionnaire, App content), so never loop over many controls in one call. Paste plan.inPageLoop once per page (one short call; it defines window.__sb), start the job with window.__sb.run([{ role: "radio", label: "No", within: "<question text>" }, { role: "checkbox", label: "<box label>", want: true }, ...]) (it returns at once), then poll window.__log.slice(-20) and window.__done in short calls every 2-5 s until the log says done. The helper only picks radios and ticks boxes (never agreements); press Next / Save yourself afterwards, by their visible text.',
'Wait for each step to render (poll for the next heading or input, up to ~10 s) before acting; never act on a step you have not read.',
'Text fields: click the field and TYPE the text (key presses), then read the value back. Never set it programmatically (form_input, el.value = …): App Store Connect\'s New App kept its Name and SKU empty after a value set (verified 2026-10-13).',
'Owner steps: hand the owner plan.ownerBatch in ONE message before you start (the "before" items: answers, the IARC Terms of Use box) and run without stopping until the "after" items (the final button).',
'An answer with status ask-owner: ask the owner in chat before filling it (AGENTS.md rule 5), or leave it empty and tell the owner. Never guess.',
'Stop before the final button (buttons.final) and tell the owner exactly what to press; then run the verify command.',
'Never sign in, type a password, accept an agreement or tick a declaration: the owner does that in the browser.',
'Never sign in (never type the owner\'s own password), accept an agreement or tick a declaration: the owner does that in the browser. The one password you type is the App Review demo account from .storebound/credentials.json (reviewDemoAccount) on Play\'s Sign in details card, never printed.',
];
/**
@ -315,12 +332,14 @@ function otherPlan(form: FormId, config: ShipConfig, answers: PlanAnswer[], p: P
case 'apple-app-record':
add({ text: 'Open App Store Connect > Apps.' });
add({ text: 'Click the add button (+, labelled "Add Apps") at the top left, then "New App" in the menu. The New App dialog opens.', click: ['+', 'New App'] });
add({ text: 'Fill the New App dialog: tick iOS, type the Name, choose the Primary Language and the Bundle ID, type the SKU, pick User Access.', answers: all(() => true) });
add({ text: 'Fill the New App dialog: tick iOS, type the Name, choose the Primary Language and the Bundle ID, type the SKU, pick User Access. Name and SKU: click the field and TYPE the text (your browser tool\'s type action, key by key); never set the value programmatically (form_input, el.value = …): the dialog then keeps an empty name / SKU (verified 2026-10-13). Read both fields back.', answers: all(() => true) });
add({ text: 'Stop. Do NOT press Create: tell the owner to review and press Create (it creates the app record for good; the bundle id and SKU cannot change).' });
add({ text: 'Verify: `storebound console verify apple-app-record` reads the new record from the App Store Connect API (or pass the App Information page text with --from-text -).' });
return {
start: { url: `${ASC}/apps`, deepLink: false, reach: ['the + button > New App'] }, steps,
uiFacts: ['Modeled on Apple\'s "Add a new app" help page, not recorded: adapt to the page.', 'User Access is a plain heading with two radios (Full Access, Limited Access), not an ARIA group.', 'The Bundle ID list only has ids registered in Certificates, Identifiers & Profiles (`storebound apple bundle` registers it).', 'The name must be unique on the App Store; "already being used": ask the owner for a variant and set apple.name.'],
uiFacts: ['Modeled on Apple\'s "Add a new app" help page, not recorded: adapt to the page.',
'Verified 2026-10-13: type, don\'t set value. A programmatic value set (form_input, el.value = …) on the Name and SKU textboxes left the dialog\'s React state empty (""): Create then sees no name / SKU. Click the field and type (key presses); `console fill` types them the same way.',
'User Access is a plain heading with two radios (Full Access, Limited Access), not an ARIA group.', 'The Bundle ID list only has ids registered in Certificates, Identifiers & Profiles (`storebound apple bundle` registers it).', 'The name must be unique on the App Store; "already being used": ask the owner for a variant and set apple.name.'],
verify: { command: 'storebound console verify apple-app-record', source: 'api-or-page-text', capture: PAGE_TEXT, expect: ['An app with the bundle id exists, with the name, primary language and SKU of the answer sheet.'] },
};
case 'apple-age-rating':
@ -337,7 +356,7 @@ function otherPlan(form: FormId, config: ShipConfig, answers: PlanAnswer[], p: P
add({ text: 'Choose the Default language; pick App or game and Free or paid. These are <material-radio> elements: click the inner [role=radio] (a DOM click from a script works in a background window), then check that aria-checked is "true" and that the "Select app or game" / "Select free or paid" hint is gone.', answers: all(a => ['language', 'type', 'price'].includes(a.id)) });
add({ text: 'Leave the two declarations (Developer Program Policies, US export laws) unticked: they are agreements the owner reads and ticks.', answers: all(a => a.status === 'agreement') });
add({ text: 'Stop. Do NOT press Create app: tell the owner to tick the declarations, check Free or paid (a free app can never become paid) and press Create app.' });
add({ text: 'After Create app the app opens at /console/u/0/developers/<developer id>/app/<app id>/app-dashboard: put that app id in play.appId (the other Play forms deep-link from it).' });
add({ text: 'After Create app the app opens at /console/u/0/developers/<developer id>/app/<app id>/app-dashboard. Read location.href and run `storebound console verify play-app-record --url "<that URL>"`: it reads the app id from the URL and shows the play.appId change as a dry diff; then the same command with `--write` saves it into storebound.config.ts (an upsert: the other Play forms deep-link from it). No need to hunt through the paginated app list (`storebound play app-id` is the fallback when the redirect was missed).' });
add({ text: `Owner step, never done by an agent or storebound (a permission grant): app-level permissions of the service account do NOT cover apps created later. The owner opens ${PLAY_SA_PATH}, then runs \`storebound apps check --store play\` to confirm the API sees the app.` });
add({ text: 'Verify before the owner presses Create app: run the capture snippet (verify.capture) in the page and pipe its JSON to `storebound console verify play-app-record --from-text -`.' });
return {
@ -348,7 +367,7 @@ function otherPlan(form: FormId, config: ShipConfig, answers: PlanAnswer[], p: P
'Fields: App name (textbox, max 30); App package name (textbox, max 150) with a "Check availability" button (read-only, safe to press) that reports "Package name available"; Default language (dropdown, default "English (United States) – en-US"); App or game (material-radio App / Game); Free or paid (material-radio Free / Paid); Declarations: two checkboxes (Developer Program Policies, US export laws), owner only.',
'Gotcha: in a background window, computer-tool clicks on the material radios did nothing, and setting the native inputs (form_input) did not update the Angular model: the form kept showing "Select app or game". Only a click on the inner [role=radio] of each material-radio (via JS: el.click()) updated it. Then verify aria-checked="true" and that no "Select …" hint is left.',
'Text inputs set with form_input update the character counters correctly.',
'After Create app the app lives at /console/u/0/developers/<developerId>/app/<appId>/app-dashboard (verified): the app id deep-links the other Play forms (play.appId; storebound also remembers it).',
'After Create app the app lives at /console/u/0/developers/<developerId>/app/<appId>/app-dashboard (verified): the app id deep-links the other Play forms (play.appId; storebound also remembers it). Take it from that redirect URL (`console verify play-app-record --url <url> --write`): the app list shows 10 apps per page and a new app is not on page 1 (verified 2026-10-13).',
`A service account's app-level permissions do NOT cover apps created later: the owner adds the new app (${PLAY_SA_PATH}). Agents never do this (a permission grant; auto mode blocks it too).`,
'The package name is fixed for good once the app is created. Free can never become Paid.',
PLAY_MATERIAL_FACT,
@ -359,14 +378,14 @@ function otherPlan(form: FormId, config: ShipConfig, answers: PlanAnswer[], p: P
case 'play-content-rating': {
const section = (sec: string) => all(a => a.id.startsWith(`d:${sec}:`));
add({ text: 'Open App content > Content ratings (/app-content/content-rating-overview), then "Start questionnaire": it opens /app-content/content-rating-iarc-questionnaire.', click: ['Start questionnaire'] });
add({ text: 'Page 1: type the email address and pick the category (material radio: JS click on the inner [role=radio]). The IARC Terms of Use checkbox is an agreement: never tick it; the owner ticks it (it is in the batch). Then Next.', click: ['Next'], answers: ['email', 'category', 'agree:iarcTerms'] });
add({ text: 'Page 1: type the email address and pick the category (material radio: JS click on the inner [role=radio]). The IARC Terms of Use checkbox is an agreement: never tick it. The owner ticked it before the run (owner batch); read its aria-checked. Still unticked: Next does nothing (verified 2026-10-13), so do not press it again and again: say once that the box is waiting for the owner and poll aria-checked every 5 s (up to 15 min). Then Next.', click: ['Next'], answers: ['email', 'category', 'agree:iarcTerms'] });
add({ text: 'The questionnaire is ONE long page of sections (Violence, Fear, Sexuality, Gambling, Language, Controlled Substance, Crude Humor, Digital Purchases, Miscellaneous). Answer each section\'s Yes / No as the sheet says; each Yes reveals follow-ups right below it, so answer top to bottom and re-read the page after every Yes. Run it as one in-page job (plan.inPageLoop) and poll window.__log.', answers: all(a => a.id.startsWith('q:')) });
add({ text: 'Violence follow-ups (after Yes): the humans / non-humans checkboxes, then setting, childlike style, reactions, presentation checkboxes, blood level, historical war setting, innocent characters, fierce sounds.', answers: section('violence') });
add({ text: 'Fear, Sexuality, Gambling, Language, Controlled Substance, Crude Humor follow-ups (after Yes): answer the questions shown as the sheet says (details.<section>.other).', answers: ['fear', 'sexuality', 'gambling', 'language', 'controlledSubstances', 'crudeHumor'].flatMap(section) });
add({ text: 'Digital Purchases follow-ups (after "purchases of digital goods" Yes): the random items (loot box) question, then trading between players.', answers: section('digitalPurchases') });
add({ text: 'Miscellaneous: user interaction (after Yes: block, report, moderation, friends only), location sharing, Nazi symbols, Korea history, terrorism, crimes.', answers: [...section('userInteraction'), ...section('miscellaneous')] });
add({ text: 'Press Save (stores the draft; nothing goes to IARC yet), then Next: the Summary shows the rating per authority (PEGI, ESRB, USK, ClassInd, Google Play ...).', click: ['Save', 'Next'] });
add({ text: 'Summary: stop. Do NOT press its Save (the final button here is "Save", not "Submit"): tell the owner the ratings shown and let them press Save (it applies the IARC ratings).' });
add({ text: `Summary: read the ratings per authority, then press its Save yourself (a declaration save, "Save", not "Submit"; verified 2026-10-13: it stores the rating declaration, which Google gets only with the next "${SEND_FOR_REVIEW}" in Publishing overview, the owner's). Tell the owner the ratings in the final report.`, click: ['Save'] });
add({ text: 'Verify: read the Summary page as text and pipe it to `storebound console verify play-content-rating --from-text -`.' });
return {
start: playStart(form, config, p), steps,
@ -374,8 +393,9 @@ function otherPlan(form: FormId, config: ShipConfig, answers: PlanAnswer[], p: P
'Verified 2026-10-12 (an agent with a browser tool on a real game):',
'App content > Content ratings is /app-content/content-rating-overview; "Start questionnaire" opens /app-content/content-rating-iarc-questionnaire.',
'Page 1: email address, the category radio, and an IARC Terms of Use checkbox: an agreement, the owner ticks it, never the agent.',
'Verified 2026-10-13: page 1\'s Next does nothing until the IARC Terms of Use box is ticked. So the box is in the ONE owner batch before the run (the owner ticks it on page 1 first), never a stop in the middle.',
'The questionnaire is one long page of sections; each Yes reveals follow-ups (Violence: humans / non-humans checkboxes, then setting, childlike style, reactions, presentation checkboxes, blood level, historical war setting, innocent characters, fierce sounds; Digital Purchases: digital goods, then loot boxes, then player trading; Miscellaneous: user interaction then block / report / moderation / friends-only, location sharing, Nazi symbols, Korea history, terrorism, crimes). Follow-up wording here is storebound\'s: match by topic and the console\'s own labels.',
'Save on the questionnaire stores it; Next goes to the Summary with per-authority ratings. The Summary\'s final button is "Save" (not "Submit"): the owner\'s.',
`Save on the questionnaire stores it; Next goes to the Summary with per-authority ratings. The Summary\'s button is "Save" (not "Submit"). Saving an App content declaration is not public (verified 2026-10-13): Google gets it only with the next "${SEND_FOR_REVIEW}" in Publishing overview (the owner\'s), so the agent presses this Save.`,
'Example result (the game of that run): PEGI 7, ESRB E10+, USK 12, ClassInd 18 (paid random items alone made it 18), Google Play 7+.',
'The email address field gets the full app.contactEmail from the config (printed masked here).',
PLAY_MATERIAL_FACT, PLAY_DASHBOARD_FACT,
@ -389,11 +409,11 @@ function otherPlan(form: FormId, config: ShipConfig, answers: PlanAnswer[], p: P
add({ text: 'Target age: tick exactly the age groups of the sheet (material checkboxes: JS click on the inner [role=checkbox]; read aria-checked back). Next.', click: ['Next'], answers: all(a => a.id.startsWith('age:')) });
if (answers.some(a => a.id === 'appeal')) add({ text: `Appeal to children: pick the answer, then Next.${teens ? ' With 13+ age groups only the page skipped this question and went straight to the Summary (verified 2026-10-12): answer it only if it shows.' : ''}`, click: ['Next'], answers: ['appeal'] });
add({ text: 'Ads (also App content > Ads, /app-content/ads-declaration): pick whether the app contains ads, if this section shows it.', answers: ['ads'] });
add({ text: 'Summary: stop. Do NOT press Save: tell the owner to review and press Save (Google reviews the declaration).' });
add({ text: `Summary: press Save (the agent's button, a declaration save: Google gets it only with the next "${SEND_FOR_REVIEW}" in Publishing overview, the owner's). Read the summary back.`, click: ['Save'] });
add({ text: 'Verify: read the section\'s summary as text and pipe it to `storebound console verify play-target-audience --from-text -`.' });
return {
start: playStart(form, config, p), steps,
uiFacts: ['Verified 2026-10-12: /app-content/target-audience-content; age groups are checkboxes; with 13+ age groups only it went straight to the Summary (no appeal-to-children question); Save there is the final button.', 'Any age group under 13 brings the Families policy.', PLAY_MATERIAL_FACT, PLAY_DASHBOARD_FACT],
uiFacts: [`Verified 2026-10-12: /app-content/target-audience-content; age groups are checkboxes; with 13+ age groups only it went straight to the Summary (no appeal-to-children question). Its Save stores the declaration (the agent's: Google gets it only with the owner's "${SEND_FOR_REVIEW}", verified 2026-10-13).`, 'Any age group under 13 brings the Families policy.', PLAY_MATERIAL_FACT, PLAY_DASHBOARD_FACT],
verify: { command: 'storebound console verify play-target-audience --from-text -', source: 'page-text', capture: PAGE_TEXT, expect: ['The target age groups and the ads answer equal the answer sheet.'] },
};
}
@ -407,36 +427,66 @@ function askOwnerOf(answers: PlanAnswer[], prefix = ''): string[] {
.map(a => `${prefix}${a.question}: ${a.status === 'agreement' ? 'an agreement, the owner ticks it' : a.status === 'owner-check' ? `${a.text} (owner check: ${a.note ?? ''})` : `ask the owner${a.note ? ` (${a.note})` : ''}`}`);
}
const ruleText = (draftSaves: string[], final: string) => `Press only navigation (Next, Back, Get Started, Edit, Set Up, Start / Manage links) and draft saves (${draftSaves.join(', ') || 'none in this form'}). Never press ${final} or anything that publishes, submits, creates, sends or accepts; stop and tell the owner.`;
const ruleText = (draftSaves: string[], final: string, declarationSaves: string[] = []) => `Press only navigation (Next, Back, Get Started, Edit, Set Up, Start / Manage links), draft saves (${draftSaves.join(', ') || 'none in this form'})${declarationSaves.length ? ` and the App content declaration saves (${declarationSaves.join(', ')}: Google gets them only with the owner's ${SEND_FOR_REVIEW})` : ''}. Never press ${final} or anything that publishes, submits, creates, sends, restarts a review or accepts; stop and tell the owner.`;
/** The App content overview: every card in the page's order, its page, its plan, and one batch of owner questions. */
function appContentPlan(cfg: ShipConfig, p: PlanIds): ConsolePlan {
const IARC_TERMS = 'Tick the IARC Terms of Use box';
/** Where the IARC Terms box is (verified 2026-10-12 / 2026-10-13). */
function iarcWhere(cfg: ShipConfig, p: PlanIds): string {
const dev = p.playDeveloperId ?? cfg.play?.developerId, app = p.playAppId ?? cfg.play?.appId;
const url = dev && app ? playAppPage(dev, app, 'app-content/content-rating-iarc-questionnaire') : '/app-content/content-rating-iarc-questionnaire';
return `Play Console > the app > Policy and programs > App content > Content ratings > "Start questionnaire" (${url}), page 1: the IARC Terms of Use checkbox under the Email address and the Category. Tick it, leave the tab open; the agent fills the rest of page 1 and presses Next (Next does nothing until it is ticked).`;
}
/** The owner batch of a plan: open answers first, then what the owner does before the agent starts, then the final steps. */
function ownerBatchOf(form: PlanFormId, cfg: ShipConfig, p: PlanIds, answers: PlanAnswer[], final: string): OwnerStep[] {
const out: Omit<OwnerStep, 'n'>[] = [];
const open = answers.filter(a => (a.status === 'ask-owner' || a.status === 'owner-check') && !a.id.endsWith('credentials'));
if (open.length) out.push({ when: 'before', kind: 'answers', text: `Answer ${open.length} question(s) in ONE batch (askOwner), then write the answers into storebound.config.ts and rerun the plan: ${open.map(a => a.question).join('; ')}` });
if (answers.some(a => a.status === 'ask-owner' && a.id.endsWith('credentials'))) out.push({ when: 'before', kind: 'credentials', text: 'Put the App Review demo account into .storebound/credentials.json (reviewDemoAccount: username, password) so the agent types it without printing it; or type the username and password into the Sign in details card yourself', where: 'App content > Sign in details > Add instructions' });
const iarc = answers.some(a => a.status === 'agreement' && /iarcTerms$/.test(a.id));
if (iarc) out.push({ when: 'before', kind: 'agreement', text: IARC_TERMS, where: iarcWhere(cfg, p) });
if (form === 'play-app-record') {
out.push({ when: 'after', kind: 'agreement', text: 'Tick the two declarations (Developer Program Policies, US export laws) and check Free or paid (a free app can never become paid)', where: 'the Create app form, bottom' });
out.push({ when: 'after', kind: 'final', text: 'Press Create app', where: 'the Create app form' });
out.push({ when: 'after', kind: 'permission', text: `Give the service account access to the new app: ${PLAY_SA_PATH}` , where: 'Users and permissions' });
} else if (final === SEND_FOR_REVIEW) out.push({ when: 'after', kind: 'final', text: `Press ${SEND_FOR_REVIEW} once every App content card and store setting is done`, where: SEND_FOR_REVIEW_WHERE });
else if (form === 'play-production-release') out.push({ when: 'after', kind: 'final', text: `Press "Submit N changes for review", then ${SEND_FOR_REVIEW}`, where: SEND_FOR_REVIEW_WHERE });
else if (final) out.push({ when: 'after', kind: 'final', text: `Press ${final}` });
return out.sort((a, b) => (a.when === b.when ? 0 : a.when === 'before' ? -1 : 1)).map((x, i) => ({ n: i + 1, ...x }));
}
/** The App content overview: every card in the page's order (the order the agent fixes them in), its page, its plan,
* and one owner batch. `all`: every card's steps inline, ending with the one owner list. */
function appContentPlan(cfg: ShipConfig, p: PlanIds, all = false): ConsolePlan {
const cards = APP_CONTENT_CARDS.map(card => ({ card, plan: card.form ? consolePlan(card.form, cfg, p) : null }));
const steps: PlanStep[] = [];
const add = (s: Omit<PlanStep, 'n'>) => steps.push({ n: steps.length + 1, ...s });
const answers: PlanAnswer[] = cards.flatMap(({ card, plan }) => (plan ? plan.answers.map(a => ({ ...a, id: `${plan.form}/${a.id}`, where: `${card.name}${a.where ? `: ${a.where}` : ''}` })) : []));
add({ text: 'Before the run: ask the owner every question of askOwner in ONE batch and write the answers into storebound.config.ts (rerun this plan: they turn certain).' });
add({ text: 'Open App content (/app-content/overview). Each card has a button aria-label="Start <Name> declaration"; a card already done shows its answers and a Manage / Edit link instead.' });
const ownerBatch = ownerBatchOf('play-app-content', cfg, p, answers, SEND_FOR_REVIEW);
add({ text: `Before the run: hand the owner the owner batch (ownerBatch, ${ownerBatch.length} item(s)) in ONE message: the open answers (write them into storebound.config.ts and rerun this plan: they turn certain) and what the owner does before you start (the IARC Terms of Use box). Then drive every card without stopping.` });
add({ text: 'Open App content (/app-content/overview). Each card has a button aria-label="Start <Name> declaration"; a card already done shows its answers and a Manage / Edit link instead (skip it unless its answers differ from the sheet).' });
for (const { card, plan } of cards) {
if (!plan) { add({ text: `${card.name}: ${card.via}. Dry run first, then the real run; then read the card back.` }); continue; }
add({ text: `${card.name}: open /app-content/${card.slug} (the button "${startLabel(card.name)}") and follow \`storebound console plan ${plan.form}\`: ${plan.steps.length} steps, final button ${plan.buttons.final}.`, click: [startLabel(card.name)], answers: answers.filter(a => a.id.startsWith(`${plan.form}/`)).map(a => a.id) });
if (!plan) { add({ text: `${card.name}: ${card.via}. Dry run first, then the real run yourself; then read the card back.` }); continue; }
if (!all) { add({ text: `${card.name}: open /app-content/${card.slug} (the button "${startLabel(card.name)}") and follow \`storebound console plan ${plan.form}\`: ${plan.steps.length} steps; its Save is yours (a declaration save).`, click: [startLabel(card.name)], answers: answers.filter(a => a.id.startsWith(`${plan.form}/`)).map(a => a.id) }); continue; }
add({ text: `${card.name}: open /app-content/${card.slug} (the button "${startLabel(card.name)}").`, click: [startLabel(card.name)] });
for (const st of plan.steps.slice(1)) add({ text: `${card.name}: ${st.text}`, ...(st.click ? { click: st.click } : {}), ...(st.answers ? { answers: st.answers.map(id => `${plan.form}/${id}`) } : {}) });
}
add({ text: 'Stop before each card\'s final Save (the content rating Summary\'s Save too): list the filled cards for the owner, who presses them (or says yes in chat for the listed cards; then the agent presses them).' });
add({ text: 'Verify: run the capture snippet (verify.capture) on the overview and pipe its JSON to `storebound console verify play-app-content --from-text -`.' });
const neverPress = [...new Set(cards.flatMap(c => c.plan?.buttons.neverPress ?? []))];
add({ text: `Verify: run the capture snippet (verify.capture) on the overview and pipe its JSON to \`storebound console verify play-app-content --from-text -\`. Then stop: the owner list is ${ownerBatch.filter(o => o.kind !== 'answers').map(o => `[${o.when}: ${o.text}]`).join(', ')}; tell the owner the ${SEND_FOR_REVIEW} step (the before steps are done already).` });
const neverPress = [...new Set([SEND_FOR_REVIEW, ...cards.flatMap(c => c.plan?.buttons.neverPress ?? [])])];
const allowed = [{ button: 'Start <Name> declaration', class: 'navigation', where: 'App content overview: opens the card' },
...cards.flatMap(c => c.plan?.buttons.allowed ?? [])].filter((b, i, l) => l.findIndex(x => x.button === b.button) === i);
return {
form: 'play-app-content', store: 'play', title: 'Play Console: App content (every declaration)',
form: 'play-app-content', store: 'play', title: `Play Console: App content (every declaration${all ? ', all cards inline' : ''})`, ownerBatch,
start: appContentStart(cfg, p), steps, answers,
askOwner: cards.flatMap(({ card, plan }) => (plan ? plan.askOwner.map(l => `${card.name}: ${l}`) : [])),
buttons: {
neverPress, final: 'Save (each card; the content rating Summary\'s Save)',
allowed: [{ button: 'Start <Name> declaration', class: 'navigation', where: 'App content overview: opens the card' },
...cards.flatMap(c => c.plan?.buttons.allowed ?? []).filter((b, i, l) => l.findIndex(x => x.button === b.button) === i)],
rule: ruleText(['Save draft / Save on the IARC questionnaire page', 'Save in Store settings'], 'a card\'s final Save'),
neverPress, final: SEND_FOR_REVIEW, allowed,
rule: ruleText(['Save draft / Save on the IARC questionnaire page'], SEND_FOR_REVIEW, ['each card\'s Save', 'the content rating Summary\'s Save']),
},
uiFacts: [
'Verified 2026-10-12 on /console/u/0/developers/<developerId>/app/<appId>/app-content/overview: each card has a button aria-label="Start <Name> declaration". The cards, in order:',
...APP_CONTENT_CARDS.map((c, i) => `${i + 1}. ${c.name}: /app-content/${c.slug}${c.verified ? '' : ' (slug modeled, not recorded)'}; ${c.via}.`),
`Verified 2026-10-13: saving a card is not public; every saved card goes to Google only with the next "${SEND_FOR_REVIEW}" in Publishing overview. So the agent presses each card's Save; the owner presses ${SEND_FOR_REVIEW} once at the end.`,
'The App category is not an App content card: Store settings (`storebound console plan play-store-settings`).',
PLAY_MATERIAL_FACT,
],
@ -447,14 +497,17 @@ function appContentPlan(cfg: ShipConfig, p: PlanIds): ConsolePlan {
};
}
export function consolePlan(form: PlanFormId, config: ShipConfig, p: PlanIds = {}): ConsolePlan {
/** Plan options: `all` (play-app-content): every card's steps inline. */
export interface PlanOpts { all?: boolean }
export function consolePlan(form: PlanFormId, config: ShipConfig, p: PlanIds = {}, o: PlanOpts = {}): ConsolePlan {
const cfg = p.appleAppId && config.apple && !config.apple.appId ? { ...config, apple: { ...config.apple, appId: p.appleAppId } } : config;
if (form === 'play-app-content') return appContentPlan(cfg, p);
if (form === 'play-app-content') return appContentPlan(cfg, p, o.all);
if (isPlanOnly(form)) {
const part = playOnlyPart(form, cfg, p);
return {
form, store: 'play', title: part.title, start: part.start, steps: part.steps, answers: part.answers, askOwner: askOwnerOf(part.answers),
buttons: { neverPress: part.neverPress, final: part.final, allowed: part.allowed, rule: ruleText(part.allowed.filter(b => b.class === 'draft-save').map(b => b.button), part.final) },
form, store: 'play', title: part.title, ownerBatch: ownerBatchOf(form, cfg, p, part.answers, part.final), start: part.start, steps: part.steps, answers: part.answers, askOwner: askOwnerOf(part.answers),
buttons: { neverPress: part.neverPress, final: part.final, allowed: part.allowed, rule: ruleText(part.allowed.filter(b => b.class === 'draft-save').map(b => b.button), part.final, part.allowed.filter(b => b.class === 'declaration-save').map(b => b.button)) },
uiFacts: part.uiFacts, driving: DRIVING, inPageLoop: IN_PAGE_LOOP, verify: part.verify, fillable: true, guidance: part.guidance,
};
}
@ -463,17 +516,18 @@ export function consolePlan(form: PlanFormId, config: ShipConfig, p: PlanIds = {
const answers = sheet(form, raw);
const part = form === 'apple-app-privacy' ? appPrivacyPlan(answers, cfg.apple?.appId) : otherPlan(form, cfg, answers, p);
const rules = FORM_BUTTONS[form];
const neverPress = [...new Set([...rules.filter(r => !r.auto && r.class === 'final-submit').flatMap(r => r.button.replace(/ \((?:form|and its confirmation dialog)\)$/, '').split(' / ')), ...NEVER_PRESS])];
// the content rating's questionnaire Save is a draft save; only the Summary's Save is final (verified 2026-10-12)
const final = form === 'play-content-rating' ? 'Save (Summary page)' : meta.stopBefore;
const neverPress = [...new Set([...rules.filter(r => !r.auto && r.class === 'final-submit').flatMap(r => r.button.replace(/ \((?:form|and its confirmation dialog)\)$/, '').split(' / ')), ...NEVER_PRESS, ...(meta.store === 'play' ? PLAY_NEVER_PRESS : [])])];
// the content rating's questionnaire Save is a draft save and its Summary's Save a declaration save (verified
// 2026-10-12 / 2026-10-13): the final button is the owner's Send changes for review in Publishing overview
const final = meta.stopBefore;
const teens = form === 'play-target-audience' && raw.filter(a => a.id.startsWith('age:')).every(a => a.certain && (a.value !== true || ['age:13-15', 'age:16-17', 'age:18-over'].includes(a.id)));
const askOwner = askOwnerOf(answers).map(l => (teens && l.startsWith('Could your app unintentionally appeal') ? `${l} (only if the page asks it: with 13+ age groups only it did not, 2026-10-12)` : l));
return {
form, store: meta.store, title: meta.title, ...part, answers, askOwner,
form, store: meta.store, title: meta.title, ownerBatch: ownerBatchOf(form, cfg, p, answers, final), ...part, answers, askOwner,
buttons: {
neverPress, final,
allowed: rules.filter(r => r.auto).map(r => ({ button: r.button, class: r.class, where: r.where })),
rule: ruleText(rules.filter(r => r.class === 'draft-save').map(r => r.button), final),
rule: ruleText(rules.filter(r => r.class === 'draft-save').map(r => r.button), final, rules.filter(r => r.class === 'declaration-save').map(r => r.button)),
},
driving: DRIVING, inPageLoop: IN_PAGE_LOOP,
fillable: meta.fillable,
@ -483,7 +537,10 @@ export function consolePlan(form: PlanFormId, config: ShipConfig, p: PlanIds = {
/** The plan as text for the terminal. */
export function planText(pl: ConsolePlan): string {
const L: string[] = [`${pl.title}${pl.fillable ? '' : ' (guide only)'}`, `start: ${pl.start.url}${pl.start.deepLink ? ' (deep link)' : ''}`, ...pl.start.reach.map(r => ` then: ${r}`), '', 'Steps:'];
const L: string[] = [`${pl.title}${pl.fillable ? '' : ' (guide only)'}`];
if (pl.ownerBatch.length) L.push('', 'Owner batch (everything the owner does, in ONE message, in the order the agent needs it):',
...pl.ownerBatch.map(o => ` ${o.n}. [${o.when === 'before' ? 'before the run' : 'after the run'}] ${o.text}${o.where ? ` @ ${o.where}` : ''}`), '');
L.push(`start: ${pl.start.url}${pl.start.deepLink ? ' (deep link)' : ''}`, ...pl.start.reach.map(r => ` then: ${r}`), '', 'Steps:');
for (const s of pl.steps) L.push(` ${s.n}. ${s.text}${s.click?.length ? ` [click: ${s.click.map(c => `"${c}"`).join(', ')}]` : ''}`);
L.push('', 'Answer sheet:');
for (const a of pl.answers) {

View file

@ -16,7 +16,7 @@
import type { ShipConfig } from './config';
import { PLAY_APP_CATEGORIES, PLAY_GAME_CATEGORIES, playCategoryOf, profileOf } from './config';
import type { PlanFormId, PlanOnlyFormId } from './console-answers';
import { NEVER_PRESS, playAppList } from './console-forms';
import { NEVER_PRESS, PLAY_NEVER_PRESS, playAppList } from './console-forms';
import type { ConsolePlan, PlanAnswer, PlanIds, PlanStatus, PlanStep } from './console-plan';
import { AD_ID_PERMISSION, type ProjectSignals } from './project-signals';
@ -100,12 +100,20 @@ function steps(list: (Omit<PlanStep, 'n'> | false | undefined)[]): PlanStep[] {
return list.filter((s): s is Omit<PlanStep, 'n'> => !!s).map((s, i) => ({ n: i + 1, ...s }));
}
/** A declaration's Save: stores the answer, which Google reviews: the owner's (or the agent's after a clear yes in chat). */
const DECLARATION_SAVE = 'Stop before Save: show the owner the filled card and let them press Save (or press it after the owner\'s clear yes in chat; one yes can cover the cards listed by `console plan play-app-content`). Google reviews the declaration with the next submission.';
/** Publishing overview's button that sends every saved change to Google: the owner's (verified 2026-10-12). */
export const SEND_FOR_REVIEW = 'Send changes for review';
/** Where the owner presses it. */
export const SEND_FOR_REVIEW_WHERE = 'Publishing overview (left menu): "Submit N changes for review", then "Send changes for review" in the "Send N changes for review?" dialog (a real click)';
/**
* A declaration's Save (verified 2026-10-13): it stores the card; nothing goes to Google before the owner presses Send
* changes for review in Publishing overview. So the agent presses it, reads the card back and goes on.
*/
const DECLARATION_SAVE = `Press Save yourself (the agent's button: it stores the declaration; Google gets it only with the next "${SEND_FOR_REVIEW}" in Publishing overview, which stays the owner's). Read the card back.`;
const declarationButtons = (where: string, extra: ConsolePlan['buttons']['allowed'] = []) => ({
final: 'Save',
neverPress: ['Save', 'Send changes for review', ...NEVER_PRESS],
allowed: [{ button: 'Start <card> declaration / Manage', class: 'navigation', where: 'App content overview: opens the card' }, ...extra, { button: 'Back', class: 'navigation', where }],
final: SEND_FOR_REVIEW,
neverPress: [...new Set([...PLAY_NEVER_PRESS, ...NEVER_PRESS])],
allowed: [{ button: 'Start <card> declaration / Manage', class: 'navigation', where: 'App content overview: opens the card' }, ...extra, { button: 'Back', class: 'navigation', where },
{ button: 'Save', class: 'declaration-save', where: `${where}: stores the declaration; Google gets it only with the owner's ${SEND_FOR_REVIEW} (verified 2026-10-13)` }],
});
// ---- Store settings: the App category (no API) ----
@ -134,7 +142,7 @@ function storeSettings(config: ShipConfig, p: PlanIds): Part {
]),
answers: [type, category],
final: 'Send changes for review',
neverPress: ['Send changes for review', ...NEVER_PRESS],
neverPress: [...PLAY_NEVER_PRESS, ...NEVER_PRESS],
allowed: [
{ button: 'Edit (the first one: App category)', class: 'navigation', where: 'Store settings: opens the App category section' },
{ button: 'Save', class: 'draft-save', where: 'Store settings: stores the category. On a published app the change goes out with Google\'s review (with managed publishing: Publishing overview > Send changes for review, the owner\'s)' },
@ -209,9 +217,16 @@ function signInDetails(config: ShipConfig, p: PlanIds): Part {
if (ra?.signInRequired) {
out.push(ra.instructions ? ans('instructions', 'Instructions for the reviewer', ra.instructions, 'certain', 'reviewAccess.instructions', { control: 'text', action: 'type', label: 'Any other information required to access your app', where: 'Add instructions' })
: ans('instructions', 'Instructions for the reviewer', null, 'ask-owner', 'reviewAccess.instructions', { note: 'Set reviewAccess.instructions (how to sign in and reach the main features).' }));
out.push(ans('credentials', 'Username and password of the demo account', null, 'ask-owner', '.storebound/credentials.json reviewDemoAccount', {
control: 'text', where: 'Add instructions: Username, Password',
note: `The owner types them from .storebound/credentials.json (reviewDemoAccount: ${p.demoAccount === undefined ? 'not checked' : p.demoAccount ? 'present' : 'MISSING, add it first'}). storebound never prints them; an agent never types a password.` }));
// with reviewDemoAccount in .storebound/credentials.json the agent types it (never printed: not in chat, logs, the
// plan or a commit); without it the owner types the username and password (or adds it there first)
out.push(p.demoAccount
? ans('credentials', 'Username and password of the demo account', null, 'certain', '.storebound/credentials.json reviewDemoAccount', {
control: 'text', action: 'type', where: 'Add instructions: Username, Password',
text: 'from .storebound/credentials.json (never printed)',
note: 'reviewDemoAccount: present. The agent types reviewDemoAccount.username and .password from .storebound/credentials.json straight into the two fields (click, type), without echoing them in chat, logs, the plan or a commit.' })
: ans('credentials', 'Username and password of the demo account', null, 'ask-owner', '.storebound/credentials.json reviewDemoAccount', {
control: 'text', where: 'Add instructions: Username, Password',
note: `reviewDemoAccount: ${p.demoAccount === undefined ? 'not checked' : 'MISSING'}. The owner adds it to .storebound/credentials.json before the run (then the agent types it, never printed), or types the username and password into the card themselves. storebound never prints them.` }));
}
return {
title: 'Play Console: Sign in details (App access)',
@ -219,13 +234,15 @@ function signInDetails(config: ShipConfig, p: PlanIds): Part {
steps: steps([
{ text: 'Open App content > Sign in details (/app-content/testing-credentials).' },
{ text: 'Pick whether any functionality is restricted (material radio: JS click on the inner [role=radio]).', answers: ['access'] },
ra?.signInRequired && { text: 'Restricted: press "Add instructions", type the instructions; leave Username and Password to the owner (the demo account in .storebound/credentials.json).', click: ['Add instructions'], answers: ['instructions', 'credentials'] },
ra?.signInRequired && { text: p.demoAccount
? 'Restricted: press "Add instructions", type the instructions, then type the Username and Password from .storebound/credentials.json (reviewDemoAccount; click each field and type: never echo them in chat or logs).'
: 'Restricted: press "Add instructions", type the instructions; the Username and Password are the owner\'s (no reviewDemoAccount in .storebound/credentials.json: in the owner batch).', click: ['Add instructions'], answers: ['instructions', 'credentials'] },
{ text: DECLARATION_SAVE },
{ text: 'Verify: read the page text and pipe it to `storebound console verify play-sign-in-details --from-text -`.' },
]),
answers: out,
...declarationButtons('Sign in details', [{ button: 'Add instructions', class: 'navigation', where: 'Sign in details: opens the instructions dialog' }]),
uiFacts: ['Verified 2026-10-12: the Sign in details card (App access) is /app-content/testing-credentials.', 'Credentials are never in the config or in chat: the owner types them from .storebound/credentials.json.', PLAY_MATERIAL_FACT],
uiFacts: ['Verified 2026-10-12: the Sign in details card (App access) is /app-content/testing-credentials.', 'Credentials are never in the config, the plan or chat: the agent types them from .storebound/credentials.json (reviewDemoAccount) without printing them; without that entry the owner types them.', PLAY_MATERIAL_FACT],
verify: pageVerify('play-sign-in-details', ['The selected radio matches reviewAccess.signInRequired; with a sign-in, one set of instructions is listed.']),
guidance: ['Open: Policy and programs > App content > Sign in details (App access)'],
};
@ -368,7 +385,7 @@ function productionCountries(config: ShipConfig, p: PlanIds): Part {
]),
answers,
final: 'Send changes for review',
neverPress: ['Send changes for review', ...NEVER_PRESS],
neverPress: [...PLAY_NEVER_PRESS, ...NEVER_PRESS],
allowed: [
{ button: 'Countries / regions (tab), Edit countries / regions', class: 'navigation', where: 'production track: opens the country list' },
{ button: 'Save', class: 'draft-save', where: 'country list: stores the countries of the draft production release; nothing is public before the owner sends the changes for review' },

View file

@ -16,12 +16,15 @@ import { type Answer, consoleAnswers, type FormId, type PlanFormId, type PlanOnl
import { APP_CONTENT_CARDS, playOnlyPart, startLabel } from './console-play-plans';
import { APPLE_PRIVACY_TYPES } from './app-privacy';
import { typeLabels } from './console-forms';
import { playAppIdInText } from './play-app-id';
import { APPLE_PURPOSES } from './console-answers';
export type RowStatus = 'match' | 'mismatch' | 'not-found' | 'ask-owner';
export interface VerifyRow { item: string; expected: string; seen: string; status: RowStatus; note?: string }
export interface VerifyResult {
form: PlanFormId;
/** play-app-record: the Play Console app id read from the URL after Create app (/app/<appId>/...). */
playAppId?: string;
source: 'page-text' | 'inputs' | 'api';
rows: VerifyRow[];
mismatches: number;
@ -143,6 +146,8 @@ export interface InputStates {
checked?: string[];
/** The visible page text (for "Select …" hints and availability messages). */
text?: string;
/** location.href (after Create app: /app/<appId>/app-dashboard). */
url?: string;
}
/** The capture snippet an agent runs in the page for `console verify play-app-record --from-text -` (prints JSON). */
@ -150,7 +155,7 @@ export const PLAY_CREATE_CAPTURE = `(() => { const t = e => (e?.innerText ?? e?.
const name = e => e.getAttribute('aria-label') || t(e.labels?.[0]) || (e.getAttribute('aria-labelledby') || '').split(/\\s+/).map(id => t(document.getElementById(id))).join(' ').trim() || t(e.closest('label')) || t(e.parentElement);
const fields = {}; for (const i of document.querySelectorAll('input:not([type]), input[type=text], textarea')) if (i.offsetParent) fields[name(i)] = i.value;
const checked = [...document.querySelectorAll('[role=radio][aria-checked=true], input[type=radio]:checked')].map(r => name(r) || t(r.closest('material-radio')));
return JSON.stringify({ fields, checked, text: (document.querySelector('main') ?? document.body).innerText }); })()`;
return JSON.stringify({ url: location.href, fields, checked, text: (document.querySelector('main') ?? document.body).innerText }); })()`;
export function parseInputStates(raw: string): InputStates | null {
const s = raw.trim();
@ -159,7 +164,7 @@ export function parseInputStates(raw: string): InputStates | null {
const j = JSON.parse(s) as Record<string, unknown>;
const fields = j.fields && typeof j.fields === 'object' ? Object.fromEntries(Object.entries(j.fields as Record<string, unknown>).filter(([, v]) => typeof v === 'string')) as Record<string, string> : undefined;
const checked = Array.isArray(j.checked) ? j.checked.filter((x): x is string => typeof x === 'string') : undefined;
return { ...(fields ? { fields } : {}), ...(checked ? { checked } : {}), ...(typeof j.text === 'string' ? { text: j.text } : {}) };
return { ...(fields ? { fields } : {}), ...(checked ? { checked } : {}), ...(typeof j.text === 'string' ? { text: j.text } : {}), ...(typeof j.url === 'string' ? { url: j.url } : {}) };
} catch { return null; }
}
@ -169,6 +174,16 @@ export const SELECT_HINT = /^select (app or game|free or paid|an? .+)$/i;
export function verifyPlayAppRecord(config: ShipConfig, raw: string): VerifyResult {
const answers = consoleAnswers('play-app-record', config).filter(a => !a.agreement);
const st = parseInputStates(raw);
// after Create app (verified 2026-10-12 / 2026-10-13): the console redirects to /app/<appId>/app-dashboard; the form
// is gone, so only the app id is checked (and handed to the play.appId upsert)
const appId = playAppIdInText(st?.url ?? raw);
const formShown = st ? Object.keys(st.fields ?? {}).length > 0 : /\b(App name|App package name|Uygulama adı)\b/.test(raw);
if (appId && !formShown) {
const want = config.play?.appId;
const row: VerifyRow = { item: 'Play app id (the URL after Create app)', expected: want ?? '(not in the config yet: play.appId)', seen: appId,
status: want && want !== appId ? 'mismatch' : 'match', ...(want && want !== appId ? { note: 'play.appId differs: --write replaces it' } : {}) };
return { ...result('play-app-record', st ? 'inputs' : 'page-text', [row], ['The Create app form is gone: the app was created. --write saves the id into play.appId.']), playAppId: appId };
}
const text = st?.text ?? (st ? '' : raw);
const ls = lines(text);
const rows: VerifyRow[] = [];
@ -332,7 +347,7 @@ export function verifyPlayDeclaration(form: Exclude<PlanOnlyFormId, 'play-app-co
const blob = fold(text);
const rows: VerifyRow[] = [];
for (const a of part.answers) {
if (a.status === 'agreement') continue;
if (a.status === 'agreement' || a.id === 'credentials') continue; // the demo account is never read back or printed
if (a.status === 'ask-owner' || a.value === null) { rows.push({ item: a.question, expected: '(owner decides)', seen: '-', status: 'ask-owner' }); continue; }
const want = a.action === 'type' || a.action === 'choose' ? String(a.value) : a.label ?? '';
const plain = !want || /^(yes|no)$/i.test(want) || a.action === 'untick';

122
cli/lib/play-app-id.ts Normal file
View file

@ -0,0 +1,122 @@
// The Play Console app id (play.appId: the digits in /console/u/0/developers/<developerId>/app/<appId>/...), which
// deep-links every Play console plan. The Play Developer API does not return it, and the console's app list shows 10
// apps per page with a new app not on page 1 (verified 2026-10-13), so finding it by hand took several steps. Two
// ways, both agent steps: (1) right after Create app the console redirects to /app/<appId>/app-dashboard: `console
// verify play-app-record --url <url>` reads it there; (2) `storebound play app-id`: the agent captures the app list
// pages (the search box or ?page=) and storebound finds the row with the package name. Either way the change to
// play.appId is shown as a dry diff first and written with --write (an upsert). Pure, except writePlayAppId.
import { readFileSync, writeFileSync } from 'node:fs';
import type { ShipConfig } from './config';
import { upsertNestedString } from './scan/config-edit';
const PLAY = 'https://play.google.com/console';
const ID = /^\d{1,25}$/;
/** The app id in a Play Console URL or in any text that holds one (a capture JSON, page text): the first
* /developers/<digits>/app/<digits>. null when there is none. */
export function playAppIdInText(text: string): string | null {
return /\/developers\/\d{1,25}\/app\/(\d{1,25})(?=[/?#"'\s]|$)/.exec(text)?.[1] ?? null;
}
/** One captured page of the app list (APP_LIST_CAPTURE). */
export interface AppListPage {
url?: string;
/** Each app row: its visible text (name, package name, status) and the href of its link. */
rows: { text: string; href: string }[];
/** The list has a next page (its "Next page" control is there and enabled). */
hasNext?: boolean;
}
/**
* Run in the app list page (one evaluate call): the rows with an /app/<id>/ link and their row text, and whether a next
* page exists. Modeled on the verified URL pattern and a paginated list of 10; the row markup was not recorded, so it
* takes the nearest row-like ancestor of each app link.
*/
export const APP_LIST_CAPTURE = `JSON.stringify((() => {
const t = e => (e?.innerText ?? e?.textContent ?? '').replace(/\\s+/g, ' ').trim();
const rows = [], seen = new Set();
for (const a of document.querySelectorAll('a[href*="/app/"]')) {
const href = a.getAttribute('href') || '';
if (!/\\/developers\\/\\d+\\/app\\/\\d+/.test(href) || seen.has(href)) continue;
seen.add(href);
const row = a.closest('[role=row], tr, li, [role=listitem]') || a.parentElement;
rows.push({ text: t(row), href });
}
const next = [...document.querySelectorAll('button, a, [role=button]')].find(b => /^next( page)?$/i.test((b.getAttribute('aria-label') || t(b)).trim()));
return { url: location.href, rows, hasNext: !!next && !next.disabled && next.getAttribute('aria-disabled') !== 'true' };
})())`;
/** One capture, a JSON array of captures, or one capture per line. Throws on anything else. */
export function parseAppListPages(raw: string): AppListPage[] {
const s = raw.trim();
const one = (j: unknown): AppListPage => {
const o = j as Record<string, unknown>;
if (!o || typeof o !== 'object' || !Array.isArray(o.rows)) throw new Error('play app-id: a capture needs "rows" (run the APP_LIST_CAPTURE snippet in the app list)');
const rows = (o.rows as unknown[]).flatMap(r => {
const x = r as Record<string, unknown>;
return x && typeof x.text === 'string' && typeof x.href === 'string' ? [{ text: x.text, href: x.href }] : [];
});
return { rows, ...(typeof o.url === 'string' ? { url: o.url } : {}), ...(typeof o.hasNext === 'boolean' ? { hasNext: o.hasNext } : {}) };
};
try {
const j = JSON.parse(s) as unknown;
return Array.isArray(j) ? j.map(one) : [one(j)];
} catch (e) {
if (!(e instanceof SyntaxError)) throw e;
return s.split(/\r?\n/).filter(l => l.trim()).map(l => one(JSON.parse(l)));
}
}
/** `id` as a whole token of `text` (com.example.game, not com.example.game2). */
const hasToken = (text: string, id: string): boolean => new RegExp(`(?<![\\w.])${id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![\\w.])`).test(text);
export type AppIdFind = { found: true; appId: string; page: number; row: string } | { found: false; pages: number; hasNext: boolean; ambiguous?: string[] };
/** The row whose text holds the package name as a whole token, across the captured pages (page numbers from 1). */
export function findPlayAppId(pages: AppListPage[], packageName: string): AppIdFind {
const hits: { appId: string; page: number; row: string }[] = [];
pages.forEach((p, i) => {
for (const r of p.rows) {
const id = playAppIdInText(r.href.startsWith('/') ? r.href : (() => { try { return new URL(r.href).pathname; } catch { return r.href; } })());
if (id && hasToken(r.text, packageName) && !hits.some(h => h.appId === id)) hits.push({ appId: id, page: i + 1, row: r.text });
}
});
if (hits.length === 1) return { found: true, ...hits[0]! };
return { found: false, pages: pages.length, hasNext: pages.at(-1)?.hasNext ?? false, ...(hits.length > 1 ? { ambiguous: hits.map(h => h.appId) } : {}) };
}
/** The app list of a developer account, page `page` (1 = the first; the `page` query parameter is modeled: prefer the
* list's own next-page control when it differs). */
export const appListPage = (developerId: string, page = 1): string =>
`${PLAY}/u/0/developers/${encodeURIComponent(developerId)}/app-list${page > 1 ? `?page=${page}` : ''}`;
/** The steps an agent follows to find the app id in the console (read-only). */
export function appIdSteps(config: ShipConfig, developerId: string | null): string[] {
const pkg = config.play?.packageName ?? '<play.packageName>';
const list = developerId ? appListPage(developerId) : `${PLAY} (pick the developer account; set play.developerId for a deep link)`;
return [
`Open the app list: ${list}.`,
`Fastest: type the package name (${pkg}) into the list's search box ("Search apps") and wait for the list to update; the one row left is the app.`,
'Run the capture snippet (capture) in the page and pipe its JSON to `storebound play app-id --from-text -` (it finds the row with the package name and reads the id from its /app/<id>/ link).',
`Not found and the capture says hasNext: press the list's next-page control (aria-label "Next page"; the URL pattern is /app-list?page=<n>, 10 apps per page, new apps are not on page 1), capture again, and pipe ALL captures (a JSON array or one per line) to the same command.`,
'Found: the command shows the play.appId change as a dry diff; run it again with --write to save it into storebound.config.ts.',
];
}
/** What --write would change: play.appId from -> to. */
export interface AppIdChange { from: string | null; to: string; changed: boolean }
export const appIdChange = (config: ShipConfig, id: string): AppIdChange => ({ from: config.play?.appId ?? null, to: id, changed: config.play?.appId !== id });
/** The dry diff line. */
export const appIdDiff = (c: AppIdChange, file: string): string => c.changed
? `${file}: play.appId ${c.from ? `'${c.from}'` : '(not set)'} -> '${c.to}'`
: `${file}: play.appId is already '${c.to}' (nothing to change)`;
/** Writes play.appId into the config file (an upsert); false when it was already that id. */
export function writePlayAppId(configPath: string, config: ShipConfig, id: string): boolean {
if (!ID.test(id)) throw new Error(`play.appId: "${id}" is not a Play Console app id (digits)`);
if (!config.play) throw new Error('the config has no play block: add play.packageName first');
if (!appIdChange(config, id).changed) return false;
writeFileSync(configPath, upsertNestedString(readFileSync(configPath, 'utf8'), configPath, 'play', 'appId', id));
return true;
}

View file

@ -1,6 +1,7 @@
// Writes one top-level block (dataSafety, appPrivacy) into storebound.config.ts / .js / .json text: replaces the
// existing `key: { ... }` property, or inserts it after the `app: { ... }` block. Pure text in, text out; the rest of
// the file (comments, order, formatting) stays as it is. Used by `storebound scan --write` only.
// the file (comments, order, formatting) stays as it is. Used by `storebound scan --write`; upsertNestedString sets one
// nested string (play.appId: `console verify play-app-record --write`, `play app-id --write`).
/** Index of the brace that closes the one at `open`, skipping strings, template literals and comments. -1 if none. */
export function matchBrace(text: string, open: number): number {
@ -69,3 +70,63 @@ export function upsertBlock(text: string, file: string, key: string, value: unkn
const block = `\n${comment ? `${ind}// ${comment}\n` : ''}${ind}${key}: ${toTs(value, ind)},`;
return text.slice(0, at) + block + text.slice(at);
}
/**
* The config text with `parent.key` set to a string `value` (an upsert: `play.appId`, used by `console verify
* play-app-record --write` and `play app-id --write`): JSON is parsed and written back; in .ts / .js the `key: '…'`
* inside the top-level `parent: { … }` block is replaced, or `key: '<value>',` is added right after its opening brace.
* Throws when the block is missing or the key holds something other than a string literal. The rest of the file
* (comments, order, formatting) stays as it is.
*/
export function upsertNestedString(text: string, file: string, parent: string, key: string, value: string): string {
if (file.endsWith('.json')) {
const j = JSON.parse(text) as Record<string, unknown>;
const p = j[parent];
if (!p || typeof p !== 'object' || Array.isArray(p)) throw new Error(`${file}: no ${parent} block to put ${parent}.${key} into`);
(p as Record<string, unknown>)[key] = value;
return JSON.stringify(j, null, 2) + '\n';
}
// the top-level block: the `parent: {` with the smallest indent
const re = new RegExp(`^([ \\t]*)${parent}\\s*:\\s*\\{`, 'gm');
let best: RegExpExecArray | null = null;
for (let m = re.exec(text); m; m = re.exec(text)) if (!best || m[1]!.length < best[1]!.length) best = m;
// a one-line config (`export default { play: { … } }`): the first `parent: {` anywhere
best ??= new RegExp(`()\\b${parent}\\s*:\\s*\\{`).exec(text);
if (!best) throw new Error(`${file}: no ${parent} block to put ${parent}.${key} into; add ${key}: '${value}' to ${parent} in the config file`);
const open = best.index + best[0].length - 1;
const close = matchBrace(text, open);
if (close < 0) throw new Error(`${file}: could not find the end of the ${parent} block`);
const body = text.slice(open + 1, close);
const lit = toTs(value);
const keyRe = new RegExp(`(^|[{,\\s])${key}\\s*:\\s*`, 'g');
for (let k = keyRe.exec(body); k; k = keyRe.exec(body)) {
if (depthAt(body, k.index + k[1]!.length) !== 0) continue; // a key of a nested object
const valStart = k.index + k[0].length;
const q = body[valStart];
if (q !== '\'' && q !== '"' && q !== '`') throw new Error(`${file}: ${parent}.${key} is not a plain string; set it to '${value}' in the config file`);
let end = valStart + 1;
for (; end < body.length && body[end] !== q; end++) if (body[end] === '\\') end++;
const abs = open + 1;
return text.slice(0, abs + valStart) + lit + text.slice(abs + end + 1);
}
if (!body.includes('\n')) {
const inner = body.trim().replace(/,$/, '');
return `${text.slice(0, open + 1)} ${inner ? `${inner}, ` : ''}${key}: ${lit} ${text.slice(close)}`;
}
const ind = /\n([ \t]*)\S/.exec(body)?.[1] ?? `${best[1]} `;
return `${text.slice(0, open + 1)}\n${ind}${key}: ${lit},${body}${text.slice(close)}`;
}
/** Bracket depth at `at` in `s` (strings and comments skipped). */
function depthAt(s: string, at: number): number {
let d = 0;
for (let i = 0; i < at; i++) {
const ch = s[i]!;
if (ch === '/' && s[i + 1] === '/') { const n = s.indexOf('\n', i); if (n < 0 || n >= at) return d; i = n; continue; }
if (ch === '/' && s[i + 1] === '*') { const n = s.indexOf('*/', i + 2); if (n < 0 || n >= at) return d; i = n + 1; continue; }
if (ch === '"' || ch === '\'' || ch === '`') { for (i++; i < at && s[i] !== ch; i++) if (s[i] === '\\') i++; continue; }
if (ch === '{' || ch === '[' || ch === '(') d++;
else if (ch === '}' || ch === ']' || ch === ')') d--;
}
return d;
}

View file

@ -396,9 +396,10 @@ export const TOOLS: ToolDef[] = [
description: 'Upload the event card (16:9, 1920x1080 to 3840x2160) and details page (9:16) images of one draft in-app event per locale, size-checked first.',
props: { name: { type: 'string', description: 'Reference name from apple.events' } }, required: ['name'], args: i => opt('name', i.name) },
{ name: 'console_plan', title: 'Console form plan (agent-first)', command: ['console', 'plan'], write: false, network: false,
description: 'Read-only plan for filling a console form that has no API with YOUR OWN browser tool (Claude in Chrome, a built-in browser, Playwright): start URL (deep link when the ids are known), ordered steps (what to click by visible text, what to answer), the answer sheet from the config (each answer certain or ask-owner, with the console\'s exact labels, curly quotes noted), the buttons never to press (Publish, Submit, Create, agreements, help panels), known UI facts per form (Play: material radios and checkboxes take a JS click on the inner [role=radio] / [role=checkbox]), a ready-made in-page helper (inPageLoop: one evaluate call times out around 45 s, so long flows run in the page and you poll window.__log) and how to verify. Forms: the App Store and Play forms of `console fill`, plus agent-only Play pages: play-store-settings (the App category, which the Play API cannot set), play-app-content (every App content card in order with its page and one batch of owner questions) and its cards play-privacy-policy, play-ads, play-sign-in-details, play-advertising-id, play-government-apps, play-financial-features, play-health-apps; and the first production release: play-production-countries (the API cannot set them) and play-production-release (its final Send changes for review is the owner\'s). Drive the page yourself, stop before the final button and tell the owner what to press, then call console_verify.',
props: { form: { type: 'string', enum: CONSOLE_PLAN_FORMS } },
required: ['form'], args: i => [String(i.form), '--json'] },
description: 'Read-only plan for filling a console form that has no API with YOUR OWN browser tool (Claude in Chrome, a built-in browser, Playwright): start URL (deep link when the ids are known), ordered steps (what to click by visible text, what to answer), the answer sheet from the config (each answer certain or ask-owner, with the console\'s exact labels, curly quotes noted), the buttons never to press (Publish, Submit, Create, agreements, help panels), known UI facts per form (Play: material radios and checkboxes take a JS click on the inner [role=radio] / [role=checkbox]), a ready-made in-page helper (inPageLoop: one evaluate call times out around 45 s, so long flows run in the page and you poll window.__log) and how to verify. Forms: the App Store and Play forms of `console fill`, plus agent-only Play pages: play-store-settings (the App category, which the Play API cannot set), play-app-content (every App content card in order with its page and one owner batch; all: true inlines every card) and its cards play-privacy-policy, play-ads, play-sign-in-details, play-advertising-id, play-government-apps, play-financial-features, play-health-apps; and the first production release: play-production-countries (the API cannot set them) and play-production-release (its final Send changes for review is the owner\'s). Drive the page yourself, stop before the final button and tell the owner what to press, then call console_verify.',
props: { form: { type: 'string', enum: CONSOLE_PLAN_FORMS },
all: { type: 'boolean', description: 'play-app-content only: every card\'s steps inline in fix order, ending with the one owner list (tick the IARC Terms before, Send changes for review after)' } },
required: ['form'], args: i => [String(i.form), ...(i.all === true ? ['--all'] : []), '--json'] },
{ name: 'console_verify', title: 'Verify a console form', command: ['console', 'verify'], write: false, network: true,
description: 'Read-only check of a filled console form against the config. Pass the page text you read (App Privacy: the App Privacy page\'s innerText, whose Data Types section shows per type "Used for …", linked and tracking; Play Create app: the JSON of the plan\'s capture snippet; Play content rating / target audience: the summary page text; play-app-content: the JSON of the plan\'s capture snippet; Play store settings and the other App content cards: the page text after Save). apple-app-record and apple-age-rating without text read the App Store Connect API. Returns a per-item match / mismatch table; exit code 1 on a mismatch.',
props: { form: { type: 'string', enum: CONSOLE_PLAN_FORMS },

64
cli/play/app-id.ts Normal file
View file

@ -0,0 +1,64 @@
// The Play Console app id of this app (play.appId), found by package name. Read-only on Play Console.
// storebound play app-id [--json] the id when known, else the agent's steps
// storebound play app-id --from-text <file|-> [--write] [--json] find it in captured app list pages
// Known: play.appId, or the id storebound remembered when a Play form was open (~/.storebound/console-accounts.json).
// Otherwise it is an agent step: the agent opens the developer account's app list (10 apps per page, a new app is not
// on page 1; verified 2026-10-13), searches the package name or walks the pages, runs the capture snippet and pipes
// the captures here; storebound finds the row with the package name and reads the id from its /app/<id>/ link. The
// play.appId change is shown as a dry diff; --write saves it into storebound.config.ts (an upsert). Right after
// Create app, `storebound console verify play-app-record --url <url> --write` takes it from the redirect instead.
// Pure logic in cli/lib/play-app-id.ts.
import { readFileSync } from 'node:fs';
import { basename } from 'node:path';
import type { Ctx } from '../lib/ctx';
import { log, printJson } from '../lib/log';
import { APP_LIST_CAPTURE, appIdChange, appIdDiff, appIdSteps, findPlayAppId, parseAppListPages, writePlayAppId } from '../lib/play-app-id';
import * as rt from '../lib/rt';
export async function run(ctx: Ctx): Promise<void> {
const { config, path } = ctx.loaded;
const args = ctx.args, json = args.has('json');
const pkg = config.play?.packageName;
if (!pkg) throw new Error('play app-id: play.packageName is not set');
const from = args.get('from-text');
const { readAccounts, withAccountIds } = await import('../console/accounts');
const cfg = withAccountIds(config);
const cached = readAccounts().play[pkg];
const dev = cfg.play?.developerId ?? cached?.developerId ?? null;
const file = basename(path || 'storebound.config.ts');
if (from === undefined) {
const known = config.play?.appId ?? cached?.appId ?? null;
const source = config.play?.appId ? 'play.appId' : cached?.appId ? 'remembered from an open Play form (~/.storebound/console-accounts.json)' : null;
const change = known && !config.play?.appId ? appIdChange(config, known) : null;
if (json) return printJson({ packageName: pkg, appId: known, source, steps: known ? [] : appIdSteps(cfg, dev), capture: APP_LIST_CAPTURE, ...(change ? { change } : {}) });
if (known) {
console.log(`${pkg}: Play Console app id ${known} (${source})`);
if (change) console.log(`${appIdDiff(change, file)} (dry: add it with \`storebound play app-id --from-text\` and --write, or put appId: '${known}' into play)`);
return;
}
console.log(`${pkg}: the Play Console app id is not known yet. Find it in the console (read-only), then pipe the captures here:\n`);
appIdSteps(cfg, dev).forEach((s, i) => console.log(` ${i + 1}. ${s}`));
console.log(`\nCapture snippet (one evaluate call in the app list page):\n${APP_LIST_CAPTURE}`);
return;
}
const raw = from === '-' || from === 'true' ? await rt.stdinText() : readFileSync(from, 'utf8');
const pages = parseAppListPages(raw);
const r = findPlayAppId(pages, pkg);
if (!r.found) {
const why = r.ambiguous ? `several rows hold ${pkg} (app ids ${r.ambiguous.join(', ')}): search the exact package name and capture again`
: r.hasNext ? `not on the ${r.pages} captured page(s) and the list has a next page: press "Next page", capture again and pipe every capture (a JSON array or one per line)`
: `not on the ${r.pages} captured page(s), and the list has no next page: is this the developer account that has the app? (search ${pkg} in the list's search box)`;
if (json) printJson({ packageName: pkg, appId: null, ...r, next: why }); else console.log(`${pkg}: ${why}`);
process.exitCode = 1;
return;
}
const change = appIdChange(config, r.appId);
const write = args.has('write') && !args.has('dry');
const wrote = write && path ? writePlayAppId(path, config, r.appId) : false;
if (json) return printJson({ packageName: pkg, appId: r.appId, page: r.page, change, written: wrote });
console.log(`${pkg}: Play Console app id ${r.appId} (page ${r.page} of the captures)`);
console.log(appIdDiff(change, file));
if (!change.changed) return;
if (wrote) log('play', `wrote play.appId '${r.appId}' into ${file}`);
else console.log('dry: run the same command with --write to save it');
}

View file

@ -9,6 +9,10 @@ fails without the fix.
| # | date | lesson | guard |
|---|---|---|---|
| 41 | 2026-10 | Every Play App content card ended in an owner-only Save, so the owner came back once per card (privacy policy, ads, app access, advertising ID, government, financial, health, target audience, content rating, data safety). Saving an App content declaration is not public: it goes to Google only with the next "Send changes for review" in Publishing overview (verified 2026-10-13). | Card Saves are the agent's (`declaration-save`): `declarationSaveAllowed` (exactly Save / Kaydet, a button on an /app-content/<card> page) and `flowAllowed` for content rating and target audience, while `publishingControl` refuses "Send changes for review", "Submit N changes for review", Submit, Publish, "Restart review" everywhere; the plans' `buttons.final` is "Send changes for review"; `console fill` presses the declaration's Save; `console plan play-app-content --all` walks every card and ends with one owner list (`cli/lib/console-forms.ts`, `cli/lib/console-plan.ts`, `cli/lib/console-play-plans.ts`, `cli/console/fill.ts`; `tests/console-play-plans.test.ts`, `tests/console-flow.test.ts`). |
| 40 | 2026-10 | Finding a new app's Play Console app id (`play.appId`) took several steps: the app list shows 10 apps per page and the new app was not on page 1. The id was in the URL Create app redirects to (/developers/<devId>/app/<appId>/app-dashboard) all along. | `console verify play-app-record --url <url>` reads the id from the redirect (or the capture JSON's url), shows the `play.appId` change as a dry diff and `--write` upserts it (`upsertNestedString`); `storebound play app-id` finds it by package name across captured app list pages (search box or pages) as an agent step (`cli/lib/play-app-id.ts`, `cli/play/app-id.ts`; `tests/play-app-id.test.ts`, a modeled 3-page list). |
| 39 | 2026-10 | Play IARC questionnaire: page 1's Next did nothing until the IARC Terms of Use box was ticked, an owner-only agreement, so the agent stopped mid-run and waited for the owner. | The box is in the plan's ONE owner batch before the run with its exact place (`ownerBatch`, printed first; `play-app-content` and `play-content-rating`); the step says not to press Next again and again; `console fill` waits for the tick ("agreement needed on …", `blocksNext`) instead of pressing a Next that does nothing (`tests/console-play-plans.test.ts`, `tests/console-flow.test.ts`). |
| 38 | 2026-10 | App Store Connect's New App dialog: a programmatic value set (a form_input-style set) on the Name and SKU textboxes left the dialog's React state empty (""), while clicking the field and typing worked. | The `apple-app-record` plan says "type, don't set value" (uiFacts and the fill step); `console fill` types every text field key by key and reads it back (`typeText` in `cli/console/fill.ts`); `tests/console-flow.test.ts` (a New App fixture whose state follows key presses only). |
| 37 | 2026-10 | A `scan.ignore` entry whose `match` text was in the evidence (a code line) instead of the finding title accepted nothing and said nothing. `init --profile game` wrote example-looking values into `.storebound/credentials.json` (`YOUR_KEY_ID`, `/absolute/path/to/upload.p12`, `sk_your_revenuecat_v2_secret_key`), which commands read as real credentials. | `scan.ignore[].evidence` matches an evidence line ("file:line text"), `match` stays the title; `scan` (report notes) and `check` (warning) name every entry that accepts no finding (`ignoreMatches` / `applyIgnore` / `unusedIgnores` in `cli/lib/scan/rules.ts`). `templates/credentials.example.json` holds empty strings only; `isPlaceholder` (`cli/lib/credentials.ts`) makes "your-…", `<…>`, "xxx", "/absolute/path/to/…", "example" and the zero Apple Ads client id missing in every credential read; `check` warns, `doctor` shows set / missing / placeholder per key. `tests/game-report.test.ts`. |
| 36 | 2026-10 | Privacy answers derived from the wrong source: `privacy-manifest --propose` wrote an EMPTY NSPrivacyCollectedDataTypes while `appPrivacy.types` listed 5 collected types; `console plan apple-app-privacy` added "Other Usage Data" from dataSafety (PSL_OTHER_APP_ACTIVITY) although `appPrivacy.types` was given; `console plan play-advertising-id` answered "uses advertising ID" Yes because dataSafety declared PSL_DEVICE_ID (a random install id, not the advertising ID). | `--propose` builds NSPrivacyCollectedDataTypes from `appPrivacy.types` (types, linked, tracking, purposes as Apple's manifest keys; NSPrivacyTracking still only with app-level domains, lesson 29) and `check` warns on an empty list against collected types. `appPrivacy.types` is authoritative in the App Privacy sheet (unlisted = not collected, certain); without it, tracking false and no ad / attribution SDK make Advertising Data, Customer Support, Environment Scanning, Hands and Head "not collected" unless a project signal suggests them (`cli/lib/project-signals.ts`). Advertising ID comes from ad / attribution SDKs, the AD_ID permission (source or merged manifest, or its `tools:node="remove"`), ads.appAdsTxt or `console.appContent.advertisingId`; none: No (certain), a conflict: ask (`advertisingIdUse`). `tests/game-report.test.ts`. |
| 35 | 2026-10 | `scan` on a Capacitor game: rule `review-prompt` (3.2.2(x), reward for rating) fired on `const rate = F.genRate(lv, bonus.gen(r));` (a bare "rate" next to "bonus"); every account finding cited `import './buildings/register';` as sign-up evidence; and 5.1.1(v) cleared as soon as `accounts.inApp` named a screen the code did not have. | The incentive signal needs a rate-us / leave-a-review phrase next to a reward, a reward "for rating / reviewing", or a reward granted within a few lines of a system review prompt call (`rewardNearReviewApi`). Sign-in / sign-up / deletion signals skip import, require and export-from lines (`codeLine`), and auth endpoints must be an absolute path, URL or `${base}/...`, never a module path; config-only accounts say "from config (accounts.enabled)". A config claim of in-app deletion without code evidence stays a finding (medium, "claimed by config, not found in code"). `tests/game-report.test.ts` with `tests/fixtures/scan/capacitor-game`. |

View file

@ -1,5 +1,11 @@
$ storebound console plan play-content-rating
Play Console: Content rating (IARC)
Owner batch (everything the owner does, in ONE message, in the order the agent needs it):
1. [before the run] Answer 17 question(s) in ONE batch (askOwner), then write the answers into storebound.config.ts and rerun the plan: Category; Violence; Fear / horror; Sexuality or nudity; Language (profanity); Controlled substances (drugs, alcohol, tobacco); Crude humour; Gambling with real money; Simulated gambling; Users can interact or exchange content; Shares the user's location with other users; Digital purchases; Unrestricted internet access (browser); Miscellaneous: Nazi symbols or references?; Miscellaneous: content about Korean history that may be sensitive?; Miscellaneous: terrorism or extremist content?; Miscellaneous: depicts or encourages crimes?
2. [before the run] Tick the IARC Terms of Use box @ Play Console > the app > Policy and programs > App content > Content ratings > "Start questionnaire" (/app-content/content-rating-iarc-questionnaire), page 1: the IARC Terms of Use checkbox under the Email address and the Category. Tick it, leave the tab open; the agent fills the rest of page 1 and presses Next (Next does nothing until it is ticked).
3. [after the run] Press Send changes for review once every App content card and store setting is done @ Publishing overview (left menu): "Submit N changes for review", then "Send changes for review" in the "Send N changes for review?" dialog (a real click)
start: https://play.google.com/console
then: Pick the developer account if asked (set play.developerId for a deep link)
then: the app (com.example.prismpuddle) in the app list
@ -8,14 +14,14 @@ start: https://play.google.com/console
Steps:
1. Open App content > Content ratings (/app-content/content-rating-overview), then "Start questionnaire": it opens /app-content/content-rating-iarc-questionnaire. [click: "Start questionnaire"]
2. Page 1: type the email address and pick the category (material radio: JS click on the inner [role=radio]). The IARC Terms of Use checkbox is an agreement: never tick it; the owner ticks it (it is in the batch). Then Next. [click: "Next"]
2. Page 1: type the email address and pick the category (material radio: JS click on the inner [role=radio]). The IARC Terms of Use checkbox is an agreement: never tick it. The owner ticked it before the run (owner batch); read its aria-checked. Still unticked: Next does nothing (verified 2026-10-13), so do not press it again and again: say once that the box is waiting for the owner and poll aria-checked every 5 s (up to 15 min). Then Next. [click: "Next"]
3. The questionnaire is ONE long page of sections (Violence, Fear, Sexuality, Gambling, Language, Controlled Substance, Crude Humor, Digital Purchases, Miscellaneous). Answer each section's Yes / No as the sheet says; each Yes reveals follow-ups right below it, so answer top to bottom and re-read the page after every Yes. Run it as one in-page job (plan.inPageLoop) and poll window.__log.
4. Violence follow-ups (after Yes): the humans / non-humans checkboxes, then setting, childlike style, reactions, presentation checkboxes, blood level, historical war setting, innocent characters, fierce sounds.
5. Fear, Sexuality, Gambling, Language, Controlled Substance, Crude Humor follow-ups (after Yes): answer the questions shown as the sheet says (details.<section>.other).
6. Digital Purchases follow-ups (after "purchases of digital goods" Yes): the random items (loot box) question, then trading between players.
7. Miscellaneous: user interaction (after Yes: block, report, moderation, friends only), location sharing, Nazi symbols, Korea history, terrorism, crimes.
8. Press Save (stores the draft; nothing goes to IARC yet), then Next: the Summary shows the rating per authority (PEGI, ESRB, USK, ClassInd, Google Play ...). [click: "Save", "Next"]
9. Summary: stop. Do NOT press its Save (the final button here is "Save", not "Submit"): tell the owner the ratings shown and let them press Save (it applies the IARC ratings).
9. Summary: read the ratings per authority, then press its Save yourself (a declaration save, "Save", not "Submit"; verified 2026-10-13: it stores the rating declaration, which Google gets only with the next "Send changes for review" in Publishing overview, the owner's). Tell the owner the ratings in the final report. [click: "Save"]
10. Verify: read the Summary page as text and pipe it to `storebound console verify play-content-rating --from-text -`.
Answer sheet:
@ -58,16 +64,17 @@ Answer sheet:
[ask] Miscellaneous: depicts or encourages crimes?: (you decide) -> pick "crime" @ Questionnaire: Miscellaneous follow-ups (crime)
Answer from the app's real content; set console.contentRating.details.miscellaneous.crimes to have it filled.
Never press: "IARC Terms of Use box", "Save (Summary page)", "Publish", "Submit", "Create", "Create app", "Send", "Release", "Definitions and Examples", "?", "More information"
Final button (the owner's): Save (Summary page)
Rule: Press only navigation (Next, Back, Get Started, Edit, Set Up, Start / Manage links) and draft saves (Save draft / Save (questionnaire page)). Never press Save (Summary page) or anything that publishes, submits, creates, sends or accepts; stop and tell the owner.
Never press: "IARC Terms of Use box", "Send changes for review", "Publish", "Submit", "Create", "Create app", "Send", "Release", "Definitions and Examples", "?", "More information", "Submit N changes for review", "Restart review"
Final button (the owner's): Send changes for review
Rule: Press only navigation (Next, Back, Get Started, Edit, Set Up, Start / Manage links), draft saves (Save draft / Save (questionnaire page)) and the App content declaration saves (Save (Summary page): Google gets them only with the owner's Send changes for review). Never press Send changes for review or anything that publishes, submits, creates, sends, restarts a review or accepts; stop and tell the owner.
UI facts:
- Verified 2026-10-12 (an agent with a browser tool on a real game):
- App content > Content ratings is /app-content/content-rating-overview; "Start questionnaire" opens /app-content/content-rating-iarc-questionnaire.
- Page 1: email address, the category radio, and an IARC Terms of Use checkbox: an agreement, the owner ticks it, never the agent.
- Verified 2026-10-13: page 1's Next does nothing until the IARC Terms of Use box is ticked. So the box is in the ONE owner batch before the run (the owner ticks it on page 1 first), never a stop in the middle.
- The questionnaire is one long page of sections; each Yes reveals follow-ups (Violence: humans / non-humans checkboxes, then setting, childlike style, reactions, presentation checkboxes, blood level, historical war setting, innocent characters, fierce sounds; Digital Purchases: digital goods, then loot boxes, then player trading; Miscellaneous: user interaction then block / report / moderation / friends-only, location sharing, Nazi symbols, Korea history, terrorism, crimes). Follow-up wording here is storebound's: match by topic and the console's own labels.
- Save on the questionnaire stores it; Next goes to the Summary with per-authority ratings. The Summary's final button is "Save" (not "Submit"): the owner's.
- Save on the questionnaire stores it; Next goes to the Summary with per-authority ratings. The Summary's button is "Save" (not "Submit"). Saving an App content declaration is not public (verified 2026-10-13): Google gets it only with the next "Send changes for review" in Publishing overview (the owner's), so the agent presses this Save.
- Example result (the game of that run): PEGI 7, ESRB E10+, USK 12, ClassInd 18 (paid random items alone made it 18), Google Play 7+.
- The email address field gets the full app.contactEmail from the config (printed masked here).
- Play Console radios and checkboxes are <material-radio> / <material-checkbox>: computer-tool clicks and setting the native input (form_input) can leave the Angular model unchanged. Click the inner [role=radio] / [role=checkbox] (or the <input> itself) with a JS el.click(), then read aria-checked (or input.checked). Their labels sit in sibling nodes, not in an aria-label: walk up from the control to the nearest parent whose innerText is not empty and match by "starts with" (verified 2026-10-12).
@ -77,9 +84,11 @@ Driving:
- Use your own browser tool (Claude in Chrome, a built-in browser, Playwright). Read the page as text or an accessibility tree first; click by visible text; adapt when the page differs from these steps (the console changes without notice), but never press anything in buttons.neverPress.
- Long flows run INSIDE the page: one Runtime.evaluate / javascript call times out at about 45 s on big Play pages (the IARC questionnaire, App content), so never loop over many controls in one call. Paste plan.inPageLoop once per page (one short call; it defines window.__sb), start the job with window.__sb.run([{ role: "radio", label: "No", within: "<question text>" }, { role: "checkbox", label: "<box label>", want: true }, ...]) (it returns at once), then poll window.__log.slice(-20) and window.__done in short calls every 2-5 s until the log says done. The helper only picks radios and ticks boxes (never agreements); press Next / Save yourself afterwards, by their visible text.
- Wait for each step to render (poll for the next heading or input, up to ~10 s) before acting; never act on a step you have not read.
- Text fields: click the field and TYPE the text (key presses), then read the value back. Never set it programmatically (form_input, el.value = …): App Store Connect's New App kept its Name and SKU empty after a value set (verified 2026-10-13).
- Owner steps: hand the owner plan.ownerBatch in ONE message before you start (the "before" items: answers, the IARC Terms of Use box) and run without stopping until the "after" items (the final button).
- An answer with status ask-owner: ask the owner in chat before filling it (AGENTS.md rule 5), or leave it empty and tell the owner. Never guess.
- Stop before the final button (buttons.final) and tell the owner exactly what to press; then run the verify command.
- Never sign in, type a password, accept an agreement or tick a declaration: the owner does that in the browser.
- Never sign in (never type the owner's own password), accept an agreement or tick a declaration: the owner does that in the browser. The one password you type is the App Review demo account from .storebound/credentials.json (reviewDemoAccount) on Play's Sign in details card, never printed.
In-page helper (plan.inPageLoop: paste it once per page in one short call, then window.__sb.run([...]) and poll window.__log):
(() => {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

After

Width:  |  Height:  |  Size: 119 KiB

Before After
Before After

View file

@ -335,21 +335,24 @@ Example App 1.0.0
<h3 class="sub-h">The consoles, too</h3>
<div class="ship">
<figure class="term term-real" aria-label="storebound console plan play-content-rating: start URL, steps, the buttons never to press and the owner&#x27;s final button">
<figure class="term term-real" aria-label="storebound console plan play-content-rating: the owner batch, start URL, steps, the buttons never to press and the owner&#x27;s final button">
<div class="term-bar" aria-hidden="true"><span></span><span></span><span></span><em>the agent drives the console</em></div>
<pre><code><span class="p">$</span> storebound console plan play-content-rating
Play Console: Content rating (IARC)
Owner batch (everything the owner does, in ONE message, in the order the agent needs it):
1. [before the run] Answer 17 question(s) in ONE batch (askOwner), then write the answers into storebound.config.ts and rerun the plan: Category; V…
2. [before the run] Tick the IARC Terms of Use box @ Play Console &gt; the app &gt; Policy and programs &gt; App content &gt; Content ratings &gt; "Start questio…
3. [after the run] Press Send changes for review once every App content card and store setting is done @ Publishing overview (left menu): "Submit…
start: https://play.google.com/console
then: Pick the developer account if asked (set play.developerId for a deep link)
then: the app (com.example.prismpuddle) in the app list
then: Policy and programs &gt; App content
then: Content rating &gt; Start (or Manage)
Steps:
1. Open App content &gt; Content ratings (/app-content/content-rating-overview), then "Start questionnaire": it opens /app-content/content-rating-iarc-questionnaire. [click: "Start questionnaire"]
2. Page 1: type the email address and pick the category (material radio: JS click on the inner [role=radio]). The IARC Terms of Use checkbox is an agreement: never tick it; the owner ticks it (it is in the batch). Then Next. [click: "Next"]
9. Summary: stop. Do NOT press its Save (the final button here is "Save", not "Submit"): tell the owner the ratings shown and let them press Save (it applies the IARC ratings).
Never press: "IARC Terms of Use box", "Save (Summary page)", "Publish", "Submit", "Create", "Create app", "Send", "Release", "Definitions and Examples", "?", "More information"
Final button (the owner's): Save (Summary page)
2. Page 1: type the email address and pick the category (material radio: JS click on the inner [role=radio]). The IARC Terms of Use checkbox is an…
9. Summary: read the ratings per authority, then press its Save yourself (a declaration save, "Save", not "Submit"; verified 2026-10-13: it stores…
Never press: "IARC Terms of Use box", "Send changes for review", "Publish", "Submit", "Create", "Create app", "Send", "Release", "Definitions and Examples", "?", "More information", "Submit N changes for review", "Restart review"
Final button (the owner's): Send changes for review
<span class="c">… the answer sheet, the in-page helper and how to verify</span></code></pre>
<figcaption class="term-cap">Real output, <a href="img/term/console-plan.txt">full text</a></figcaption>
</figure>

View file

@ -8,8 +8,9 @@ description: Fill App Store Connect and Google Play Console forms that have no A
CLI: `storebound` (or `bunx storebound` in a project that has it, or `bun <toolchain>/cli/index.ts`).
**Do it, don't ask** (first rule in AGENTS.md / `docs/agents-core.md`): you open the console, navigate, fill the form,
save drafts and verify; the owner only signs in once and presses the final button. Ask the owner's `ask-owner`
answers in one batch before you start, never one at a time on the page.
save drafts and Play App content declarations, and verify; the owner only signs in once, does the plan's
`ownerBatch` "before" steps (answers, the IARC Terms of Use box) and presses the final button at the end. Hand the
owner the whole `ownerBatch` in ONE message before you start, never one item at a time on the page.
**Read this to the owner first.** Console pages change without notice. You (the agent) fill the form from storebound's
answer sheet and the owner reviews every field and presses the final button. Nobody types a password or touches a
@ -23,26 +24,32 @@ the UI shows. This is the primary mode.
1. `storebound console plan <form> --json` (MCP: `console_plan`; read-only, offline; `--lookup` asks the App Store
Connect API for the app id). It returns:
- `ownerBatch` (printed first): EVERY owner step of the plan in ONE list, in the order you need them: `before`
(open answers to write into the config, the IARC Terms of Use box with its exact place, the demo account when it
is not in `.storebound/credentials.json`) and `after` (the final button, e.g. "Send changes for review");
- `start`: the URL (a deep link when `apple.appId`, `play.developerId` or `play.appId` is known, or the ids
storebound remembered) and how to reach the form otherwise;
- `steps`: plain language plus what to `click` (by visible text) and which `answers` go on each step;
- `answers`: every answer with `status` (`certain`, `ask-owner`, `owner-check`, `agreement`), the console's own
`label` (curly quotes as shown, `curlyQuotes: true`), `action` (tick / untick / pick / type / choose) and `where`;
- `buttons.neverPress` (Publish, Submit, Create, Create app, Send, Release, "Definitions and Examples", "?", the
final Save of a form ...), `buttons.final` and `buttons.allowed` (navigation and draft saves);
- `buttons.neverPress` (Publish, Submit, Create, Create app, Send, Release, "Send changes for review", "Restart
review", "Definitions and Examples", "?" ...), `buttons.final` and `buttons.allowed` (navigation, draft saves and
Play App content declaration saves);
- `uiFacts` (verified facts per form, see below), `driving`, `inPageLoop` (a ready-made in-page helper, see 5) and
`verify` (the command and what to capture).
Forms: `apple-app-record`, `apple-age-rating`, `apple-app-privacy`, `play-app-record`, `play-content-rating`,
`play-target-audience` (these also have the scripted `console fill`), and agent-only Play pages:
`play-store-settings` (the App category: the Play API has no category field, so `play listing` leaves it "Not
selected"), `play-app-content` (every App content card in order, its page, its plan and ONE batch of owner
questions), its cards `play-privacy-policy`, `play-ads`, `play-sign-in-details`, `play-advertising-id`,
selected"), `play-app-content` (every App content card in order, its page, its plan and ONE owner batch;
`--all` / MCP `all: true` puts every card's steps inline in fix order and ends with the one owner list: tick the
IARC Terms of Use box (before), Send changes for review (after)), its cards `play-privacy-policy`, `play-ads`, `play-sign-in-details`, `play-advertising-id`,
`play-government-apps`, `play-financial-features`, `play-health-apps`, and the first production release:
`play-production-countries` and `play-production-release`. `storebound console plan` lists them.
2. Collect the `ask-owner` answers of every form you will fill in ONE question to the owner before you start (AGENTS.md
rule 5) and write them into the config, so the plan has them. Never guess them; leave them empty if the owner
wants to answer in the page.
2. Send the owner the `ownerBatch` of every form you will fill in ONE message before you start (AGENTS.md rule 5): the
`ask-owner` answers (write them into the config, so the plan has them; never guess them) and the `before` steps
(Play content rating: tick the IARC Terms of Use box on the questionnaire's page 1 first; page 1's Next does
nothing until it is ticked). Then run without stopping until the `after` steps.
3. Open `start.url` in your browser tool. If a sign-in page shows, the owner signs in; you wait.
4. Follow `steps`. Read the page (text or accessibility tree) before each action, click by visible text, set each
answer as the sheet says, wait for the next step to render. If the page differs from the plan, adapt (the console
@ -57,13 +64,25 @@ the UI shows. This is the primary mode.
The helper finds each control by its label (aria-label, else the nearest parent with non-empty innerText), clicks
the inner `[role=radio]` / `[role=checkbox]` of a material control and reads it back; it never clicks buttons and
refuses agreements. Press Next / Save yourself afterwards. Never click past a step you have not read.
6. Stop before `buttons.final` and tell the owner exactly what to press (App Privacy: review the summary, press
Text fields: click and TYPE (key presses); never set `value` programmatically (App Store Connect's New App kept an
empty Name / SKU after a `form_input` set, verified 2026-10-13).
6. Play App content cards (Privacy policy, Ads, Sign in details, Advertising ID, Government apps, Financial features,
Health apps, Target audience, the content rating questionnaire and its Summary, Data safety): press each card's
**Save yourself**. Saving a declaration is not public: Google gets it only with the next "Send changes for review"
in Publishing overview, which stays the owner's (verified 2026-10-13). The Sign in details demo account: type it
from `.storebound/credentials.json` (`reviewDemoAccount`) without echoing it; only when it is not there does the
owner type it.
7. Stop before `buttons.final` and tell the owner exactly what to press (App Privacy: review the summary, press
Publish, then Publish in the "Publish Your App Privacy Responses?" dialog; Play Create app: tick the two
declarations, press Create app; content rating: the Summary's Save (not Submit); target audience and the other
App content cards: Save; Play production: "Submit N changes for review", then "Send changes for review"; New App:
declarations, press Create app; Play App content, content rating, target audience, Store settings and production:
"Submit N changes for review", then "Send changes for review" in Publishing overview, once at the end; New App:
Create).
7. `storebound console verify <form> --from-text -` (MCP: `console_verify` with `text`) with what `verify.capture`
says: App Privacy the page's `innerText`, Play Create app the JSON of the capture snippet. Exit 1 / mismatches:
8. `storebound console verify <form> --from-text -` (MCP: `console_verify` with `text`) with what `verify.capture`
says: App Privacy the page's `innerText`, Play Create app the JSON of the capture snippet; after Create app,
`storebound console verify play-app-record --url "<location.href>"` reads the new app's id from the redirect
(`/app/<appId>/app-dashboard`), shows the `play.appId` change as a dry diff, and `--write` saves it (an upsert).
Missed the redirect: `storebound play app-id` (the app list is paginated, 10 per page, a new app is not on page 1:
search the package name or walk the pages, pipe the captures in). Exit 1 / mismatches:
fix them in the page (or the config), verify again. `apple-app-record` and `apple-age-rating` verify from the API
without text.
@ -85,7 +104,12 @@ the UI shows. This is the primary mode.
radios did nothing and `form_input` on the native inputs did not update the form ("Select app or game" stayed):
click the inner `[role=radio]` with a JS click, then check `aria-checked="true"` and that no "Select …" hint is
left. Text inputs set by `form_input` are fine. After Create app the app lives at
`/console/u/0/developers/<developerId>/app/<appId>/app-dashboard`: put the id in `play.appId`.
`/console/u/0/developers/<developerId>/app/<appId>/app-dashboard`: `console verify play-app-record --url <url>
--write` puts the id in `play.appId` (2026-10-13: the app list shows 10 apps per page and the new app was not on
page 1, so take the id from the redirect).
- **App Store Connect New App** (2026-10-13): a programmatic value set (`form_input`, `el.value = …`) on the Name and
SKU textboxes left the dialog's React state empty (""); clicking the field and typing worked. Type, don't set
value. `console fill` types every text field the same way.
- **Play material radios and checkboxes** (Create app, App content, IARC, 2026-10-12): `<material-radio>` /
`<material-checkbox>`; click the inner `[role=radio]` / `[role=checkbox]` (or the `<input>`) with a JS click and read
`aria-checked` back. Their labels sit in sibling nodes: read the nearest parent with non-empty `innerText`.
@ -96,15 +120,17 @@ the UI shows. This is the primary mode.
government-apps, finance (2 steps; the last has "My app doesn't provide any financial features"; Next, Save),
health (2 steps; the last step's "Other" box was the no-features answer on that run; Next, Save),
target-audience-content (age checkboxes; with 13+ only it went straight to the Summary, no appeal question; Save),
content-rating-overview > "Start questionnaire" > content-rating-iarc-questionnaire.
content-rating-overview > "Start questionnaire" > content-rating-iarc-questionnaire. Each card's Save is yours
(2026-10-13: nothing goes to Google before the owner's "Send changes for review").
- **IARC questionnaire** (2026-10-12): page 1 has the email, the category radio and an IARC Terms of Use checkbox (an
agreement: the owner ticks it, never you). Then ONE long page of sections; each Yes reveals follow-ups (Violence:
agreement: the owner ticks it, never you; page 1's Next does nothing until it is ticked, 2026-10-13, so it is in the
owner batch before the run). Then ONE long page of sections; each Yes reveals follow-ups (Violence:
humans / non-humans, setting, childlike style, reactions, presentation, blood level, historical war, innocent
characters, fierce sounds; Digital Purchases: digital goods, then loot boxes, then player trading; Miscellaneous:
user interaction then block / report / moderation / friends-only, location sharing, Nazi symbols, Korea history,
terrorism, crimes). The answers come from `console.contentRating.answers` and `.details`, `products[].randomRewards`
and `social`. Save, then Next to the Summary with per-authority ratings; its final button is **Save**, not Submit
(the owner's). Example: PEGI 7, ESRB E10+, USK 12, ClassInd 18 (paid random items), Google Play 7+.
and `social`. Save, then Next to the Summary with per-authority ratings; its button is **Save**, not Submit: yours
(a declaration save). Example: PEGI 7, ESRB E10+, USK 12, ClassInd 18 (paid random items), Google Play 7+.
- **App Privacy Publish** (2026-10-12): a `<dialog>` "Publish Your App Privacy Responses?" with Cancel / Publish (the
owner's); afterwards the page shows "Published a few seconds ago by …", which `console verify` reads.
- **Play first production release** (2026-10-12): countries are console-only (`edits.countryAvailability` is
@ -235,11 +261,14 @@ fix. It is not available over MCP: run it from your shell (or the owner uses the
6. Multi-page forms: storebound fills a page and clicks its Next itself (App Privacy: Get Started or Edit next to
Data Types, Next, the data types' Save, then "Set Up <type>" for each collected type in the Data Types section:
purposes > Next > linked > Next > two information pages > Next > Next > tracking > Save; a dialog step it does not know: a debug dump and
`paused on "<type>"` until the owner answered it; content rating: Save draft, then Next). It stops on a page only when an answer there is uncertain: it logs
`paused on "<type>"` until the owner answered it; content rating: Save draft, then Next). It stops on a page only when an answer there is uncertain, or (content rating page 1) the IARC Terms of Use box is not ticked yet (`agreement needed on …`; Next does nothing until the owner ticks it): it logs
`answer needed on "<page>": <the answers>`, leaves them outlined, and goes on by itself once the owner has set
each one (a box that should stay empty: click it twice). It never clicks Next past an open question.
7. On the last page storebound prints "review every field and press <Save | Submit | Publish | Create> yourself"
(App Privacy: "review the summary, then press Publish yourself"). The
7. On the last page storebound prints "review every field and press <Submit | Publish | Create> yourself"
(App Privacy: "review the summary, then press Publish yourself"). Content rating and target audience: storebound
presses the declaration's Save itself and says "storebound pressed Save … press Send changes for review in
Publishing overview yourself" (once every App content card is done). Text fields are typed key by key, never set
(New App kept an empty Name / SKU after a value set). The
command ends when the owner closes the window (`--json` then prints the result with `filled`, `highlighted`,
`fallback`, `stoppedAt`).
8. Nothing waits silently. While storebound waits it logs `still waiting for <what>, <time>` once a minute. When the
@ -287,8 +316,9 @@ content", "Create and set up your app"):
| New App (App Store) | + and New App (Cancel only to leave a team without the bundle id) | **Create**: creates the app record for good |
| Age rating (guide only) | nothing (`storebound apple appinfo` sets it by API) | Set Up / Next / Done, if the owner wants to look |
| Create app (Play) | Create app on the app list (opens the form); Check availability next to the package name (read-only) | the two **declarations** (agreements, never ticked) and **Create app** in the form: creates the app |
| Content rating (Play) | Start; **Save draft** then Next on each page (a draft: IARC gets nothing before the Summary's Save; the owner agreed) | the **IARC Terms of Use** box (an agreement) and **Save** on the Summary (not "Submit", verified 2026-10-12): sends the questionnaire to IARC |
| Target audience (Play) | Start / Manage, Next between the pages | **Save** on the last page: stores the declaration, which Google then reviews |
| Content rating (Play) | Start; **Save draft** then Next on each page (a draft); **Save** on the Summary (not "Submit"; a declaration save: Google gets it only with the next Send changes for review, verified 2026-10-13) | the **IARC Terms of Use** box (an agreement; ticked by the owner before the run) and **Send changes for review** in Publishing overview |
| Target audience (Play) | Start / Manage, Next between the pages, **Save** on the last page (a declaration save) | **Send changes for review** in Publishing overview: sends the declarations to Google |
| Other App content cards (agent-only plans) | Start <Name> declaration, Next, **Save** (a declaration save) | **Send changes for review**; the demo account only when it is not in `.storebound/credentials.json` |
Mid-way storebound stops only for an uncertain answer (see 6). A navigation button is never the owner's: when the
console page changed so that storebound cannot find it, it says so and you click it with your browser tool.
@ -297,9 +327,10 @@ console page changed so that storebound cannot find it, it says so and you click
Every `[check]` answer, linked-to-identity and tracking for each App Privacy data type (unless `appPrivacy.types`
answers them), Developer's Advertising or Marketing, the IARC answers, the target
ages, Play's Free or paid and its declarations, the IARC Terms of Use box, the Sign in details username and password,
and pressing the final button (App Privacy Publish, the IARC Summary's Save, each App content card's Save, New App
Create, Play Create app, Play's Send changes for review).
ages, Play's Free or paid and its declarations, the IARC Terms of Use box (before your run), the Sign in details
username and password only when they are not in `.storebound/credentials.json`, and pressing the final button (App
Privacy Publish, New App Create, Play Create app, Play's Send changes for review). The App content cards' Save and
the IARC Summary's Save are yours (nothing goes to Google before Send changes for review).
## Out of scope

View file

@ -24,8 +24,10 @@ products) and answers the compliance facts no file proves, all in one batch at t
App or Game and Free or Paid (material radios: click the inner `[role=radio]`, then no "Select …" hint may be
left), and the declarations, which the owner ticks before pressing Create app. Free can never become Paid.
`storebound console verify play-app-record` checks the form first. Afterwards the app lives at
`/console/u/0/developers/<developerId>/app/<appId>/app-dashboard`: put that app id in `play.appId` (the App
content forms deep-link from it).
`/console/u/0/developers/<developerId>/app/<appId>/app-dashboard`: run `storebound console verify play-app-record
--url "<that URL>"` (dry diff of `play.appId`), then the same with `--write` to save it (the App content forms
deep-link from it). Missed the redirect: `storebound play app-id` (the app list shows 10 per page and a new app is
not on page 1; search the package name, pipe the captures in).
**Owner step after every Create app:** a service account's app-level permissions do NOT cover apps created later.
Granting them is the owner's (a permission grant; agents in auto mode are blocked from it too): give the exact
path in the same message as the Create app button, Users and permissions > the service account > Add app > the new
@ -71,11 +73,15 @@ products) and answers the compliance facts no file proves, all in one batch at t
content, Data safety, Advertising ID, Government apps, Financial features, Health apps), each card's page and
start button (aria-label "Start <Name> declaration"), its own plan (`console plan play-privacy-policy`,
`play-ads`, `play-sign-in-details`, `play-content-rating`, `play-target-audience`, `play-advertising-id`,
`play-government-apps`, `play-financial-features`, `play-health-apps`) and ONE batch of owner questions
(`askOwner`): ask them at the start, write the answers into the config (`console.contentRating.details`,
`console.appContent`, `social`, `products[].randomRewards`), rerun the plan. Data safety goes through the API
(step 9). The IARC Terms of Use box and each card's final Save are the owner's (`store-declarations` skill).
Behind a sign-in: the owner types the demo account in Sign in details (`review-access`); with accounts: the
`play-government-apps`, `play-financial-features`, `play-health-apps`) and ONE owner batch (`ownerBatch`,
printed first): the open answers (write them into the config: `console.contentRating.details`,
`console.appContent`, `social`, `products[].randomRewards`; rerun the plan) and, before you start, the IARC Terms
of Use box on the questionnaire's page 1 (an agreement; page 1's Next does nothing until it is ticked). Then
`console plan play-app-content --all` walks every card in order without a stop: you press each card's **Save**
(a declaration save: Google gets nothing before "Send changes for review", the owner's, verified 2026-10-13).
Data safety goes through the API (step 9). Behind a sign-in: you type the demo account from
`.storebound/credentials.json` (`reviewDemoAccount`, never printed; the owner only when it is not there;
`review-access`); with accounts: the
delete-account URL (`account-deletion`); subscriptions: `storebound play subscriptions` (`store-subscriptions`).
11. **UI · Store settings (App category).** `play listing` cannot set it (the Play API has no category field), so a
new app shows "Not selected": set `play.category` (Play's name, e.g. "Strategy"; `storebound check` warns

View file

@ -7,8 +7,9 @@ description: Answer the store compliance forms for a mobile app or game - Google
**Do it, don't ask** (first rule in AGENTS.md): you fill these forms yourself (API, or the console with your browser
tool from `storebound console plan`). Ask the owner only the answers no code or config proves, all in one batch at
the start, and save them in the config; the owner presses only App Privacy Publish and the final Submit / Save of a
console questionnaire.
the start, and save them in the config; the owner presses only App Privacy Publish, Play's "Send changes for review"
(Publishing overview) and a console form's final Submit / Create, and ticks the IARC Terms of Use box before you
start. Saving a Play App content card is yours (it is not public before Send changes for review).
`storebound scan` proposes the Data safety and App Privacy answers from the detected SDKs and code (each item "detected (file:line)" or "ask owner") as a diff against `dataSafety` / `appPrivacy`; `--write` adds only the detected ones after the owner agrees.
@ -29,16 +30,22 @@ CLI: `storebound` (or `bunx storebound` in a project that has it, or `bun <toolc
3. **Play App content (UI, you drive it).** `storebound console plan play-app-content --json` lists every card of
`/app-content/overview` in order (Privacy policy, Ads, Sign in details, Content ratings, Target audience and
content, Data safety, Advertising ID, Government apps, Financial features, Health apps) with its page, start
button (aria-label "Start <Name> declaration") and its own plan, plus ONE batch (`askOwner`) of what the config
does not prove. Ask that batch at the start and write the answers into the config:
button (aria-label "Start <Name> declaration") and its own plan, plus ONE owner batch (`ownerBatch`, printed
first) of what the config does not prove and what the owner does. Hand it over at the start and write the answers
into the config:
- `console.contentRating.answers` and `.details` (the IARC follow-ups each Yes reveals: violence details,
`other` for fear / sexuality / gambling / language / controlled substances / crude humor, player trading, the
user interaction follow-ups, the Miscellaneous questions: Nazi symbols, Korea history, terrorism, crimes);
- `products[].randomRewards` (loot boxes: proven per product; ClassInd rated such a game 18), `social` (chat,
block, report, moderation, friends only, location sharing);
- `console.appContent` (government, financialFeatures, healthFeatures, advertisingId), `reviewAccess`.
The IARC Terms of Use box is an agreement (the owner ticks it). Each card's final Save, and the IARC Summary's Save
(not "Submit"), are the owner's, or yours after the owner's clear yes in chat.
The IARC Terms of Use box is an agreement: the owner ticks it on the questionnaire's page 1 BEFORE your run (it is
in the owner batch; page 1's Next does nothing until it is ticked, verified 2026-10-13). Each card's Save and the
IARC Summary's Save (not "Submit") are yours: a saved declaration goes to Google only with the next "Send changes
for review" in Publishing overview, the owner's one button at the end. `console plan play-app-content --all` walks
every card in order and ends with that owner list. The Sign in details demo account: type it from
`.storebound/credentials.json` (`reviewDemoAccount`) without printing it; the owner types it only when it is kept
out of there.
4. **Apple age rating (API).** `storebound apple appinfo` (all NONE except your overrides, e.g. `advertising: true`).
5. **Apple App Privacy (UI only, you drive it: `storebound console plan apple-app-privacy`).** App Store Connect >
the app > App Privacy > Get Started / Edit, from `store/app-privacy.md`; per data type: purposes > linked to

View file

@ -361,8 +361,9 @@ that fails without the fix (see CONTRIBUTING.md).
`social` and `products[].randomRewards` from ONE batch of owner answers (`storebound console plan play-app-content`
lists them), then drive `console plan play-store-settings`, `play-app-content` and its cards,
`play-production-countries` and `play-production-release`. Click the inner `[role=radio]` / `[role=checkbox]`;
run long flows with the plan's `inPageLoop` and poll `window.__log`. Never tick the IARC Terms of Use box; the
Summary's final button is "Save" (the owner's). "Send changes for review" is the owner's real click; managed
run long flows with the plan's `inPageLoop` and poll `window.__log`. Never tick the IARC Terms of Use box (the
owner ticks it before the run: lesson 39); the Summary's button is "Save", and like every App content card Save it
is yours (lesson 41). "Send changes for review" is the owner's real click; managed
publishing is off by default, so approved means live (`play.managedPublishing`).
- **Guard:** the plans above, `check` (category warning, open App content answers), `validate` (category reminder,
"production has 0 countries"), `play status` / `play release` notes; `tests/console-play-plans.test.ts`.
@ -511,3 +512,40 @@ that fails without the fix (see CONTRIBUTING.md).
- **Guard:** `scan` (notes) and `check` (warnings) name every entry that accepts no finding; the credentials template
is empty strings; "your-…", `<…>`, "xxx", "/absolute/path/to/…" and "example" values count as missing everywhere
(`check` warns, `doctor` lists them). `tests/game-report.test.ts`.
## 38. New App: Name and SKU empty after a value set
- **Symptom:** App Store Connect's New App dialog showed the Name and SKU, yet the dialog's state was empty ("").
- **Cause:** the dialog is React: a programmatic value set (`form_input`, `el.value = …`) changes the input, not the
component state; clicking the field and typing works (verified 2026-10-13).
- **Do:** type, don't set value: click the field, type the text key by key, read it back.
- **Guard:** the `apple-app-record` plan (uiFacts, the fill step); `console fill` types every text field (`typeText`);
`tests/console-flow.test.ts`.
## 39. IARC page 1: Next does nothing until the Terms box is ticked
- **Symptom:** on the content rating questionnaire's page 1 Next did nothing, and the agent waited mid-run for the
owner.
- **Cause:** the IARC Terms of Use checkbox gates Next, and it is an agreement only the owner ticks.
- **Do:** hand the owner the plan's `ownerBatch` in ONE message before the run: it has "Tick the IARC Terms of Use
box" with the exact place (Content ratings > Start questionnaire, page 1). Never tick it yourself; if it is still
unticked, say so once and poll it, do not press Next again and again.
- **Guard:** `ownerBatch` in `console plan play-content-rating` / `play-app-content` (printed first); `console fill`
waits for the tick ("agreement needed on …"); `tests/console-play-plans.test.ts`, `tests/console-flow.test.ts`.
## 40. Finding play.appId in a paginated app list
- **Symptom:** finding a new app's Play Console app id took several steps: 10 apps per page, the new app not on page 1.
- **Cause:** the Play API does not return the id; it is only in console URLs.
- **Do:** after Create app read `location.href` (`/app/<appId>/app-dashboard`) and run `storebound console verify
play-app-record --url "<url>"` (dry diff), then `--write` (an upsert of `play.appId`). Missed it: `storebound play
app-id` (search the package name in the list or walk the pages, pipe the captures in), then `--write`.
- **Guard:** `playAppIdInText`, `findPlayAppId`, `upsertNestedString`; `tests/play-app-id.test.ts`.
## 41. App content card Saves are the agent's
- **Symptom:** every Play App content card ended in an owner-only Save, so the owner came back once per card.
- **Cause:** a saved declaration was treated as public. It is not: Google gets it only with the next "Send changes
for review" in Publishing overview (verified 2026-10-13).
- **Do:** press each card's Save yourself (privacy policy, ads, app access, advertising ID, government, financial,
health, target audience, the content rating questionnaire and Summary, data safety). The owner keeps: the IARC
Terms box (before), typing the demo account only when it is not in `.storebound/credentials.json` (otherwise you
type it, never printed), and "Send changes for review" / Submit / Publish / "Restart review" (after).
`storebound console plan play-app-content --all` walks every card and ends with that one owner list.
- **Guard:** `declarationSaveAllowed`, `publishingControl`, `flowAllowed` (`cli/lib/console-forms.ts`), the plans'
`buttons.final` "Send changes for review", `console fill`'s declaration Save; `tests/console-play-plans.test.ts`.

View file

@ -160,7 +160,8 @@ describe('--dry --json', () => {
expect(Object.keys(r)).toEqual(['form', 'store', 'title', 'url', 'answers', 'uncertain', 'guidance']);
expect(r).toMatchObject({ form: 'play-target-audience', store: 'play', uncertain: 1 });
expect(r.answers[0]).toMatchObject({ id: 'age:5-under', certain: true, source: 'console.targetAudience.ages' });
expect(r.guidance.at(-1)).toContain('press Save yourself');
expect(r.guidance.at(-1)).toContain('storebound presses the declaration\'s Save itself');
expect(r.guidance.at(-1)).toContain('press Send changes for review in Publishing overview yourself');
await expect(run({ loaded: { config, root: '/tmp/none', path: '', resolve: p => p }, args: parseArgs(['play-target-audience', '--store', 'apple', '--dry']) })).rejects.toThrow('play form');
});
});

View file

@ -117,7 +117,7 @@ describe('button classes (FORM_BUTTONS, flowClass, flowAllowed)', () => {
expect(setUpAllowed('Set Up Coarse Location', { ...w, privacyPage: false })).toBe(false);
expect(setUpTexts('Purchase History')).toContain('Set Up Purchases');
});
test('other forms: Next is navigation; the content rating\'s Save draft is clicked; Save, Submit, Create, Done stay the owner\'s', () => {
test('other forms: Next is navigation; the content rating\'s Save draft and the Play declarations\' Save are clicked; Submit, Create, Done, Send changes for review stay the owner\'s', () => {
const w = at({ privacyPage: false });
for (const f of ['play-content-rating', 'play-target-audience', 'apple-app-record', 'play-app-record'] as const) {
expect(flowAllowed(f, 'Next', 'button', w)).toBe(true);
@ -125,18 +125,25 @@ describe('button classes (FORM_BUTTONS, flowClass, flowAllowed)', () => {
expect(flowAllowed(f, 'Get Started', 'button', w)).toBe(false);
expect(flowAllowed(f, 'Save draft', 'button', w)).toBe(f === 'play-content-rating');
expect(flowAllowed(f, 'Taslağı kaydet', 'button', w)).toBe(f === 'play-content-rating');
for (const n of ['Save', 'Submit', 'Create', 'Create app', 'Done', 'Start rollout', 'Publish']) expect(flowAllowed(f, n, 'button', w)).toBe(false);
// an App content declaration's Save goes to Google only with Send changes for review (verified 2026-10-13)
expect(flowAllowed(f, 'Save', 'button', w)).toBe(f === 'play-content-rating' || f === 'play-target-audience');
for (const n of ['Submit', 'Create', 'Create app', 'Done', 'Start rollout', 'Publish', 'Send changes for review', 'Submit 3 changes for review', 'Restart review']) expect(flowAllowed(f, n, 'button', w)).toBe(false);
}
expect(flowClass('play-content-rating', 'Save draft', 'button', w)).toBe('draft-save');
expect(flowClass('play-target-audience', 'Save', 'button', w)).toBe('final-submit');
expect(flowClass('play-target-audience', 'Save', 'button', w)).toBe('declaration-save');
expect(flowClass('play-content-rating', 'Save', 'button', w)).toBe('declaration-save');
expect(flowClass('apple-app-record', 'Save', 'button', w)).toBe('final-submit');
expect(flowClass('play-content-rating', 'Submit', 'button', w)).toBe('final-submit');
expect(flowAllowed('play-target-audience', 'Save', 'button', at({ privacyPage: false, inDialog: true }))).toBe(false);
});
test('every form has its buttons classified; only navigation and the agreed draft saves (App Privacy, content rating) are automatic', () => {
test('every form has its buttons classified; only navigation, the agreed draft saves (App Privacy, content rating) and the Play declaration saves are automatic', () => {
for (const [f, rules] of Object.entries(FORM_BUTTONS)) {
expect(rules.length).toBeGreaterThan(0);
for (const r of rules) if (r.auto) expect(r.class === 'navigation' || (['apple-app-privacy', 'play-content-rating'].includes(f) && r.class === 'draft-save')).toBe(true);
for (const r of rules) if (r.auto) expect(r.class === 'navigation' || (['apple-app-privacy', 'play-content-rating'].includes(f) && r.class === 'draft-save')
|| (['play-content-rating', 'play-target-audience'].includes(f) && r.class === 'declaration-save')).toBe(true);
expect(rules.some(r => r.class === 'final-submit' && !r.auto)).toBe(true);
}
for (const f of ['play-content-rating', 'play-target-audience'] as const) expect(FORM_BUTTONS[f].some(r => r.button === 'Send changes for review' && r.class === 'final-submit' && !r.auto)).toBe(true);
});
});
@ -330,10 +337,16 @@ describe.skipIf(!browser)('App Privacy: each data type set up from the real page
});
describe.skipIf(!browser)('the other forms: navigation by itself, final buttons and draft saves left to the owner', () => {
scope.concurrent('content rating: Save draft and Next by itself on each page, a stop at the uncertain questions until answered, the Summary; its Save never clicked', async () => {
scope.concurrent('content rating: waits for the owner\'s IARC Terms tick, Save draft and Next by itself on each page, a stop at the uncertain questions until answered, then the Summary\'s Save (a declaration save)', async () => {
const b = await local();
const log = logLines();
const run = scope.run(fillSession(b, { form: 'play-content-rating', config: base0, startUrl: base + 'play-content-rating.html', ...fast(log) }));
// page 1: Next does nothing until the IARC Terms box is ticked (verified 2026-10-13): storebound waits for the owner
await log.wait('the agreement stop', l => l.some(m => m.startsWith('agreement needed on "Email address and category"') && m.includes('IARC Terms of Use')));
await Bun.sleep(300);
expect(await clicks(b)).toEqual([]);
expect(await b.page.getByRole('checkbox', { name: /IARC Terms of Use/ }).isChecked()).toBe(false); // never ticked by storebound
await b.page.getByRole('checkbox', { name: /IARC Terms of Use/ }).check(); // the owner
await log.wait('the questionnaire stop', l => l.some(m => m.startsWith('answer needed on "Questionnaire"')));
expect(log.lines).toContain('clicked "Save draft" (draft-save)');
expect(log.lines).toContain('clicked "Next" (navigation)');
@ -343,23 +356,24 @@ describe.skipIf(!browser)('the other forms: navigation by itself, final buttons
// the owner answers every outlined question
const open = b.page.locator('[role="radiogroup"][data-storebound-check]');
for (let i = 0, n = await open.count(); i < n; i++) await open.nth(i).getByRole('radio', { name: 'No' }).check();
await log.wait('the final line', l => l.some(m => m.includes('press Save yourself')));
expect(await clicks(b)).toEqual(['Save draft', 'Next', 'Save draft', 'Next']);
await log.wait('the final line', l => l.some(m => m.includes('storebound pressed Save') && m.includes('Send changes for review in Publishing overview yourself')));
expect(await clicks(b)).toEqual(['Save draft', 'Next', 'Save draft', 'Next', 'Save']);
expect(log.lines).toContain('clicked "Save" (declaration-save)');
expect(await b.page.getByRole('heading', { name: 'Summary' }).isVisible()).toBe(true);
expect(navAsked(log.lines)).toEqual([]);
await b.context.close();
await run;
}, TEST_MS);
scope.concurrent('target audience: Next by itself, a stop at the uncertain appeal question until answered, then Next; Save stays the owner\'s', async () => {
scope.concurrent('target audience: Next by itself, a stop at the uncertain appeal question until answered, then Next and the declaration\'s Save; Send changes for review stays the owner\'s', async () => {
const b = await local();
const log = logLines();
const run = scope.run(fillSession(b, { form: 'play-target-audience', config: base0, startUrl: base + 'play-target-audience.html', ...fast(log) }));
await log.wait('the appeal stop', l => l.some(m => m.startsWith('answer needed on "Appeal to children"')));
expect(await clicks(b)).toEqual(['Next']);
await b.page.getByRole('radiogroup', { name: /appeal to children/ }).getByRole('radio', { name: 'No' }).check();
await log.wait('the final line', l => l.some(m => m.includes('press Save yourself')));
expect(await clicks(b)).toEqual(['Next', 'Next']);
await log.wait('the final line', l => l.some(m => m.includes('storebound pressed Save')));
expect(await clicks(b)).toEqual(['Next', 'Next', 'Save']);
expect(navAsked(log.lines)).toEqual([]);
await b.context.close();
await run;
@ -373,6 +387,14 @@ describe.skipIf(!browser)('the other forms: navigation by itself, final buttons
await log.wait('the final line', l => l.some(m => m.includes(`press ${final} yourself`)));
// Play: only the read-only package name check (verified 2026-10-12); never the final button
expect(await clicks(b)).toEqual(form === 'play-app-record' ? ['Check availability'] : []);
// App Store Connect's New App (React, verified 2026-10-13): a programmatic value set left its state empty; the
// engine types like a person (the fixture's state only follows key presses)
if (form === 'apple-app-record') {
const state = await b.page.evaluate(() => (window as any).__state);
const shown = await b.page.evaluate(() => [...document.querySelectorAll('input[type=text]')].map(i => (i as HTMLInputElement).value));
expect(state.Name).toBe('Example Game');
expect([state.Name, state.SKU]).toEqual(shown);
}
expect(navAsked(log.lines)).toEqual([]);
await b.context.close();
await run;

View file

@ -4,7 +4,7 @@
import { describe, expect, test } from 'bun:test';
import { IARC_QUESTIONS, type ShipConfig, validateConfig, validateExtras } from '../cli/lib/config';
import { consoleAnswers, PLAN_FORM_IDS, PLAN_ONLY_FORM_IDS } from '../cli/lib/console-answers';
import { formDef, forbiddenTick } from '../cli/lib/console-forms';
import { declarationSaveAllowed, flowAllowed, forbiddenControl, formDef, forbiddenTick, publishingControl } from '../cli/lib/console-forms';
import { consolePlan, IN_PAGE_LOOP, playConsoleReminders, planText } from '../cli/lib/console-plan';
import { APP_CONTENT_CARDS, PLAY_MATERIAL_FACT, startLabel } from '../cli/lib/console-play-plans';
import { appPrivacyPublished, verifyAppContent, verifyPlayDeclaration, verifyText } from '../cli/lib/console-verify';
@ -44,7 +44,19 @@ describe('agent-only Play plans: shape, URLs, never-press lists, the owner batch
expect(p.askOwner).toHaveLength(p.answers.filter(a => a.status !== 'certain').length);
for (const b of ['Publish', 'Submit', 'Send changes for review']) expect(p.buttons.neverPress).toContain(b);
for (const b of p.buttons.allowed) expect(b.class).not.toBe('final-submit');
if (form.startsWith('play-') && !['play-store-settings', 'play-app-content', 'play-production-countries', 'play-production-release'].includes(form)) { expect(p.buttons.final).toBe('Save'); expect(p.buttons.neverPress).toContain('Save'); }
// an App content card's Save is the agent's (verified 2026-10-13); the owner's button is Send changes for review
if (form.startsWith('play-') && !['play-store-settings', 'play-app-content', 'play-production-countries', 'play-production-release'].includes(form)) {
expect(p.buttons.final).toBe('Send changes for review');
expect(p.buttons.neverPress).not.toContain('Save');
expect(p.buttons.allowed).toContainEqual(expect.objectContaining({ button: 'Save', class: 'declaration-save' }));
expect(p.steps.some(s => s.text.startsWith('Press Save yourself'))).toBe(true);
const last = p.ownerBatch.at(-1)!;
expect([last.when, last.kind]).toEqual(['after', 'final']);
expect(last.text).toContain('Send changes for review');
}
for (const b of ['Restart review', 'Submit N changes for review']) expect(p.buttons.neverPress).toContain(b);
p.ownerBatch.forEach((o, i) => expect(o.n).toBe(i + 1));
expect(planText(p).indexOf('Owner batch')).toBeLessThan(planText(p).indexOf('Steps:'));
expect(p.uiFacts.join('\n')).toContain(PLAY_MATERIAL_FACT);
expect(p.verify.command).toBe(`storebound console verify ${form} --from-text -`);
expect(p.inPageLoop).toBe(IN_PAGE_LOOP);
@ -89,18 +101,96 @@ describe('agent-only Play plans: shape, URLs, never-press lists, the owner batch
expect(p.answers.some(a => a.id === 'play-content-rating/agree:iarcTerms' && a.status === 'agreement')).toBe(true);
expect(p.askOwner.some(l => l.startsWith('Content ratings: IARC Terms of Use: an agreement'))).toBe(true);
expect(p.askOwner.some(l => l.startsWith('Government apps: '))).toBe(true);
for (const b of ['IARC Terms of Use box', 'Save (Summary page)', 'Save', 'Submit', 'Create app']) expect(p.buttons.neverPress).toContain(b);
for (const b of ['IARC Terms of Use box', 'Send changes for review', 'Restart review', 'Submit', 'Create app']) expect(p.buttons.neverPress).toContain(b);
for (const b of ['Save', 'Save (Summary page)']) expect(p.buttons.neverPress).not.toContain(b);
expect(p.buttons.final).toBe('Send changes for review');
expect(p.verify.capture).toContain('[aria-label^="Start "]');
});
test('App content: ONE owner batch at the top: the open answers, the IARC Terms tick before the run (with its place), Send changes for review after', () => {
const p = consolePlan('play-app-content', { ...base, console: { ...base.console, appContent: undefined } } as ShipConfig);
const kinds = p.ownerBatch.map(o => `${o.when}:${o.kind}`);
expect(kinds).toEqual(['before:answers', 'before:agreement', 'after:final']);
expect(p.ownerBatch[0]!.text).toContain('Is the app developed by or on behalf of a government?');
expect(p.ownerBatch[1]).toMatchObject({ text: 'Tick the IARC Terms of Use box' });
expect(p.ownerBatch[1]!.where).toContain(`${dev}/app-content/content-rating-iarc-questionnaire`);
expect(p.ownerBatch[1]!.where).toContain('page 1');
expect(p.ownerBatch[2]!.text).toContain('Send changes for review');
const text = planText(p);
expect(text.indexOf('Owner batch')).toBeLessThan(text.indexOf('start:'));
expect(text).toContain('[before the run] Tick the IARC Terms of Use box');
expect(p.steps[0]!.text).toContain('ONE message');
// a complete config: only the two owner steps are left
const done = consolePlan('play-app-content', { ...base, console: { ...base.console, appContent: { government: false, financialFeatures: false, healthFeatures: false, advertisingId: true } }, reviewAccess: { signInRequired: false } } as ShipConfig);
const steps = done.ownerBatch.filter(o => o.kind !== 'answers');
expect(steps.map(o => [o.when, o.kind])).toEqual([['before', 'agreement'], ['after', 'final']]);
expect(steps[0]!.text).toBe('Tick the IARC Terms of Use box');
expect(steps[1]!.text).toContain('Send changes for review');
});
test('content rating: the owner batch has the IARC Terms tick before the run; the Summary Save is the agent\'s', () => {
const p = consolePlan('play-content-rating', base);
expect(p.ownerBatch.map(o => [o.when, o.kind])).toEqual([['before', 'answers'], ['before', 'agreement'], ['after', 'final']]);
expect(p.ownerBatch[1]!.where).toContain('Next does nothing until it is ticked');
expect(p.steps.map(s => s.text).join('\n')).toContain('Next does nothing (verified 2026-10-13)');
expect(p.uiFacts.join('\n')).toContain('page 1\'s Next does nothing until the IARC Terms of Use box is ticked');
});
test('--all: every card inline in fix order, ending with the one owner list [IARC Terms (before), Send changes for review (after)]', () => {
const cfg = { ...base, console: { ...base.console, appContent: { government: false, financialFeatures: false, healthFeatures: false, advertisingId: true } }, reviewAccess: { signInRequired: false } } as ShipConfig;
const one = consolePlan('play-app-content', cfg), all = consolePlan('play-app-content', cfg, {}, { all: true });
expect(all.steps.length).toBeGreaterThan(one.steps.length + 20);
all.steps.forEach((s, i) => expect(s.n).toBe(i + 1));
// the cards in the overview's order
const at = APP_CONTENT_CARDS.map(c => all.steps.findIndex(s => s.text.startsWith(`${c.name}: `)));
expect(at.every(i => i > 0)).toBe(true);
expect([...at].sort((a, b) => a - b)).toEqual(at);
// every answer of every card is set in a step; answer ids keep their card
const inSteps = new Set(all.steps.flatMap(s => s.answers ?? []));
for (const a of all.answers) expect({ id: a.id, listed: inSteps.has(a.id) }).toEqual({ id: a.id, listed: true });
// card Saves are pressed by the agent, the run ends with the owner list
expect(all.steps.filter(s => s.text.includes('Press Save yourself')).length).toBeGreaterThanOrEqual(7);
const last = all.steps.at(-1)!.text;
expect(last).toContain('[before: Tick the IARC Terms of Use box]');
expect(last).toContain('[after: Press Send changes for review');
expect(all.ownerBatch.filter(o => o.kind !== 'answers').map(o => o.when)).toEqual(['before', 'after']);
expect(all.buttons.final).toBe('Send changes for review');
});
test('the click gate: card Saves allowed on an App content card page; Send changes for review, Submit, Publish, Restart review never', () => {
const card = (slug: string) => ({ url: `${dev}/app-content/${slug}` });
for (const slug of ['privacy-policy', 'ads-declaration', 'testing-credentials', 'ad-id-declaration', 'government-apps', 'finance', 'health', 'target-audience-content', 'content-rating-iarc-questionnaire', 'data-privacy-security'])
expect({ slug, ok: declarationSaveAllowed('Save', 'button', card(slug)) }).toEqual({ slug, ok: true });
expect(declarationSaveAllowed('Kaydet', 'button', card('ads-declaration'))).toBe(true);
for (const n of ['Send changes for review', 'Submit', 'Publish', 'Restart review', 'Submit 3 changes for review', 'Save and publish', 'Save draft', 'Start rollout'])
expect({ n, ok: declarationSaveAllowed(n, 'button', card('ads-declaration')) }).toEqual({ n, ok: false });
// only on a card page, only a button
expect(declarationSaveAllowed('Save', 'button', { url: `${dev}/app-content/overview` })).toBe(false);
expect(declarationSaveAllowed('Save', 'button', { url: `${dev}/tracks/production` })).toBe(false);
expect(declarationSaveAllowed('Save', 'button', { url: 'https://example.com/app-content/ads-declaration' })).toBe(false);
expect(declarationSaveAllowed('Save', 'link', card('ads-declaration'))).toBe(false);
for (const n of ['Send changes for review', 'Submit', 'Publish', 'Restart review', 'Submit 3 changes for review', 'Değişiklikleri incelemeye gönder']) {
expect(publishingControl(n)).toBe(true);
expect(forbiddenControl(n)).toBe(true);
for (const f of ['play-content-rating', 'play-target-audience'] as const) expect(flowAllowed(f, n, 'button', { privacyPage: false, inDialog: false, inDataTypes: false, dataTypes: [] })).toBe(false);
}
for (const n of ['Save', 'Releases', 'Next']) expect(publishingControl(n)).toBe(false);
});
test('Sign in details: the demo account never printed, typed by the owner', () => {
const p = consolePlan('play-sign-in-details', { ...base, reviewAccess: { signInRequired: true, instructions: 'Sign in with the demo account.' } } as ShipConfig, { demoAccount: true });
expect(p.answers.find(a => a.id === 'access')).toMatchObject({ value: true, status: 'certain', label: 'All or some functionality in my app is restricted' });
expect(p.answers.find(a => a.id === 'instructions')).toMatchObject({ status: 'certain', action: 'type' });
const cred = p.answers.find(a => a.id === 'credentials')!;
expect(cred).toMatchObject({ status: 'ask-owner', value: null });
// in .storebound/credentials.json: the agent types it, never printed (no value in the plan)
expect(cred).toMatchObject({ status: 'certain', value: null, action: 'type', text: 'from .storebound/credentials.json (never printed)' });
expect(cred.note).toContain('reviewDemoAccount: present');
expect(cred.note).toContain('never types a password');
expect(p.ownerBatch.map(o => o.kind)).toEqual(['final']);
// kept out of .storebound: the owner types it (in the owner batch, before the run)
const own = consolePlan('play-sign-in-details', { ...base, reviewAccess: { signInRequired: true, instructions: 'Sign in with the demo account.' } } as ShipConfig, { demoAccount: false });
expect(own.answers.find(a => a.id === 'credentials')).toMatchObject({ status: 'ask-owner', value: null });
expect(own.ownerBatch.map(o => [o.when, o.kind])).toEqual([['before', 'credentials'], ['after', 'final']]);
expect(JSON.stringify(own)).not.toMatch(/password"\s*:/i);
expect(consolePlan('play-sign-in-details', base).answers.map(a => a.status)).toEqual(['ask-owner']);
});
@ -117,12 +207,14 @@ describe('agent-only Play plans: shape, URLs, never-press lists, the owner batch
});
describe('IARC content rating: verified flow, follow-ups, never-press', () => {
test('the IARC Terms box is an agreement, the Summary\'s Save is final (not Submit), the flow and example ratings are recorded', () => {
test('the IARC Terms box is an agreement, the Summary\'s Save (not Submit) is the agent\'s declaration save, the flow and example ratings are recorded', () => {
const p = consolePlan('play-content-rating', base);
expect(p.answers.find(a => a.id === 'agree:iarcTerms')).toMatchObject({ status: 'agreement', value: null, action: 'untick' });
expect(p.buttons.final).toBe('Save (Summary page)');
for (const b of ['IARC Terms of Use box', 'Save (Summary page)', 'Submit']) expect(p.buttons.neverPress).toContain(b);
expect(p.buttons.final).toBe('Send changes for review');
for (const b of ['IARC Terms of Use box', 'Send changes for review', 'Submit']) expect(p.buttons.neverPress).toContain(b);
expect(p.buttons.neverPress).not.toContain('Save (Summary page)');
expect(p.buttons.allowed.map(b => b.button)).toContain('Save draft / Save (questionnaire page)');
expect(p.buttons.allowed).toContainEqual(expect.objectContaining({ button: 'Save (Summary page)', class: 'declaration-save' }));
const facts = p.uiFacts.join('\n');
for (const f of ['content-rating-iarc-questionnaire', 'one long page', '"Save" (not "Submit")', 'PEGI 7, ESRB E10+, USK 12, ClassInd 18', 'Google Play 7+']) expect(facts).toContain(f);
expect(p.askOwner.some(l => l.startsWith('IARC Terms of Use: an agreement'))).toBe(true);

View file

@ -20,4 +20,11 @@
</fieldset>
<button type="button">Help</button> <button type="button">Cancel</button> <button type="button">Create</button>
</div>
<script>
// Models the verified 2026-10-13 behaviour of the real (React) dialog: a programmatic value set leaves the component
// state empty; only key presses update it. window.__state holds what the dialog would submit.
window.__state = { Name: '', SKU: '' };
for (const [label, input] of [['Name', document.querySelectorAll('input[type=text]')[0]], ['SKU', document.querySelectorAll('input[type=text]')[1]]])
input.addEventListener('keyup', () => { window.__state[label] = input.value; });
</script>
</body></html>

129
tests/play-app-id.test.ts Normal file
View file

@ -0,0 +1,129 @@
// The Play Console app id (play.appId): taken from the URL Create app redirects to (`console verify play-app-record
// --url`), or found by package name in captured app list pages (`play app-id`, a modeled list of 10 apps per page with
// the new app on page 3), then written into the config as an upsert after a dry diff. Placeholder ids only.
import { afterAll, describe, expect, test } from 'bun:test';
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import type { ShipConfig } from '../cli/lib/config';
import { consolePlan } from '../cli/lib/console-plan';
import { PLAY_CREATE_CAPTURE, verifyText } from '../cli/lib/console-verify';
import { APP_LIST_CAPTURE, appIdChange, appIdDiff, appIdSteps, appListPage, type AppListPage, findPlayAppId, parseAppListPages, playAppIdInText, writePlayAppId } from '../cli/lib/play-app-id';
import { upsertNestedString } from '../cli/lib/scan/config-edit';
const PKG = 'com.example.game';
const base: ShipConfig = {
app: { name: 'Example Game', profile: 'game', privacyPolicyUrl: 'https://example.com/privacy' },
languages: [{ id: 'en', play: 'en-US', apple: 'en-US' }],
play: { packageName: PKG, developerId: '4444' },
} as ShipConfig;
const dash = (app: string) => `https://play.google.com/console/u/0/developers/4444/app/${app}/app-dashboard`;
/** A modeled app list: 25 apps, 10 per page, the new app (com.example.game) on page 3, a look-alike on page 1. */
function list(): AppListPage[] {
const apps = Array.from({ length: 24 }, (_, i) => ({ name: `Other App ${i + 1}`, pkg: `com.example.other${i + 1}`, id: String(4975000000000000000 + i) }));
apps.splice(1, 0, { name: 'Example Game Pro', pkg: 'com.example.game2', id: '4975999999999999998' }); // not a whole-token match
apps.push({ name: 'Example Game', pkg: PKG, id: '4975123456789012345' });
const pages: AppListPage[] = [];
for (let i = 0; i < apps.length; i += 10) {
const slice = apps.slice(i, i + 10);
pages.push({ url: appListPage('4444', pages.length + 1), hasNext: i + 10 < apps.length,
rows: slice.map(a => ({ text: `${a.name} ${a.pkg} Production Published`, href: `/console/u/0/developers/4444/app/${a.id}/app-dashboard` })) });
}
return pages;
}
describe('the app id from a URL or a capture', () => {
test('playAppIdInText: the /developers/<id>/app/<id> digits, anywhere', () => {
expect(playAppIdInText(dash('4975123456789012345'))).toBe('4975123456789012345');
expect(playAppIdInText(JSON.stringify({ url: dash('9876'), fields: {} }))).toBe('9876');
expect(playAppIdInText('https://play.google.com/console/u/0/developers/4444/app-list')).toBeNull();
expect(playAppIdInText('https://play.google.com/console/u/0/developers/4444/create-new-app')).toBeNull();
});
test('console verify play-app-record --url: the created app\'s id matches and is handed to the upsert', () => {
const r = verifyText('play-app-record', base, dash('9876'));
expect(r).toMatchObject({ ok: true, playAppId: '9876', matched: 1 });
expect(r.rows[0]!.expected).toContain('not in the config yet');
// the capture snippet carries the URL too
expect(PLAY_CREATE_CAPTURE).toContain('url: location.href');
expect(verifyText('play-app-record', base, JSON.stringify({ url: dash('9876'), fields: {}, checked: [], text: 'Dashboard' })).playAppId).toBe('9876');
// a different id in the config: a mismatch, and --write would replace it
const other = verifyText('play-app-record', { ...base, play: { ...base.play!, appId: '1111' } } as ShipConfig, dash('9876'));
expect(other).toMatchObject({ ok: false, mismatches: 1, playAppId: '9876' });
// the form still open (before Create app): no app id, the usual field rows
const form = verifyText('play-app-record', base, JSON.stringify({ url: 'https://play.google.com/console/u/0/developers/4444/create-new-app', fields: { 'App name': 'Example Game' }, checked: [], text: '' }));
expect(form.playAppId).toBeUndefined();
expect(form.rows.length).toBeGreaterThan(1);
});
test('the plan says how to capture it after Create app', () => {
const p = consolePlan('play-app-record', base);
expect(p.steps.map(s => s.text).join('\n')).toContain('console verify play-app-record --url');
expect(p.uiFacts.join('\n')).toContain('10 apps per page');
});
});
describe('play app-id: the package name across the app list pages', () => {
test('found on page 3 of a modeled 3-page list; the look-alike com.example.game2 never matches', () => {
const pages = list();
expect(pages).toHaveLength(3);
expect(pages[0]!.rows).toHaveLength(10);
expect(findPlayAppId(pages.slice(0, 1), PKG)).toEqual({ found: false, pages: 1, hasNext: true });
expect(findPlayAppId(pages.slice(0, 2), PKG)).toEqual({ found: false, pages: 2, hasNext: true });
expect(findPlayAppId(pages, PKG)).toMatchObject({ found: true, appId: '4975123456789012345', page: 3 });
expect(findPlayAppId(pages, 'com.example.missing')).toEqual({ found: false, pages: 3, hasNext: false });
});
test('captures: one object, a JSON array or one per line', () => {
const pages = list();
expect(parseAppListPages(JSON.stringify(pages[2]))).toHaveLength(1);
expect(parseAppListPages(JSON.stringify(pages))).toHaveLength(3);
expect(parseAppListPages(pages.map(p => JSON.stringify(p)).join('\n'))).toHaveLength(3);
expect(() => parseAppListPages('{"nope":1}')).toThrow('rows');
expect(findPlayAppId(parseAppListPages(pages.map(p => JSON.stringify(p)).join('\n')), PKG)).toMatchObject({ found: true, page: 3 });
});
test('the agent steps: the search box first, then the pages; the capture snippet is valid JavaScript', () => {
const steps = appIdSteps(base, '4444').join('\n');
for (const s of ['https://play.google.com/console/u/0/developers/4444/app-list', 'search box', PKG, 'Next page', '/app-list?page=', '--write']) expect(steps).toContain(s);
expect(() => new Function(`return ${APP_LIST_CAPTURE}`)).not.toThrow();
expect(appListPage('4444', 3)).toBe('https://play.google.com/console/u/0/developers/4444/app-list?page=3');
});
});
describe('play.appId written into the config: a dry diff, then an upsert', () => {
const dir = mkdtempSync(join(tmpdir(), 'sb-app-id-'));
afterAll(() => rmSync(dir, { recursive: true, force: true }));
const TS = `import { defineConfig } from 'storebound';\n\nexport default defineConfig({\n app: { name: 'Example Game' },\n languages: [{ id: 'en', play: 'en-US', apple: 'en-US' }],\n play: {\n packageName: '${PKG}', // the package\n developerId: '4444',\n listing: { title: 'x' },\n },\n});\n`;
test('adds play.appId inside play (only there), then replaces it; the rest of the file stays', () => {
const added = upsertNestedString(TS, 'storebound.config.ts', 'play', 'appId', '9876');
expect(added).toContain(` play: {\n appId: '9876',\n packageName: '${PKG}', // the package`);
expect(added.match(/appId/g)).toHaveLength(1);
expect(added.replace(" appId: '9876',\n", '')).toBe(TS);
const again = upsertNestedString(added, 'storebound.config.ts', 'play', 'appId', '1111');
expect(again).toBe(added.replace("'9876'", "'1111'"));
expect(upsertNestedString("export default { app: { name: 'X' }, play: { packageName: 'a.b' } };\n", 'c.ts', 'play', 'appId', '5')).toBe("export default { app: { name: 'X' }, play: { packageName: 'a.b', appId: '5' } };\n");
expect(JSON.parse(upsertNestedString(JSON.stringify({ play: { packageName: 'a.b' } }), 'c.json', 'play', 'appId', '5')).play).toEqual({ packageName: 'a.b', appId: '5' });
expect(() => upsertNestedString("export default { app: {} };\n", 'c.ts', 'play', 'appId', '5')).toThrow('no play block');
// a nested appId (another block inside play) is not the one replaced
const nested = "export default {\n play: {\n extra: { appId: 'keep' },\n packageName: 'a.b',\n },\n};\n";
expect(upsertNestedString(nested, 'c.ts', 'play', 'appId', '5')).toContain(" appId: '5',\n extra: { appId: 'keep' },");
});
test('writePlayAppId: a dry diff first, a write only when it changes, re-runs converge', () => {
const file = join(dir, 'storebound.config.ts');
writeFileSync(file, TS);
const cfg = { ...base } as ShipConfig;
const change = appIdChange(cfg, '9876');
expect(change).toEqual({ from: null, to: '9876', changed: true });
expect(appIdDiff(change, 'storebound.config.ts')).toBe("storebound.config.ts: play.appId (not set) -> '9876'");
expect(writePlayAppId(file, cfg, '9876')).toBe(true);
expect(readFileSync(file, 'utf8')).toContain("appId: '9876'");
const now = { ...cfg, play: { ...cfg.play!, appId: '9876' } } as ShipConfig;
expect(writePlayAppId(file, now, '9876')).toBe(false); // already there: nothing written
expect(appIdDiff(appIdChange(now, '9876'), 'storebound.config.ts')).toContain('already');
expect(() => writePlayAppId(file, cfg, 'abc')).toThrow('not a Play Console app id');
});
});

View file

@ -224,7 +224,7 @@ const SHOTS: Shot[] = [
const sample = [sheetRows.find(l => l.includes('[ok]')), sheetRows.find(l => l.includes('[you]')), sheetRows.find(l => l.includes('[ask]'))].filter((l): l is string => !!l);
const after = lines.slice(facts).filter(l => l.trim()).length;
return [
...lines.slice(0, steps), 'Steps:', ...stepLines.slice(0, 6).map(short), `… ${stepLines.length - 6} more steps`, '',
...lines.slice(0, steps).map(short), 'Steps:', ...stepLines.slice(0, 3).map(short), `… ${stepLines.length - 3} more steps`, '',
'Answer sheet:', ...sample.map(short), `… ${sheetRows.length - sample.length} more answers (${sheetRows.filter(l => l.includes('[ask]')).length} of all ${sheetRows.length} are "ask the owner")`, '',
...lines.slice(never, facts).filter(l => l.trim()).map(l => l.startsWith('Never press:') ? l : short(l)), `… UI facts and driving notes (${after} lines)`,
].filter((l, i, a) => !(l === '' && a[i - 1] === ''));