console plans: guardrails from a real run (2026-10-21): health none label and Sign in details Yes/No + Add details (old labels as altLabels, verify accepts both), finance boxes on step 1, App Privacy Additional Setup Required OK, verify from DOM input states (verify.capture, --from), Save + confirm before every Next on Play pages, IARC Digital purchases boxes and an exact-label in-page helper that refuses patterns and blanket answers; review contact found in parent folders, the profile and more key shapes with the files checked listed; one-line errors for missing required values (Args.required); pitfalls 86-87, tests

This commit is contained in:
CRTkafa 2026-10-21 05:03:02 +07:00
parent a6dab8f3aa
commit 335a4124ab
23 changed files with 486 additions and 74 deletions

View file

@ -2,6 +2,23 @@
## Unreleased
- **Console plans follow the live pages (2026-10-21 run).** Play health: "My app does not have any health features"
(verify accepts it, "Other" stays a fallback); Sign in details: "Is any part of your app restricted?" Yes / No and
"Add details" (old labels kept as `altLabels`); finance: the boxes on step 1, step 2 information only; App Privacy:
the "Additional Setup Required" OK that opens the first type's setup by itself is in the steps and UI facts.
- **Save after every page.** Every Play plan step that answers a page with a Save next to Next now says Save, wait
for "Your changes have been saved", then Next; the rule line says it too.
- **IARC by exact labels.** "Purchases of digital goods" is ticked after Yes on "Digital purchases" (the loot-box and
trading follow-ups show only then); the in-page helper matches exact labels and questions and refuses patterns,
radios without their question and blanket group answers.
- **`console verify` reads input states.** The Play declaration plans' `verify.capture` returns every radio /
checkbox with aria-checked / checked / aria-selected as JSON; `console verify <form> --from <file|->` takes it.
- **`apple review-details` finds the contact** in parent folders' `.storebound/credentials.json` and the credential
profile, under more key shapes, and lists every file and key checked when there is none.
- **One-line errors for missing required values:** `apple listing` without `--version` says "--version <x.y.z> is
required (storebound apple status shows the editable version)"; the same for privacy-manifest `--ios`, adhoc
export `--archive`, edit `--ops` and resubmit `--build`. A flag given without a value counts as missing.
## 0.8.21 · 2026-10-20
- **`shots record --source web` films at device pixels.** Chromium's screencast always sent frames at the page's CSS

View file

