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

@ -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}`],
};