@ -165,8 +165,7 @@ function options(ctx: Ctx): void {
function exportIpa(ctx: Ctx): void {
const a = ctx.args;
const archive = a.get('archive');
if (!archive || archive === 'true') throw new Error('usage: storebound apple adhoc export --archive <App.xcarchive> [--out build/adhoc] [--dry]');
const archive = a.required('archive', 'App.xcarchive', 'Xcode: Product > Archive, or storebound apple build');
if (!existsSync(archive)) throw new Error(`${archive}: no such archive (Xcode: Product > Archive, or xcodebuild archive)`);
const plistRel = a.str('plist', 'build/ExportOptions.plist');
const plist = ctx.loaded.resolve(plistRel);

View file

@ -195,8 +195,7 @@ export async function run(ctx: Ctx): Promise<void> {
const { config, root } = ctx.loaded;
const a = ctx.args;
if (a._[0] === 'pull') return pull(ctx);
const version = a.get('version');
if (!version) throw new Error('usage: apple listing --version 1.0.0 [--build N] [--include-drafts] [--dry] [--json]');
const version = a.required('version', 'x.y.z', 'storebound apple status shows the editable version');
const dry = a.has('dry');
const c = await ascClient(root, need(config.apple?.bundleId, 'apple.bundleId'), config.apple?.appId);
const appId = await c.app();

View file

@ -39,8 +39,8 @@ export async function submitVersion(ctx: Ctx, mode: 'submit' | 'resubmit'): Prom
const TAG = mode;
const { config, root } = ctx.loaded;
const a = ctx.args;
const number = a.get('build');
if (!number || !/^\d+(\.\d+)*$/.test(number)) throw new Error(`usage: apple ${mode} --build <n> [--version x.y.z] [--dry] [--yes]: the build number you just uploaded (never "the latest")`);
const number = a.required('build', 'n', 'the build number you just uploaded, never "the latest"; storebound apple testflight status lists them');
if (!/^\d+(\.\d+)*$/.test(number)) throw new Error(`--build ${number}: a build number like 42 or 1.0.3 (the one you just uploaded, never "the latest")`);
const c = await ascClient(root, need(config.apple?.bundleId, 'apple.bundleId'), config.apple?.appId);
const appId = await c.app();
const ids = [...(config.products ?? []).filter(p => sellsOn(config, p, 'apple')).map(p => p.id), ...allSubscriptions(config.subscriptions).filter(x => sellsOn(config, x.sub, 'apple')).map(x => x.sub.id)];

View file

@ -8,7 +8,7 @@
import { refuseTemplate } from '../lib/template-answers';
import type { Ctx } from '../lib/ctx';
import { need } from '../lib/ctx';
import { reviewContact, reviewDemoAccount } from '../lib/credentials';
import { findReviewContact, noContactMessage, reviewDemoAccount } from '../lib/credentials';
import { reviewDetailPlan } from '../lib/review-details';
import { done, log } from '../lib/log';
import { createOnce } from '../lib/upsert';
@ -29,11 +29,15 @@ export async function run(ctx: Ctx): Promise<void> {
if (a?.contactEmail) { contact = { contactFirstName: a.contactFirstName, contactLastName: a.contactLastName, contactPhone: a.contactPhone, contactEmail: a.contactEmail }; break; }
}
if (!contact) throw new Error(`app ${from} has no review contact on its recent versions`);
} else {
const rc = reviewContact(root);
}
let checked: string[] = [];
if (!from) {
const found = findReviewContact(root);
checked = found.checked;
const rc = found.contact;
if (rc) contact = { contactFirstName: rc.firstName, contactLastName: rc.lastName, contactPhone: rc.phone, contactEmail: rc.email };
}
if (!contact) throw new Error('no review contact: add "reviewContact" to .storebound/credentials.json or pass --contact-from <app id>');
if (!contact) throw new Error(noContactMessage(root, checked));
const { attributes, summary } = reviewDetailPlan(config, contact, reviewDemoAccount(root));
if (ctx.args.has('dry')) { log('apple', `would set review details (contact ${from ? 'copied from ' + from : 'from credentials'}, ${summary})`); return; }

View file

@ -43,7 +43,7 @@ export const COMMANDS: Record<string, [() => Promise<Mod>, string]> = {
'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] [--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 verify': [() => import('./console/verify'), '<form> [--from <file|->] [--from-text <file|->] [--json]: check a filled console form against the config (page text, the input-state JSON of the plan\'s verify.capture, 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)'],
'originality': [() => import('./originality'), 'Guideline 4.3 check vs your other apps (--against <folders>): listing text, name, icon and screenshot perceptual hashes, project structure; scores, flags, suggestions; offline, exit 1 on a near-duplicate'],

View file

@ -1,5 +1,6 @@
// 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 <form> [--from <file|->] [--from-text <file|->] [--json]
// --from takes the JSON of the plan's verify.capture snippet (input states: aria-checked, checked, aria-selected) or page text.
// 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
@ -30,7 +31,7 @@ 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'), url = args.get('url');
const from = args.get('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;

View file

@ -50,7 +50,6 @@
"upload"
],
"apple adhoc": [
"archive",
"csv",
"method",
"name",
@ -175,7 +174,6 @@
"version"
],
"apple resubmit": [
"build",
"new-version",
"no-submit",
"platform",
@ -210,7 +208,6 @@
"wait-review"
],
"apple submit": [
"build",
"new-version",
"no-submit",
"platform",
@ -423,6 +420,7 @@
"cordova-ios",
"expo",
"flutter",
"from",
"from-text",
"godot",
"name",
@ -447,7 +445,6 @@
"dir"
],
"edit": [
"ops",
"show"
],
"flow": [
@ -688,7 +685,6 @@
"with-automatic"
],
"privacy-manifest": [
"ios",
"propose",
"sdk-dirs",
"tracking-domains"
@ -926,7 +922,6 @@
"apple",
"apply",
"apps",
"archive",
"aso",
"base",
"base-url",
@ -1058,7 +1053,6 @@
"only-keywords",
"only-missing",
"only-terms",
"ops",
"originality",
"out",
"param",
@ -1334,7 +1328,7 @@
"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 <file|->] [--from-text <file|->] [--json]",
"storebound console verify play-app-record --url <url after Create app> [--write]"
],
"desktop": [

View file

@ -86,8 +86,7 @@ export async function run(ctx: Ctx): Promise<void> {
const a = ctx.args;
const { root } = ctx.loaded;
if (a.has('show')) { printJson(editableValues(ctx.loaded)); return; }
const opsJson = a.get('ops');
if (!opsJson) throw new Error('usage: storebound edit --ops \'<json>\' [--dry] [--json] | storebound edit --show --json');
const opsJson = a.required('ops', 'json', 'storebound edit --show --json lists the editable values');
const { plan: p, texts } = await plan(ctx, opsJson);
const dry = a.has('dry');
// with --json in a terminal, stdout is the JSON result only: the lines go to stderr (under `serve` the result is a

View file

@ -8,8 +8,13 @@ export interface Args {
list(name: string): string[];
/** Every value of a flag given more than once (--param a=1 --param b=2), in order. */
many(name: string): string[];
/** A flag that needs a value: missing, or given without one (--version alone), throws ONE line with the fix. */
required(name: string, placeholder: string, fix?: string): string;
}
/** The one-line error of a missing required flag (never the whole help). */
export const requiredError = (name: string, placeholder: string, fix?: string): Error => new Error(`--${name} <${placeholder}> is required${fix ? ` (${fix})` : ''}`);
export function parseArgs(argv: string[], booleans: string[] = []): Args {
const flags = new Map<string, string>();
const repeated = new Map<string, string[]>();
@ -35,5 +40,6 @@ export function parseArgs(argv: string[], booleans: string[] = []): Args {
num: (n, d) => { const v = get(n); const x = v === undefined ? d : Number(v); if (Number.isNaN(x)) throw new Error(`--${n}: not a number`); return x; },
list: n => (get(n) ?? '').split(',').map(s => s.trim()).filter(Boolean),
many: n => repeated.get(n) ?? [],
required: (n, ph, fix) => { const v = get(n); if (v === undefined || v === 'true' || !v.trim()) throw requiredError(n, ph, fix); return v; },
};
}

View file

@ -291,6 +291,8 @@ export const IARC_LABELS: Record<IarcQuestion, string> = {
userInteraction: 'Users can interact or exchange content', sharesLocation: 'Shares the user\'s location with other users', digitalPurchases: 'Digital purchases',
unrestrictedInternet: 'Unrestricted internet access (browser)',
};
/** IARC's Digital purchases question and the box its Yes reveals (exact console labels, 2026-10-21). */
export const IARC_DIGITAL_PURCHASES = 'Digital purchases', IARC_DIGITAL_GOODS = 'Purchases of digital goods';
const CATEGORY_LABELS = { game: 'Game', social: 'Social or communication', other: 'All other app types' } as const;
/** The IARC follow-up questions (console.contentRating.details), in the page's order: section -> key -> label. The
@ -362,6 +364,9 @@ function contentRating(config: ShipConfig): Answer[] {
if (topics && !topics.some(t => topic.get(t) === true)) continue;
const given = (det as Record<string, Record<string, unknown> | undefined>)[sec] ?? {};
const src = (k: string) => `console.contentRating.details.${sec}.${k}`;
// Yes on Digital purchases reveals boxes; "Purchases of digital goods" must be ticked before the loot-box and trading
// follow-ups show (2026-10-21)
if (sec === 'digitalPurchases') out.push(ok(`d:digitalPurchases:digitalGoods`, `Digital purchases: ${IARC_DIGITAL_GOODS}`, true, `console.contentRating.answers.digitalPurchases (Yes) or what is sold`, 'Tick it first: the loot-box and trading questions show only after it.'));
for (const [k, kind] of Object.entries(IARC_DETAIL_KEYS[sec])) {
if (sec === 'digitalPurchases' && k === 'lootBoxes') continue; // the q:lootBoxes answer above
const label = IARC_DETAIL_LABELS[sec][k]!, id = `d:${sec}:${k}`, v = given[k];

View file

@ -462,6 +462,7 @@ export const FORM_BUTTONS: Record<FormId, ButtonRule[]> = {
{ button: 'Edit (next to one data type)', class: 'navigation', where: 'Data Types section, a type set up before: reopens its questions, only when its purposes differ from the config', auto: true },
{ button: '? / More information', class: 'navigation', where: 'next to each heading and data type: help only (never needed)', auto: false },
{ button: 'Next / Continue', class: 'navigation', where: 'questionnaire dialogs', auto: true },
{ button: 'OK', class: 'navigation', where: 'the "Additional Setup Required" message after the data types Save (it may open the first type\'s setup by itself)', auto: true },
{ button: 'Back', class: 'navigation', where: 'questionnaire dialogs (never needed)', auto: false },
{ button: 'Definitions and Examples', class: 'navigation', where: 'a data type\'s tracking step: help only (never clicked)', auto: false },
{ button: 'Save', class: 'draft-save', where: 'questionnaire dialogs: stores the answers, not public until Publish', auto: true },

View file

@ -9,10 +9,10 @@ import { consoleLink, consoleUrl } from './console-links';
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_FULL_ACCESS_BOX, PLAY_MATERIAL_FACT, playAppPage, playOnlyPart, SEND_FOR_REVIEW, SEND_FOR_REVIEW_WHERE, startLabel } from './console-play-plans';
import { APP_CONTENT_CAPTURE, APP_CONTENT_CARDS, appContentStart, PLAY_FULL_ACCESS_BOX, PLAY_MATERIAL_FACT, playAppPage, playOnlyPart, SAVE_EACH_PAGE, 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';
import { APPLE_PURPOSES, IARC_DIGITAL_GOODS, IARC_DIGITAL_PURCHASES } from './console-answers';
export type PlanStatus = 'certain' | 'ask-owner' | 'owner-check' | 'agreement';
@ -37,6 +37,8 @@ export interface PlanAnswer {
where?: string;
/** The label has a typographic quote (’ “ ”): match straight and curly quotes alike. */
curlyQuotes?: boolean;
/** Older or alternative console labels of the same control (tried after `label`; verify accepts them too). */
altLabels?: string[];
}
export interface PlanStep {
@ -162,6 +164,7 @@ function sheet(form: FormId, answers: Answer[]): PlanAnswer[] {
if (kind === 'tracking') return withLabel(p, trackingLabel(t, v !== false), { control: 'radio', action: 'pick', where: `the "${t}" dialog, step 5 (tracking)` });
return p;
}
if (a.id === 'd:digitalPurchases:digitalGoods') return withLabel(p, IARC_DIGITAL_GOODS, { control: 'checkbox', action: 'tick', where: `Digital Purchases: the boxes "${IARC_DIGITAL_PURCHASES}" Yes reveals` });
const hit = fieldOf.get(a.id);
if (!hit) return p;
const { f, s } = hit;
@ -183,6 +186,7 @@ const ids = (answers: PlanAnswer[], pred: (a: PlanAnswer) => boolean) => answers
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.',
'Exact labels only: every label and within is the console\'s exact text from the answer sheet (a trailing "…" allows any rest). Never answer by keywords or regex and never blanket-answer the unanswered groups: a keyword helper once answered IARC\'s "Digital purchases" No because its text also mentions "cash rewards". The helper refuses patterns, radios without their exact question and group steps; a question without an exact sheet answer is the owner\'s.',
'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).',
@ -214,18 +218,23 @@ export const IN_PAGE_LOOP = [
' const scopeOf = (text) => {',
' const want = norm(text).toLowerCase(); let best = null;',
' for (const el of document.querySelectorAll("section, fieldset, div, [role=radiogroup], [role=group]"))',
' if (norm(el.innerText).toLowerCase().includes(want) && el.querySelector(CONTROLS) && (!best || best.contains(el))) best = el;',
' if (norm((el.innerText || "").split("\\n")[0]).toLowerCase() === want && el.querySelector(CONTROLS) && (!best || best.contains(el))) best = el;',
' return best;',
' };',
' const find = (role, label, scope) => {',
' const want = norm(label).toLowerCase();',
' const prefix = /…$/.test(label), want = norm(label).replace(/…$/, "").toLowerCase();',
' const hits = [...(scope || document).querySelectorAll("[role=" + role + "], input[type=" + role + "]")]',
' .filter((el) => el.getClientRects().length && labelOf(el).toLowerCase().startsWith(want));',
' .filter((el) => { const l = labelOf(el).toLowerCase(); return el.getClientRects().length && (prefix ? l.startsWith(want) : l === want); });',
' return hits.length === 1 ? hits[0] : null;',
' };',
' const KEYWORDS = /[|*^$\\\\[\\]]/;',
' const set = async (s) => {',
' if (typeof s.label !== "string" || !s.label || (s.within !== undefined && typeof s.within !== "string")) return "refused: pass the exact console label (a string), never a pattern";',
' if (KEYWORDS.test(s.label) || KEYWORDS.test(s.within || "")) return "refused: keywords or a pattern; pass the exact console label from the answer sheet";',
' if (s.role === "radio" && !s.within) return "refused: a radio needs within = its exact question text (no blanket answers)";',
' if (s.group || s.all || s.rest) return "refused: no blanket answers for groups without an exact answer";',
' const scope = s.within ? scopeOf(s.within) : null;',
' if (s.within && !scope) return "no block with \\"" + s.within + "\\"";',
' if (s.within && !scope) return "no block whose question is exactly \\"" + s.within + "\\"";',
' const el = find(s.role, s.label, scope);',
' if (!el) return "not found or ambiguous";',
' if (NEVER.test(labelOf(el))) return "refused: an agreement (the owner ticks it)";',
@ -254,6 +263,9 @@ export const IN_PAGE_LOOP = [
'})()',
].join('\n');
/** App Privacy after the data types Save: the "Additional Setup Required" message and what its OK may open. */
export const APPLE_ADDITIONAL_SETUP = 'After saving the data types, an "Additional Setup Required" message appears: press its OK. That OK sometimes opens the first type\'s setup dialog by itself; the dialog it opened IS the first type\'s setup (step 1, purposes), so carry on from there with that type and do not press its Set Up again. Otherwise press the first type\'s "Set Up".';
function appPrivacyPlan(answers: PlanAnswer[], appId: string | null | undefined): Pick<ConsolePlan, 'steps' | 'uiFacts' | 'verify' | 'start'> {
const types = answers.filter(a => a.id.startsWith('type:'));
const collected = types.filter(a => a.value === true).map(a => a.id.slice(5));
@ -262,6 +274,7 @@ function appPrivacyPlan(answers: PlanAnswer[], appId: string | null | undefined)
add({ text: appId ? 'Open the App Privacy page of the app (deep link).' : `Open App Store Connect > Apps > the app > App Privacy (sidebar): ${consoleUrl('asc.apps')}` });
add({ text: 'First time: click "Get Started" and answer the Data Collection question; then Next.', click: ['Get Started'], answers: ['collects'] });
add({ text: 'Data types: when the Data Types section does not list exactly the collected types below, click the "Edit" next to the "Data Types" heading (never the Privacy Policy Edit), tick exactly these types and untick the rest, then Save (a draft: nothing is public before Publish). Console labels: Purchase History shows as "Purchases", Other Data Types as "Other Data".', click: ['Edit', 'Save'], answers: ids(answers, a => a.id.startsWith('type:')) });
add({ text: `${APPLE_ADDITIONAL_SETUP}`, click: ['OK'] });
for (const t of collected) {
const set = setUpTexts(t).filter(x => !/ ayarla$/.test(x));
add({ type: t, text: `"${t}": click the text "${set.join('" or "')}" in the Data Types section (a plain <p> with a click handler, not a button). A dialog opens whose h2 is "${typeLabels(t).join('" or "')}". (Set up before: its row shows Edit and a summary; open it with that row's Edit only when the summary differs from the answers.)`, click: set });
@ -280,6 +293,7 @@ function appPrivacyPlan(answers: PlanAnswer[], appId: string | null | undefined)
'Verified 2026-10-12 on /apps/<id>/distribution/privacy:',
'Data Types section: each collected type has "Set Up <type>", a plain <p> inside a div with the click handler (no button or link role). Click it by its exact text. It opens a <dialog> whose h2 is the type name.',
`Step 1, purposes: 6 checkboxes in this order: ${APPLE_PURPOSES.map(purposeLabel).join(', ')} (Developer’s with a curly ’). An input has no aria label; its label is the nearest non-empty parent text (the label and its help text run together, e.g. "AnalyticsSuch as ..."): match by "starts with the label". Buttons: Cancel, Next.`,
`Never a surprise (verified 2026-10-21): ${APPLE_ADDITIONAL_SETUP}`,
'Step 2, linked: 2 radios, "Yes, <type in lower case> data collected from this app is linked to the user’s identity" / "No, …" (curly ’). Buttons: Back, Cancel, Next.',
'Step 3: an information page "Let’s define two important terms…" (Tracking / Third-Party Data). No inputs: press Next.',
'Step 4: an information page "To help put tracking into context, here are a few examples:". No inputs: press Next. (Two information steps in a row: keep pressing Next while the dialog shows no inputs.)',
@ -384,9 +398,9 @@ function otherPlan(form: FormId, config: ShipConfig, answers: PlanAnswer[], p: P
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: `Digital Purchases follow-ups: Yes on "${IARC_DIGITAL_PURCHASES}" reveals checkboxes; tick "${IARC_DIGITAL_GOODS}" (and only the other boxes of the sheet) first: the random items (loot box) and player trading questions show only after that. Match each by its exact console label, never by keywords (its text also mentions cash rewards).`, 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: `Press Save (stores the draft; nothing goes to IARC yet), wait for "${SAVE_CONFIRM}", then Next: the Summary shows the rating per authority (PEGI, ESRB, USK, ClassInd, Google Play ...).`, click: ['Save', 'Next'] });
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 {
@ -396,6 +410,7 @@ function otherPlan(form: FormId, config: ShipConfig, answers: PlanAnswer[], p: P
`App content > Content ratings is /app-content/content-rating-overview (${consoleUrl('play.content-rating', planIds)}); "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.',
`Verified 2026-10-21: Yes on "${IARC_DIGITAL_PURCHASES}" reveals checkboxes ("${IARC_DIGITAL_GOODS}" and others); the loot-box and trading follow-ups show only once they are ticked. Answer by exact console label: a keyword helper answered it No because its text also mentions "cash rewards".`,
'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 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+.',
@ -429,7 +444,21 @@ 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, 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.`;
/** Play: an answered page whose Save sits next to Next is saved (and confirmed) before Next; inserted into every Play plan. */
export function saveBeforeNext(steps: PlanStep[]): PlanStep[] {
return steps.map(st => {
const c = st.click ?? [];
const i = c.indexOf('Next');
if (i < 0) return st;
const answered = (st.answers?.length ?? 0) > 0 || c.slice(0, i).includes('Save');
if (!answered) return st;
const click = c.slice(0, i).includes('Save') ? c : [...c.slice(0, i), 'Save', ...c.slice(i)];
return { ...st, click, text: st.text.includes(SAVE_CONFIRM) ? st.text : `${st.text} ${SAVE_EACH_PAGE}` };
});
}
export const SAVE_CONFIRM = 'Your changes have been saved';
const PLAY_SAVE_RULE = ` On a Play page with a Save next to Next: answer the page, press Save, wait for "${SAVE_CONFIRM}", only then Next.`;
const ruleText = (draftSaves: string[], final: string, declarationSaves: string[] = [], play = false) => `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.${play ? PLAY_SAVE_RULE : ''}`;
const IARC_TERMS = 'Tick the IARC Terms of Use box';
/** Where the IARC Terms box is (verified 2026-10-12 / 2026-10-13). */
@ -485,7 +514,7 @@ function appContentPlan(cfg: ShipConfig, p: PlanIds, all = false): ConsolePlan {
askOwner: cards.flatMap(({ card, plan }) => (plan ? plan.askOwner.map(l => `${card.name}: ${l}`) : [])),
buttons: {
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']),
rule: ruleText(['Save draft / Save on the IARC questionnaire page'], SEND_FOR_REVIEW, ['each card\'s Save', 'the content rating Summary\'s Save'], true),
},
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:',
@ -510,8 +539,8 @@ export function consolePlan(form: PlanFormId, config: ShipConfig, p: PlanIds = {
if (isPlanOnly(form)) {
const part = playOnlyPart(form, cfg, p);
return {
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)) },
form, store: 'play', title: part.title, ownerBatch: ownerBatchOf(form, cfg, p, part.answers, part.final), start: part.start, steps: saveBeforeNext(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), true) },
uiFacts: part.uiFacts, driving: DRIVING, inPageLoop: IN_PAGE_LOOP, verify: part.verify, fillable: true, guidance: part.guidance,
};
}
@ -527,11 +556,11 @@ export function consolePlan(form: PlanFormId, config: ShipConfig, p: PlanIds = {
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, ownerBatch: ownerBatchOf(form, cfg, p, answers, final), ...part, answers, askOwner,
form, store: meta.store, title: meta.title, ownerBatch: ownerBatchOf(form, cfg, p, answers, final), ...part, ...(meta.store === 'play' ? { steps: saveBeforeNext(part.steps) } : {}), 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, rules.filter(r => r.class === 'declaration-save').map(r => r.button)),
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), meta.store === 'play'),
},
driving: DRIVING, inPageLoop: IN_PAGE_LOOP,
fillable: meta.fillable,

View file

@ -86,8 +86,25 @@ interface Part {
guidance: string[];
}
/**
* The plan's in-page snippet for Play declarations: every visible radio / checkbox / option with its label, its
* checked or selected state (aria-checked, the checked property, aria-selected) and its group (the first text line of
* the nearest radiogroup / fieldset / group), plus the page text. Prints JSON for `console verify <form> --from -`.
*/
export const INPUT_STATES_CAPTURE = `(() => { const n = s => (s || '').replace(/\\s+/g, ' ').trim();
const label = e => n(e.getAttribute('aria-label')) || n(e.labels?.[0]?.innerText) || (() => { for (let a = e.parentElement; a && a !== document.body; a = a.parentElement) { const t = n(a.innerText); if (t) return t; } return ''; })();
const role = e => e.getAttribute('role') || e.type;
const on = e => e.hasAttribute('aria-checked') ? e.getAttribute('aria-checked') === 'true' : e.hasAttribute('aria-selected') ? e.getAttribute('aria-selected') === 'true' : !!e.checked;
const group = e => { const g = e.closest('[role=radiogroup], fieldset, [role=group], material-radio-group'); return g ? n((g.innerText || '').split('\\n')[0]) : ''; };
const states = [...document.querySelectorAll('[role=radio], [role=checkbox], [role=option], input[type=radio], input[type=checkbox]')].filter(e => e.getClientRects().length && !(e.matches('input') && e.closest('[role=radio], [role=checkbox]')))
.map(e => ({ label: label(e), role: role(e), checked: on(e), group: group(e) }));
return JSON.stringify({ url: location.href, states, text: (document.querySelector('main') ?? document.body).innerText }); })()`;
const PAGE_TEXT = 'document.querySelector("main")?.innerText ?? document.body.innerText';
const pageVerify = (form: PlanFormId, expect: string[]): ConsolePlan['verify'] => ({ command: `storebound console verify ${form} --from-text -`, source: 'page-text', capture: PAGE_TEXT, expect });
// declarations: the input-state snippet (radios / boxes are not readable from text); the plain page text still works
const pageVerify = (form: PlanFormId, expect: string[]): ConsolePlan['verify'] => (['play-store-settings', 'play-privacy-policy', 'play-production-countries', 'play-production-release'].includes(form)
? { command: `storebound console verify ${form} --from-text -`, source: 'page-text', capture: PAGE_TEXT, expect }
: { command: `storebound console verify ${form} --from -`, source: 'page-text', capture: INPUT_STATES_CAPTURE, expect: [...expect, 'Run verify.capture in the page (one short call; it returns JSON with every radio / checkbox and its checked state) and pipe that JSON to the command.'] });
const ans = (id: string, question: string, value: string | boolean | null, status: PlanStatus, source: string, extra: Partial<PlanAnswer> = {}): PlanAnswer => ({
id, question, value: status === 'ask-owner' || status === 'agreement' ? null : value,
@ -198,7 +215,7 @@ function adsDeclaration(config: ShipConfig, p: PlanIds): Part {
{ text: `Open App content > Ads: ${start(config, p, 'app-content/ads-declaration', []).url}` },
{ text: 'Pick the radio (JS click on the inner [role=radio]); read aria-checked back.', answers: ['ads'] },
{ text: DECLARATION_SAVE },
{ text: 'Verify: read the page text and pipe it to `storebound console verify play-ads --from-text -`.' },
{ text: 'Verify: run verify.capture in the page (the input states as JSON: radios and boxes are not in page text) and pipe it to `storebound console verify play-ads --from -`.' },
]),
answers: [a ? ans('ads', 'Does your app contain ads?', a.v, 'certain', a.src, { control: 'radio', action: 'pick', label: yesNo(a.v, yes, no), where: 'Ads' })
: ans('ads', 'Does your app contain ads?', null, 'ask-owner', 'console.targetAudience.containsAds', { control: 'radio', where: 'Ads', note: 'Any ad SDK (banner, rewarded, house ads of other apps) is Yes; set console.targetAudience.containsAds.' })],
@ -216,6 +233,11 @@ export const GUEST_ENTRY_NAME = 'Guest play (no account needed)';
/** The Add instructions dialog's required box: an agreement, ticked by the owner only. */
export const PLAY_FULL_ACCESS_BOX = 'Sign in details in this declaration provide full access to all the features and content within this app, including premium or paid content';
/** The Sign in details card's first question (2026-10-21: Yes / No radios; before that two long labelled radios, kept as altLabels). */
export const PLAY_RESTRICTED_QUESTION = 'Is any part of your app restricted?';
/** The dialog opener: "Add details" since 2026-10-21; "Add instructions" before (the fallback). */
export const PLAY_ADD_DETAILS = 'Add details', PLAY_ADD_DETAILS_OLD = 'Add instructions';
function signInDetails(config: ShipConfig, p: PlanIds): Part {
const ra = config.reviewAccess;
const all = 'All functionality in my app is available without any access restrictions', some = 'All or some functionality in my app is restricted';
@ -225,8 +247,8 @@ function signInDetails(config: ShipConfig, p: PlanIds): Part {
// one entry that has no credentials and says how to get in as a guest
const guest = !!ra && !ra.signInRequired && !!ra.instructions?.trim();
const restricted = !!ra && (ra.signInRequired || guest);
const q = 'Is any functionality restricted (sign-in, membership, location)?';
const out: PlanAnswer[] = [ra ? ans('access', q, restricted, 'certain', guest ? 'reviewAccess.instructions (guest steps, no sign-in)' : 'reviewAccess.signInRequired', { control: 'radio', action: 'pick', label: restricted ? some : all, where: 'Sign in details (App access)',
const q = PLAY_RESTRICTED_QUESTION;
const out: PlanAnswer[] = [ra ? ans('access', q, restricted, 'certain', guest ? 'reviewAccess.instructions (guest steps, no sign-in)' : 'reviewAccess.signInRequired', { control: 'radio', action: 'pick', label: restricted ? 'Yes' : 'No', altLabels: [restricted ? some : all], where: 'Sign in details (App access)',
...(guest ? { note: 'No sign-in, but guest steps: answered restricted so the reviewer reads them (Play rejected a guest-play game declared unrestricted as a login wall).' } : {}) })
: ans('access', q, null, 'ask-owner', 'reviewAccess.signInRequired', { control: 'radio', where: 'Sign in details', note: 'Set reviewAccess { signInRequired } (review-access skill).' })];
const long = (t: string) => t.length > PLAY_ACCESS_INFO_MAX ? { note: `${t.length} characters: Play takes at most ${PLAY_ACCESS_INFO_MAX}; shorten reviewAccess.instructions.` } : {};
@ -251,25 +273,25 @@ function signInDetails(config: ShipConfig, p: PlanIds): Part {
}
// the dialog's required declaration box: an agreement, so the owner ticks it (agents never tick declarations)
if (restricted) out.push(ans('fullAccess', 'Do these sign in details give full access to every feature and content, including premium or paid content?', null, 'agreement', 'Play Add instructions dialog (required)', { control: 'checkbox', label: PLAY_FULL_ACCESS_BOX, where: 'Add instructions dialog, required box' }));
const add = 'Add instructions';
const add = PLAY_ADD_DETAILS, addOld = PLAY_ADD_DETAILS_OLD;
return {
title: 'Play Console: Sign in details (App access)',
start: start(config, p, 'app-content/testing-credentials', appContentReach('Sign in details')),
steps: steps([
{ text: `Open App content > Sign in details: ${start(config, p, 'app-content/testing-credentials', []).url}` },
{ text: 'Pick whether any functionality is restricted (material radio: JS click on the inner [role=radio]).', answers: ['access'] },
{ text: `"${PLAY_RESTRICTED_QUESTION}": pick ${restricted ? 'Yes' : 'No'} (material radio: JS click on the inner [role=radio]; older pages show the long labels "${some}" / "${all}" instead). The dialog button is "${add}" (older pages: "${addOld}").`, answers: ['access'] },
guest && { text: `Guest steps (no sign-in): press "${add}", type the Name "${GUEST_ENTRY_NAME}", leave Username and Password empty, and type reviewAccess.instructions into "${info}" (at most ${PLAY_ACCESS_INFO_MAX} characters).`, click: [add], answers: ['entryName', 'credentials', 'instructions'] },
ra?.signInRequired && { text: p.demoAccount
? `Restricted: press "${add}", 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}", type the instructions; the Username and Password are the owner's (no reviewDemoAccount in .storebound/credentials.json: in the owner batch).`, click: [add], answers: ['instructions', 'credentials'] },
restricted && { text: `Leave the required box "${PLAY_FULL_ACCESS_BOX}" unticked and stop: the owner ticks it (a declaration). Then press "Add" (closes the dialog; the entry is listed).`, click: ['Add'], answers: ['fullAccess'] },
{ text: DECLARATION_SAVE },
{ text: 'Verify: read the page text and pipe it to `storebound console verify play-sign-in-details --from-text -`.' },
{ text: 'Verify: run verify.capture in the page (the input states as JSON: radios and boxes are not in page text) and pipe it to `storebound console verify play-sign-in-details --from -`.' },
]),
answers: out,
...declarationButtons('Sign in details', [{ button: add, class: 'navigation', where: 'Sign in details: opens the instructions dialog' },
...declarationButtons('Sign in details', [{ button: add, class: 'navigation', where: 'Sign in details: opens the details dialog' }, { button: addOld, class: 'navigation', where: 'Sign in details: the same dialog opener on older pages' },
...(restricted ? [{ button: 'Add', class: 'navigation', where: 'Add instructions dialog: lists the entry on the card (nothing is stored before Save)' }] : [])]),
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.',
uiFacts: ['Verified 2026-10-12: the Sign in details card (App access) is /app-content/testing-credentials.', `Verified 2026-10-21: the first question reads "${PLAY_RESTRICTED_QUESTION}" with Yes / No radios, and the dialog opens with "${add}" (was "${addOld}"; try both).`, '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.',
`The Add instructions dialog: Name, Username, Password, "${info}" (at most ${PLAY_ACCESS_INFO_MAX} characters) and the required box "${PLAY_FULL_ACCESS_BOX}" (the owner's).`,
'A guest-play game declared "no restrictions" was rejected as a login wall (empty name field, Continue disabled, an "I have a code" link on the first screen): with guest steps the card says restricted with one entry without credentials.', PLAY_MATERIAL_FACT],
verify: pageVerify('play-sign-in-details', [guest ? `Restricted is selected; one entry "${GUEST_ENTRY_NAME}" with no username or password is listed.` : 'The selected radio matches reviewAccess.signInRequired; with a sign-in, one set of instructions is listed.']),
@ -325,7 +347,7 @@ function advertisingId(config: ShipConfig, p: PlanIds): Part {
{ text: 'Pick Yes or No (material radio: JS click on the inner [role=radio]).', answers: ['uses'] },
uses.v === true && { text: 'Tick exactly the purposes of the sheet (material checkboxes: JS click on the inner [role=checkbox]; read aria-checked back).', answers: out.filter(a => a.id.startsWith('purpose')).map(a => a.id) },
{ text: DECLARATION_SAVE },
{ text: 'Verify: read the page text and pipe it to `storebound console verify play-advertising-id --from-text -`.' },
{ text: 'Verify: run verify.capture in the page (the input states as JSON: radios and boxes are not in page text) and pipe it to `storebound console verify play-advertising-id --from -`.' },
]),
answers: out,
...declarationButtons('Advertising ID'),
@ -344,7 +366,7 @@ function government(config: ShipConfig, p: PlanIds): Part {
{ text: `Open App content > Government apps: ${start(config, p, 'app-content/government-apps', []).url}` },
{ text: 'Pick whether the app is developed by or on behalf of a government (material radio).', answers: ['government'] },
{ text: DECLARATION_SAVE },
{ text: 'Verify: read the page text and pipe it to `storebound console verify play-government-apps --from-text -`.' },
{ text: 'Verify: run verify.capture in the page (the input states as JSON: radios and boxes are not in page text) and pipe it to `storebound console verify play-government-apps --from -`.' },
]),
answers: [v !== undefined ? ans('government', 'Is the app developed by or on behalf of a government?', v, 'certain', 'console.appContent.government', { control: 'radio', action: 'pick', label: v ? 'Yes' : 'No', where: 'Government apps' })
: ans('government', 'Is the app developed by or on behalf of a government?', null, 'ask-owner', 'console.appContent.government', { control: 'radio', where: 'Government apps', note: 'Set console.appContent.government (true only for a government body or its contractor).' })],
@ -356,31 +378,43 @@ function government(config: ShipConfig, p: PlanIds): Part {
}
/** Financial features / Health apps: two steps, the features on the last one (or the none box). */
/** The health card's none option (offered since 2026-10-21). */
export const PLAY_NO_HEALTH = 'My app does not have any health features';
const TICK_HOW = 'material checkboxes: JS click on the inner [role=checkbox]; read aria-checked back';
/** Multi-step Play forms with a Save next to Next: Save each page before Next (an unsaved page is lost on Next). */
export const SAVE_EACH_PAGE = 'Press this page\'s "Save" right after answering it and wait for "Your changes have been saved" BEFORE pressing Next (Next does not keep an unsaved page).';
function featureCard(config: ShipConfig, p: PlanIds, kind: 'finance' | 'health'): Part {
const fin = kind === 'finance';
const form: PlanOnlyFormId = fin ? 'play-financial-features' : 'play-health-apps';
const name = fin ? 'Financial features' : 'Health apps';
const key = fin ? 'financialFeatures' : 'healthFeatures';
const v = config.console?.appContent?.[key];
const none = fin ? 'My app doesn\'t provide any financial features' : 'Other';
const none = fin ? 'My app doesn\'t provide any financial features' : PLAY_NO_HEALTH;
const out: PlanAnswer[] = v === false
? [ans('none', fin ? 'No financial features' : 'No health features', true, 'certain', `console.appContent.${key}`, { control: 'checkbox', action: 'tick', label: none, curlyQuotes: false, where: `${name}, last step`,
...(fin ? {} : { note: 'On the verified run (2026-10-12) the no-health-features answer was the last step\'s "Other" box; when the page offers an explicit none option, tick that instead.' }) })]
: Array.isArray(v) ? v.map(l => ans(`feature:${l}`, `${name}: ${l}`, true, 'certain', `console.appContent.${key}`, { control: 'checkbox', action: 'tick', label: l, where: `${name}, last step` }))
: [ans('features', `${name}: which features does the app provide?`, null, 'ask-owner', `console.appContent.${key}`, { control: 'checkbox', where: `${name}, last step`, note: `Set console.appContent.${key}: false for none, or the console's labels to tick.` })];
? [ans('none', fin ? 'No financial features' : 'No health features', true, 'certain', `console.appContent.${key}`, { control: 'checkbox', action: 'tick', label: none, curlyQuotes: false, where: fin ? `${name}, step 1` : `${name}, last step`,
...(fin ? {} : { altLabels: ['Other'], note: `Verified 2026-10-21: the page offers "${PLAY_NO_HEALTH}"; tick it. Only an older page without it took "Other" (2026-10-12).` }) })]
: Array.isArray(v) ? v.map(l => ans(`feature:${l}`, `${name}: ${l}`, true, 'certain', `console.appContent.${key}`, { control: 'checkbox', action: 'tick', label: l, where: fin ? `${name}, step 1` : `${name}, last step` }))
: [ans('features', `${name}: which features does the app provide?`, null, 'ask-owner', `console.appContent.${key}`, { control: 'checkbox', where: fin ? `${name}, step 1` : `${name}, last step`, note: `Set console.appContent.${key}: false for none, or the console's labels to tick.` })];
return {
title: `Play Console: ${name} (App content)`,
start: start(config, p, `app-content/${kind}`, appContentReach(name)),
steps: steps([
{ text: `Open App content > ${name}: ${start(config, p, `app-content/${kind}`, []).url}` },
{ text: 'Step 1: read it and press Next.', click: ['Next'] },
{ text: `Last step: tick exactly the boxes of the sheet (material checkboxes: JS click on the inner [role=checkbox]; read aria-checked back)${fin ? '; no financial features: "My app doesn\'t provide any financial features"' : '; no health features: "Other" (as on the verified run) or the none option the page offers'}. Then Next if the page shows one.`, click: ['Next'], answers: out.map(a => a.id) },
...(fin ? [
{ text: `Step 1: tick exactly the boxes of the sheet (${TICK_HOW}); no financial features: "My app doesn't provide any financial features". ${SAVE_EACH_PAGE} Then Next.`, click: ['Save', 'Next'], answers: out.map(a => a.id) },
{ text: 'Step 2: an information page only (no inputs): read it.' },
] : [
{ text: 'Step 1: read it and press Next.', click: ['Next'] },
{ text: `Last step: tick exactly the boxes of the sheet (${TICK_HOW}); no health features: "${PLAY_NO_HEALTH}" (an older page without it: "Other").`, answers: out.map(a => a.id) },
]),
{ text: DECLARATION_SAVE },
{ text: `Verify: read the page text and pipe it to \`storebound console verify ${form} --from-text -\`.` },
{ text: `Verify: run verify.capture in the page (the input states as JSON: radios and boxes are not in page text) and pipe it to \`storebound console verify ${form} --from -\`.` },
]),
answers: out,
...declarationButtons(name, [{ button: 'Next', class: 'navigation', where: `${name}: between the two steps` }]),
uiFacts: [`Verified 2026-10-12: /app-content/${kind}, two steps; the last step holds ${fin ? '"My app doesn\'t provide any financial features"' : 'the health feature boxes, "Other" among them'}; then Next and Save.`, PLAY_MATERIAL_FACT],
uiFacts: [fin ? 'Verified 2026-10-21: /app-content/finance, two steps; the checkboxes ("My app doesn\'t provide any financial features" among them) are on step 1, step 2 is an information page only; then Save.'
: `Verified 2026-10-21: /app-content/health, two steps; the last step holds the health feature boxes and "${PLAY_NO_HEALTH}" (before: only "Other" fit); then Save.`, PLAY_MATERIAL_FACT],
verify: pageVerify(form, ['The ticked boxes match the sheet.']),
guidance: [`Open: Policy and programs > App content > ${name}: ${start(config, p, `app-content/${kind}`, []).url}`],
};

View file

@ -337,6 +337,23 @@ export function verifyAppContent(text: string): VerifyResult {
return result('play-app-content', st ? 'inputs' : 'page-text', rows, st ? [] : ['The start buttons are aria-labels, not page text: pass the JSON of the plan\'s capture snippet.']);
}
/** One labelled control read from the DOM by INPUT_STATES_CAPTURE. */
export interface InputState { label: string; role: 'radio' | 'checkbox' | 'option' | string; checked: boolean; group?: string }
export { INPUT_STATES_CAPTURE } from './console-play-plans';
/** The input states of a capture JSON ({ states, text }); null for plain page text. */
export function parseStates(raw: string): { states: InputState[]; text: string } | null {
const s = raw.trim();
if (!s.startsWith('{')) return null;
try {
const j = JSON.parse(s) as { states?: unknown; text?: unknown };
if (!Array.isArray(j.states)) return null;
const states = j.states.filter((x): x is InputState => !!x && typeof x === 'object' && typeof (x as InputState).label === 'string' && typeof (x as InputState).checked === 'boolean');
return { states, text: typeof j.text === 'string' ? j.text : '' };
} catch { return null; }
}
/**
* Store settings and the single App content declarations, from the page text after Save: text answers (the category,
* the privacy policy URL) and option labels must appear; the App category's "Not selected" is a mismatch. Yes / No
@ -344,19 +361,39 @@ export function verifyAppContent(text: string): VerifyResult {
*/
export function verifyPlayDeclaration(form: Exclude<PlanOnlyFormId, 'play-app-content'>, config: ShipConfig, text: string): VerifyResult {
const part = playOnlyPart(form, config, {});
const blob = fold(text);
const st = parseStates(text);
const blob = fold(st ? st.text : text);
const rows: VerifyRow[] = [];
const radios = st?.states.filter(x => /radio/.test(x.role)) ?? [];
const radioQuestions = part.answers.filter(a => a.control === 'radio').length;
for (const a of part.answers) {
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 labels = [a.label, ...(a.altLabels ?? [])].filter((l): l is string => !!l);
const want = a.action === 'type' || a.action === 'choose' ? String(a.value) : a.label ?? '';
if (st && (a.control === 'radio' || a.control === 'checkbox') && labels.length) {
// input states from the DOM: the control with this (or an older) label, checked as the sheet says
let pool = a.control === 'radio' ? radios : st.states.filter(x => /checkbox|option/.test(x.role));
if (a.control === 'radio') {
const byQ = pool.filter(x => x.group && fold(x.group).startsWith(fold(a.question)));
if (byQ.length) pool = byQ;
else if (radioQuestions > 1) pool = []; // several radio questions: only an exact question group tells them apart
}
const hit = pool.find(x => labels.some(l => fold(x.label) === fold(l)));
const wantOn = a.action !== 'untick';
const picked = a.control === 'radio' ? pool.filter(x => x.checked).map(x => x.label) : [];
rows.push(hit
? { item: a.question, expected: `${labels[0]} ${wantOn ? 'checked' : 'unchecked'}`, seen: `${hit.label} ${hit.checked ? 'checked' : 'unchecked'}${!hit.checked && picked.length ? ` (checked: ${picked.join(', ')})` : ''}`, status: hit.checked === wantOn ? 'match' : 'mismatch' }
: { item: a.question, expected: `${labels[0]} ${wantOn ? 'checked' : 'unchecked'}`, seen: 'no control with that label', status: 'not-found' });
continue;
}
const plain = !want || /^(yes|no)$/i.test(want) || a.action === 'untick';
if (plain) { rows.push({ item: a.question, expected: a.text, seen: 'not readable from page text', status: 'not-found', note: 'read the control (aria-checked) in the page' }); continue; }
const shown = blob.includes(fold(want));
if (plain) { rows.push({ item: a.question, expected: a.text, seen: 'not readable from page text', status: 'not-found', note: 'pass the input states: run the plan\'s verify.capture snippet and `--from -`' }); continue; }
const shownAs = a.action === 'type' || a.action === 'choose' ? (blob.includes(fold(want)) ? want : null) : labels.find(l => blob.includes(fold(l))) ?? null;
const unset = a.id === 'category' && /not selected|select a category/.test(blob);
rows.push({ item: a.question, expected: want, seen: shown ? want : unset ? 'Not selected' : 'not found', status: shown && !unset ? 'match' : unset ? 'mismatch' : 'not-found' });
rows.push({ item: a.question, expected: want, seen: shownAs ?? (unset ? 'Not selected' : 'not found'), status: shownAs && !unset ? 'match' : unset ? 'mismatch' : 'not-found' });
}
return result(form, 'page-text', rows, ['Page text after Save: Yes / No radios and unticked boxes are not readable from text and are never counted as a match.']);
return result(form, st ? 'inputs' : 'page-text', rows, st ? ['Input states from the DOM (aria-checked, checked, aria-selected).'] : ['Page text after Save: Yes / No radios and unticked boxes are not readable from text and are never counted as a match; pass the input states (plan verify.capture, `--from -`).']);
}
/** The production track's Countries / regions tab: "<N> countries / regions" (0 is a mismatch) and, with

View file

@ -212,11 +212,65 @@ export function ascKey(root: string): AscKey {
return { keyId, issuerId, pem: inline(p8, /BEGIN PRIVATE KEY/) };
}
/** The keys and nestings a review contact is read from (first hit): the documented one, then shapes seen in real files. */
export const REVIEW_CONTACT_KEYS = ['reviewContact', 'apple.reviewContact', 'appReview.contact', 'review.contact', 'reviewDetails.contact', 'contact'] as const;
const pickStr = (o: Record<string, unknown>, ...ks: string[]): string => { for (const k of ks) { const v = o[k]; if (typeof v === 'string' && v.trim()) return v.trim(); } return ''; };
/** A contact object in any accepted field spelling (firstName / first_name / contactFirstName; a "name" is split). */
export function contactOf(raw: unknown): ReviewContact | null {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
const o = raw as Record<string, unknown>;
const email = pickStr(o, 'email', 'contactEmail', 'emailAddress');
if (!email || isPlaceholder(email, { example: false })) return null;
let firstName = pickStr(o, 'firstName', 'first_name', 'contactFirstName', 'givenName'), lastName = pickStr(o, 'lastName', 'last_name', 'contactLastName', 'familyName');
const name = pickStr(o, 'name', 'fullName');
if (!firstName && !lastName && name) { const parts = name.split(/\s+/); firstName = parts[0] ?? ''; lastName = parts.slice(1).join(' '); }
return { firstName, lastName, phone: pickStr(o, 'phone', 'contactPhone', 'phoneNumber'), email };
}
const atPath = (o: Store, path: string): unknown => path.split('.').reduce<unknown>((x, k) => (x && typeof x === 'object' ? (x as Store)[k] : undefined), o);
/** Where a review contact is looked up, in order: the project's .storebound/credentials.json, the same file in each
* parent folder up to the repository root (a config in a subfolder), then the project's credential profile file. */
function contactFiles(root: string): string[] {
const out: string[] = [];
let d = resolve(root);
for (let i = 0; i < 6; i++) {
out.push(join(d, '.storebound', 'credentials.json'));
if (existsSync(join(d, '.git'))) break;
const up = resolve(d, '..');
if (up === d || up === homedir()) break;
d = up;
}
try { const p = activeProfile(root); if (p && PROFILE_RE.test(p.name)) out.push(join(profileDir(), `${p.name}.json`)); } catch { /* unreadable project file: listed below */ }
return out;
}
/** App Review contact (personal data, never in the config), the file and key it came from, and every place checked. */
export function findReviewContact(root: string): { contact: ReviewContact | null; from: string | null; checked: string[] } {
const checked: string[] = [];
for (const f of contactFiles(root)) {
if (!existsSync(f)) { checked.push(`${f} (missing)`); continue; }
let st: Store;
try { st = JSON.parse(readFileSync(f, 'utf8')) as Store; } catch (e) { checked.push(`${f} (unreadable: ${(e as Error).message})`); continue; }
for (const k of REVIEW_CONTACT_KEYS) {
const c = contactOf(atPath(st, k));
if (c) return { contact: c, from: `${f} ${k}`, checked };
}
checked.push(`${f} (keys ${REVIEW_CONTACT_KEYS.join(', ')}: none with an email)`);
}
return { contact: null, from: null, checked };
}
/** The error when no review contact is found: every file and key that was checked, and the fix. */
export function noContactMessage(root: string, checked: string[] = findReviewContact(root).checked): string {
return `no review contact. Checked: ${checked.join('; ') || '(nothing)'}. Add "reviewContact": { "firstName", "lastName", "phone", "email" } to ${join(resolve(root), '.storebound', 'credentials.json')}, or pass --contact-from <Apple ID of another app>`;
}
/** App Review contact from .storebound/credentials.json (personal data, so never in the config). */
export function reviewContact(root: string): ReviewContact | null {
const c = fileStore(root).reviewContact as Partial<ReviewContact> | undefined;
if (!c?.email || isPlaceholder(c.email, { example: false })) return null;
return { firstName: c.firstName ?? '', lastName: c.lastName ?? '', phone: c.phone ?? '', email: c.email };
return findReviewContact(root).contact;
}
export interface DemoAccount { username: string; password: string }

View file

@ -431,7 +431,7 @@ export const TOOLS: ToolDef[] = [
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.',
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 privacy policy: the page text after Save; the other App content cards (ads, sign-in details, advertising ID, government, financial, health): the JSON of the plan\'s verify.capture snippet, which reads radios and boxes from the DOM: aria-checked, checked, aria-selected). 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 },
text: { type: 'string', description: 'The page text (or the capture JSON) of the finished form' } },
required: ['form'], args: i => [String(i.form), ...(typeof i.text === 'string' && i.text ? [`--text=${i.text}`] : []), '--json'] },

View file

@ -65,8 +65,7 @@ async function configAppPrivacy(): Promise<{ trackingDomains: string[]; types: P
}
export async function run(args: Args): Promise<void> {
const ios = args.get('ios');
if (!ios) throw new Error('usage: privacy-manifest --ios <path to the iOS project folder, e.g. ios/App> [--sdk-dirs a,b] [--json]');
const ios = args.required('ios', 'path', 'the iOS project folder, e.g. ios/App');
const root = resolve(ios);
if (!existsSync(root)) throw new Error(`${root} does not exist`);
const sdks: SdkInfo[] = [];

View file

@ -117,8 +117,9 @@ the UI shows. This is the primary mode.
the Category dropdown's `[role=option]` with exactly the category name (`play.category`) > Save.
- **Play App content** (2026-10-12): `/app-content/overview`; each card has a button aria-label="Start <Name>
declaration". Pages: privacy-policy, ads-declaration, testing-credentials (Sign in details), ad-id-declaration,
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),
government-apps, finance (2 steps, 2026-10-21: the checkboxes, "My app doesn't provide any financial features"
among them, are on step 1; step 2 is an information page only), health (2 steps; 2026-10-21: the last step offers
"My app does not have any health features", tick that; only an older page without it took "Other"),
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. Each card's Save is yours
(2026-10-13: nothing goes to Google before the owner's "Send changes for review").
@ -131,6 +132,21 @@ the UI shows. This is the primary mode.
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 button is **Save**, not Submit: yours
(a declaration save). Example: PEGI 7, ESRB E10+, USK 12, ClassInd 18 (paid random items), Google Play 7+.
- **Save after every page** (Play, 2026-10-21): on a multi-step Play form with a Save next to Next (the IARC
questionnaire, finance, target audience), answer the page, press **Save**, wait for "Your changes have been saved",
and only then press Next. The plan steps and the rule line say so on every such step.
- **IARC Digital purchases** (2026-10-21): Yes on "Digital purchases" reveals checkboxes ("Purchases of digital goods"
and others); tick them first, the loot-box and trading follow-ups show only after that. Answer every IARC question
by its exact console label: a keyword helper answered this one No because its text also mentions "cash rewards". The
in-page helper refuses patterns, radios without their exact question (`within`) and blanket group answers.
- **Sign in details** (2026-10-21): the first question reads "Is any part of your app restricted?" with Yes / No; the
dialog button is "Add details" (older pages: the long radio labels and "Add instructions"; the plan keeps both).
- **App Privacy "Additional Setup Required"** (2026-10-21): after saving the data types this message appears; its OK
sometimes opens the first type's setup dialog by itself. That dialog IS the first type's setup: carry on from step 1
there, do not press its Set Up again.
- **Verify reads input states** (2026-10-21): Yes / No radios and unticked boxes are not in page text. Run the plan's
`verify.capture` snippet (it returns every radio / checkbox with aria-checked, checked or aria-selected as JSON) and
pipe it to `storebound console verify <form> --from -`; the page-text path stays for text answers.
- **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

View file

@ -1049,3 +1049,30 @@ that fails without the fix (see CONTRIBUTING.md).
smaller than asked, point `STOREBOUND_CHROME` at a current Chromium or record with `--source folder`.
- **Guard:** `recordSize` / `captureShortfall` in `cli/shots/devices.ts` (first frame size checked per clip, plus a
frames-per-second warning under 20); `tests/video.test.ts`.
## 86. Console plans behind the live pages (Play App content, IARC, App Privacy; 2026-10-21)
- **Symptom:** on a real run the health card offered "My app does not have any health features" while the sheet said
"Other" and verify said "not found"; Sign in details asked "Is any part of your app restricted? Yes / No" with an
"Add details" button; finance had its boxes on step 1 (step 2 is information only); App Privacy's "Additional Setup
Required" OK opened the first type's setup by itself; verify could not read Yes / No radios from text; a page
answered and then left with Next lost its answers; a keyword helper answered IARC "Digital purchases" No because its
text mentions "cash rewards", so the digital goods boxes and the loot-box / trading follow-ups never showed.
- **Do:** follow the plan's labels (new label first, `altLabels` as the fallback). On every Play page with a Save next
to Next: Save, wait for "Your changes have been saved", then Next. In App Privacy, a setup dialog that opened by
itself after the OK is the first type's: carry on there. Verify with the plan's `verify.capture` JSON
(`console verify <form> --from -`). Answer IARC by exact console labels only, never keyword sweeps over unanswered
groups; tick "Purchases of digital goods" after Yes on "Digital purchases".
- **Guard:** `cli/lib/console-play-plans.ts` (labels, step order, `INPUT_STATES_CAPTURE`), `saveBeforeNext` and the
exact-label `IN_PAGE_LOOP` in `cli/lib/console-plan.ts`, `parseStates` in `cli/lib/console-verify.ts`;
`tests/console-guardrails-1021.test.ts`.
## 87. "no review contact" with a reviewContact in the credentials file; whole help for a missing --version
- **Symptom:** `apple review-details` said "no review contact" while `.storebound/credentials.json` had
`reviewContact { firstName, lastName, phone, email }` (only the project folder's own file was read, under one key);
`apple listing` without `--version` (or with `--version` and no value) printed usage text or went on with "true".
- **Do:** update storebound. The contact is read from the project's file, the same file in parent folders up to the
repository root and the credential profile, under `reviewContact` (also `apple.reviewContact`, `appReview.contact`,
ASC field names); when none is found the error lists every file and key checked. Missing required values are one
line: "--version <x.y.z> is required (storebound apple status shows the editable version)".
- **Guard:** `findReviewContact` / `noContactMessage` in `cli/lib/credentials.ts`, `Args.required` in
`cli/lib/args.ts` (listing, privacy-manifest, adhoc export, edit, resubmit); `tests/console-guardrails-1021.test.ts`.

View file

@ -0,0 +1,191 @@
// Guardrails from a real run of the console plans (Play Console and App Store Connect, 2026-10-21): new labels with
// the old ones as fallbacks, step order, Save before Next, IARC by exact labels, verify from input states, the review
// contact lookup and one-line errors for missing values. Pure; every id is a placeholder.
import { describe, expect, test } from 'bun:test';
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import type { ShipConfig } from '../cli/lib/config';
import { PLAN_FORM_IDS } from '../cli/lib/console-answers';
import { APPLE_ADDITIONAL_SETUP, consolePlan, IN_PAGE_LOOP, SAVE_CONFIRM, saveBeforeNext } from '../cli/lib/console-plan';
import { INPUT_STATES_CAPTURE, PLAY_ADD_DETAILS, PLAY_ADD_DETAILS_OLD, PLAY_NO_HEALTH, PLAY_RESTRICTED_QUESTION } from '../cli/lib/console-play-plans';
import { parseStates, verifyPlayDeclaration, verifyText } from '../cli/lib/console-verify';
import { contactOf, findReviewContact, noContactMessage } from '../cli/lib/credentials';
import { parseArgs } from '../cli/lib/args';
const base = {
app: { name: 'Example Game', profile: 'game', privacyPolicyUrl: 'https://example.com/privacy', supportUrl: 'https://example.com/support', website: 'https://example.com', contactEmail: 'support@example.com' },
languages: [{ id: 'en', play: 'en-US', apple: 'en-US' }],
play: { packageName: 'com.example.game', developerId: '4444', appId: '9876' },
apple: { bundleId: 'com.example.game' },
products: [{ id: 'com.example.game.coins', type: 'consumable', usd: 0.99, listings: { en: { title: 'Coins', description: 'Coins' } }, randomRewards: false }],
console: { appContent: { financialFeatures: false, healthFeatures: false, government: false, advertisingId: false }, contentRating: { category: 'game' } },
reviewAccess: { signInRequired: false },
} as unknown as ShipConfig;
const json = (states: { label: string; role: string; checked: boolean; group?: string }[], text = '') => JSON.stringify({ states, text });
const REPO_MARK = ['', 'g', 'i', 't'].join('').replace(/^/, '.');
describe('1. health: the new none label, accepted by verify', () => {
test('the sheet ticks the new label, "Other" is the fallback', () => {
expect(consolePlan('play-health-apps', base).answers[0]).toMatchObject({ label: PLAY_NO_HEALTH, altLabels: ['Other'], action: 'tick' });
});
test('verify: page text and input states with the new label match', () => {
expect(verifyText('play-health-apps', base, `Health apps\n${PLAY_NO_HEALTH}\nSave`).ok).toBe(true);
expect(verifyText('play-health-apps', base, json([{ label: PLAY_NO_HEALTH, role: 'checkbox', checked: true }, { label: 'Other', role: 'checkbox', checked: false }])).ok).toBe(true);
expect(verifyText('play-health-apps', base, json([{ label: 'Other', role: 'checkbox', checked: true }])).ok).toBe(true); // an older page
expect(verifyText('play-health-apps', base, json([{ label: PLAY_NO_HEALTH, role: 'checkbox', checked: false }])).mismatches).toBe(1);
});
});
describe('2. sign in details: Yes / No and "Add details", old labels kept', () => {
test('labels', () => {
const p = consolePlan('play-sign-in-details', base);
expect(p.answers[0]).toMatchObject({ question: PLAY_RESTRICTED_QUESTION, label: 'No', altLabels: ['All functionality in my app is available without any access restrictions'] });
const g = consolePlan('play-sign-in-details', { ...base, reviewAccess: { signInRequired: false, instructions: 'Tap Play as guest.' } } as ShipConfig);
expect(g.steps.some(s => s.click?.includes(PLAY_ADD_DETAILS))).toBe(true);
expect(g.steps.some(s => s.text.includes(PLAY_ADD_DETAILS_OLD))).toBe(true);
expect(g.buttons.allowed.map(b => b.button)).toEqual(expect.arrayContaining([PLAY_ADD_DETAILS, PLAY_ADD_DETAILS_OLD]));
expect(g.uiFacts.join(' ')).toContain(PLAY_RESTRICTED_QUESTION);
});
test('verify: the No radio of the question from input states', () => {
const r = verifyText('play-sign-in-details', base, json([{ label: 'Yes', role: 'radio', checked: false, group: PLAY_RESTRICTED_QUESTION }, { label: 'No', role: 'radio', checked: true, group: PLAY_RESTRICTED_QUESTION }]));
expect(r).toMatchObject({ source: 'inputs', ok: true });
});
});
test('3. finance: the boxes on step 1, step 2 information only', () => {
const p = consolePlan('play-financial-features', base);
const tick = p.steps.findIndex(s => s.answers?.includes('none'));
expect(p.steps[tick]!.text).toMatch(/^Step 1: tick/);
expect(p.steps[tick + 1]!.text).toMatch(/^Step 2: an information page only/);
expect(p.answers[0]!.where).toContain('step 1');
});
test('4. App Privacy: the Additional Setup Required OK is in the steps and the UI facts', () => {
const p = consolePlan('apple-app-privacy', { ...base, dataSafety: { collectsData: true, encryptedInTransit: true, data: { PSL_DEVICE_ID: { collect: ['analytics'], shared: false } } } } as ShipConfig);
expect(p.steps.some(s => s.text === APPLE_ADDITIONAL_SETUP && s.click?.includes('OK'))).toBe(true);
expect(p.uiFacts.some(f => f.includes('Additional Setup Required') && f.includes('first type'))).toBe(true);
expect(p.buttons.allowed.map(b => b.button)).toContain('OK');
});
describe('5. verify from input states', () => {
test('the plan gives the snippet and --from', () => {
for (const f of ['play-advertising-id', 'play-government-apps', 'play-ads', 'play-health-apps'] as const) {
const v = consolePlan(f, base).verify;
expect(v.capture).toBe(INPUT_STATES_CAPTURE);
expect(v.command).toBe(`storebound console verify ${f} --from -`);
}
for (const s of ['aria-checked', 'aria-selected', '.checked']) expect(INPUT_STATES_CAPTURE).toContain(s);
expect(() => new Function(`return ${INPUT_STATES_CAPTURE}`)).not.toThrow();
});
test('advertising-id and government: radios read from the JSON, not "not readable"', () => {
const no = json([{ label: 'Yes', role: 'radio', checked: false }, { label: 'No', role: 'radio', checked: true }]);
expect(verifyText('play-advertising-id', base, no)).toMatchObject({ ok: true, source: 'inputs' });
expect(verifyText('play-government-apps', base, no)).toMatchObject({ ok: true });
expect(verifyText('play-government-apps', base, json([{ label: 'Yes', role: 'radio', checked: true }, { label: 'No', role: 'radio', checked: false }])).mismatches).toBe(1);
expect(verifyPlayDeclaration('play-government-apps', base, 'Government apps\nYes\nNo').rows[0]!.seen).toBe('not readable from page text');
expect(parseStates('plain text')).toBeNull();
});
});
describe('8. Save after every page before Next', () => {
test('every Play plan step that answers a page and presses Next saves and confirms first', () => {
let checked = 0;
for (const form of PLAN_FORM_IDS) {
const p = consolePlan(form, base);
if (p.store !== 'play') continue;
for (const s of p.steps) {
const i = s.click?.indexOf('Next') ?? -1;
if (i < 0 || !s.answers?.length) continue;
checked++;
expect(s.click!.slice(0, i)).toContain('Save');
expect(s.text).toContain(SAVE_CONFIRM);
}
expect(p.buttons.rule).toContain(SAVE_CONFIRM);
}
expect(checked).toBeGreaterThan(2);
});
test('saveBeforeNext leaves information pages alone', () => {
expect(saveBeforeNext([{ n: 1, text: 'Read it.', click: ['Next'] }])[0]!.click).toEqual(['Next']);
expect(saveBeforeNext([{ n: 1, text: 'Pick.', click: ['Next'], answers: ['a'] }])[0]!.click).toEqual(['Save', 'Next']);
});
});
describe('9. IARC Digital purchases by exact labels', () => {
test('Yes reveals "Purchases of digital goods", ticked before the follow-ups', () => {
const p = consolePlan('play-content-rating', base);
expect(p.answers.find(a => a.id === 'q:digitalPurchases')?.value).toBe(true);
expect(p.answers.find(a => a.id === 'd:digitalPurchases:digitalGoods')).toMatchObject({ label: 'Purchases of digital goods', action: 'tick', status: 'certain' });
const ids = p.answers.map(a => a.id);
expect(ids.indexOf('d:digitalPurchases:digitalGoods')).toBeLessThan(ids.indexOf('d:digitalPurchases:playerTrading'));
expect(p.steps.find(s => s.answers?.includes('d:digitalPurchases:digitalGoods'))!.text).toContain('exact console label');
});
test('the in-page helper matches exact labels and refuses blanket answers', () => {
expect(IN_PAGE_LOOP).toContain('l === want');
expect(IN_PAGE_LOOP).toContain('refused: a radio needs within');
expect(IN_PAGE_LOOP).toContain('refused: no blanket answers');
expect(IN_PAGE_LOOP).toContain('refused: keywords or a pattern');
expect(() => new Function(IN_PAGE_LOOP)).not.toThrow();
});
test('the helper in a tiny DOM: keyword and blanket answers for the digital purchases block are refused', async () => {
const g = globalThis as Record<string, unknown>;
const radio = (label: string) => ({ getAttribute: (n: string) => (n === 'aria-label' ? label : n === 'aria-checked' ? 'false' : null), hasAttribute: (n: string) => n === 'aria-checked', getClientRects: () => [1], matches: () => true, closest: () => null, click() {} });
const els = [radio('Yes'), radio('No')];
const block = { innerText: 'Digital purchases\nDoes the app offer digital goods, or cash rewards?', querySelector: () => els[0], querySelectorAll: () => els, contains: () => false };
g.document = { body: {}, querySelectorAll: (q: string) => (q.includes('section') ? [block] : els) };
g.window = g;
try {
new Function(IN_PAGE_LOOP)();
const sb = g.__sb as { run(s: unknown[]): string };
sb.run([{ role: 'radio', label: 'No|no', within: 'cash' }, { role: 'radio', label: 'No' }, { role: 'radio', label: 'No', within: 'cash rewards' }, { role: 'radio', group: true, label: 'No', within: 'Digital purchases' }]);
await (g.__run as Promise<void>);
const log = (g.__log as string[]).join('\n');
expect(log).toContain('refused: keywords or a pattern');
expect(log).toContain('refused: a radio needs within');
expect(log).toContain('no block whose question is exactly');
expect(log).toContain('refused: no blanket answers');
} finally { for (const k of ['document', '__sb', '__log', '__done', '__run', 'window']) delete g[k]; }
});
});
describe('6. the review contact', () => {
const contact = { firstName: 'Ada', lastName: 'Example', phone: '+1 555 0100', email: 'review@example.org' };
const repo = () => { const top = mkdtempSync(join(tmpdir(), 'sb-rc-')); mkdirSync(join(top, REPO_MARK)); mkdirSync(join(top, '.storebound')); return top; };
test('the documented shape in the project, a parent folder and other key shapes', () => {
const top = repo();
mkdirSync(join(top, 'app'));
writeFileSync(join(top, '.storebound', 'credentials.json'), JSON.stringify({ reviewContact: contact }));
const r = findReviewContact(join(top, 'app'));
expect(r.contact).toEqual(contact);
expect(r.checked[0]).toContain(join(top, 'app', '.storebound', 'credentials.json'));
expect(contactOf({ contactFirstName: 'Ada', contactLastName: 'Example', contactPhone: '1', contactEmail: 'review@example.org' })?.firstName).toBe('Ada');
expect(contactOf({ name: 'Ada Example', email: 'review@example.org' })).toMatchObject({ firstName: 'Ada', lastName: 'Example' });
writeFileSync(join(top, '.storebound', 'credentials.json'), JSON.stringify({ apple: { reviewContact: contact } }));
expect(findReviewContact(top).contact?.email).toBe(contact.email);
});
test('none: the error names every file and key checked', () => {
const top = repo();
writeFileSync(join(top, '.storebound', 'credentials.json'), JSON.stringify({ reviewContact: { firstName: 'Ada' } }));
const m = noContactMessage(top);
expect(m).toContain(join(top, '.storebound', 'credentials.json'));
expect(m).toContain('reviewContact, apple.reviewContact');
expect(m).toContain('--contact-from');
});
});
describe('7. one-line errors for missing required values', () => {
test('missing and value-less flags', () => {
const fix = 'storebound apple status shows the editable version';
expect(() => parseArgs([]).required('version', 'x.y.z', fix)).toThrow(`--version <x.y.z> is required (${fix})`);
expect(() => parseArgs(['--version']).required('version', 'x.y.z', fix)).toThrow('is required');
expect(() => parseArgs(['--version', '--dry']).required('version', 'x.y.z')).toThrow('is required');
expect(parseArgs(['--version', '1.2.0']).required('version', 'x.y.z')).toBe('1.2.0');
});
test('the CLI prints one line for apple listing without --version', () => {
const dir = mkdtempSync(join(tmpdir(), 'sb-one-'));
writeFileSync(join(dir, 'storebound.config.ts'), `export default ${JSON.stringify(base)};\n`);
const p = Bun.spawnSync(['bun', join(import.meta.dir, '..', 'cli', 'index.ts'), 'apple', 'listing'], { cwd: dir, env: { ...process.env, STOREBOUND_NO_NETWORK: '1' } });
expect(p.stderr.toString().trim()).toBe('error: --version <x.y.z> is required (storebound apple status shows the editable version)');
expect(p.exitCode).toBe(1);
});
});

View file

@ -58,7 +58,7 @@ describe('agent-only Play plans: shape, URLs, never-press lists, the owner batch
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.verify.command).toBe(`storebound console verify ${form} ${p.verify.capture.includes("aria-selected") ? "--from" : "--from-text"} -`);
expect(p.inPageLoop).toBe(IN_PAGE_LOOP);
expect(planText(p)).toContain('window.__sb.run');
expect(JSON.parse(JSON.stringify(p))).toEqual(p);
@ -179,7 +179,7 @@ describe('agent-only Play plans: shape, URLs, never-press lists, the owner batch
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 === 'access')).toMatchObject({ value: true, status: 'certain', label: 'Yes', altLabels: ['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')!;
// in .storebound/credentials.json: the agent types it, never printed (no value in the plan)
@ -200,7 +200,7 @@ describe('agent-only Play plans: shape, URLs, never-press lists, the owner batch
expect(ad.answers.filter(a => a.action === 'tick').map(a => a.label).sort()).toEqual(['Advertising or marketing', 'Analytics']);
const none = { ...base, console: { ...base.console, appContent: { financialFeatures: false, healthFeatures: false, government: false } } } as ShipConfig;
expect(consolePlan('play-financial-features', none).answers).toMatchObject([{ id: 'none', action: 'tick', label: 'My app doesn\'t provide any financial features', status: 'certain' }]);
expect(consolePlan('play-health-apps', none).answers).toMatchObject([{ id: 'none', action: 'tick', label: 'Other', status: 'certain' }]);
expect(consolePlan('play-health-apps', none).answers).toMatchObject([{ id: 'none', action: 'tick', label: 'My app does not have any health features', altLabels: ['Other'], status: 'certain' }]);
expect(consolePlan('play-government-apps', none).answers).toMatchObject([{ id: 'government', value: false, label: 'No', status: 'certain' }]);
expect(consolePlan('play-financial-features', base).answers).toMatchObject([{ id: 'features', status: 'ask-owner' }]);
});

View file

@ -210,7 +210,7 @@ describe('full CLI coverage: new methods', () => {
describe('the owner flag', () => {
const saved = { ...process.env };
afterEach(() => { for (const k of ['STOREBOUND_SERVE', 'STOREBOUND_OWNER_CONFIRMED']) { if (saved[k] === undefined) delete process.env[k]; else process.env[k] = saved[k]; } });
const ctx = { loaded: {} as never, args: { has: (n: string) => n === 'yes', get: () => undefined, str: (_: string, d: string) => d, num: (_: string, d: number) => d, list: () => [], many: () => [], _: [] } };
const ctx = { loaded: {} as never, args: { has: (n: string) => n === 'yes', get: () => undefined, str: (_: string, d: string) => d, num: (_: string, d: number) => d, list: () => [], many: () => [], required: (n: string) => { throw new Error(`--${n} is required`); }, _: [] } };
test('under serve: no by default, even with --yes; yes only with the owner flag', async () => {
process.env.STOREBOUND_SERVE = '1';
delete process.env.STOREBOUND_OWNER_CONFIRMED;