560 lines
50 KiB
TypeScript
560 lines
50 KiB
TypeScript
// Console plans for the Play Console pages that have no API and no scripted fill (`storebound console plan <form>`):
|
|
// Store settings (the App category: the Play Developer API's edits.details has no category field, so `play listing`
|
|
// leaves it "Not selected") and the App content declarations next to Content rating and Target audience: Privacy
|
|
// policy, Ads, Sign in details (App access), Advertising ID, Government apps, Financial features, Health apps, plus the
|
|
// App content overview that lists every card in order. Pure: answers come from the config only (AGENTS.md rule 5:
|
|
// anything it does not prove is ask-owner, collected in the one batch at the start).
|
|
// Verified on a real Play Console by an agent with a browser tool (2026-10-12):
|
|
// /developers/<devId>/app/<appId>/store-settings: the first Edit opens App category; Category is a dropdown whose
|
|
// entries are [role=option] with the exact category name; Save stores it.
|
|
// /developers/<devId>/app/<appId>/app-content/overview: each card has a button aria-label="Start <Name> declaration".
|
|
// Card pages: app-content/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"), health (2 steps;
|
|
// the last step's "Other" box was the no-health-features answer on that run), target-audience-content,
|
|
// content-rating-overview > "Start questionnaire" > content-rating-iarc-questionnaire.
|
|
// The Data safety card's slug was not recorded (modeled); storebound answers Data safety through the API anyway.
|
|
import type { ShipConfig } from './config';
|
|
import { PLAY_APP_CATEGORIES, PLAY_GAME_CATEGORIES, playCategoryOf, profileOf } from './config';
|
|
import type { PlanFormId, PlanOnlyFormId } from './console-answers';
|
|
import { NEVER_PRESS, PLAY_NEVER_PRESS, playAppList } from './console-forms';
|
|
import type { ConsolePlan, PlanAnswer, PlanIds, PlanStatus, PlanStep } from './console-plan';
|
|
import { AD_ID_PERMISSION, type ProjectSignals } from './project-signals';
|
|
import { playGamesPart } from './console-games-plan';
|
|
import { PUBLISH_BUTTON, PUBLISH_CONFIRM } from './play-publish';
|
|
|
|
const PLAY = 'https://play.google.com/console';
|
|
|
|
/** Play Console's material radios and checkboxes (verified 2026-10-12 on Create app, App content and the IARC
|
|
* questionnaire): what every Play plan tells the agent. */
|
|
export const PLAY_MATERIAL_FACT = 'Play Console radios and checkboxes are <material-radio> / <material-checkbox>: computer-tool clicks and setting the native input (form_input) can leave the Angular model unchanged. Click the inner [role=radio] / [role=checkbox] (or the <input> itself) with a JS el.click(), then read aria-checked (or input.checked). Their labels sit in sibling nodes, not in an aria-label: walk up from the control to the nearest parent whose innerText is not empty and match by "starts with" (verified 2026-10-12).';
|
|
|
|
/** One App content card on /app-content/overview, in the page's order. */
|
|
export interface AppContentCard {
|
|
/** The card's name; its button is aria-label="Start <name> declaration". */
|
|
name: string;
|
|
/** The page under /app-content/. */
|
|
slug: string;
|
|
/** The plan that fills it, or null when an API command covers it. */
|
|
form: PlanFormId | null;
|
|
/** How storebound covers it. */
|
|
via: string;
|
|
/** The slug was read on a real console (2026-10-12). */
|
|
verified: boolean;
|
|
}
|
|
|
|
export const APP_CONTENT_CARDS: AppContentCard[] = [
|
|
{ name: 'Privacy policy', slug: 'privacy-policy', form: 'play-privacy-policy', via: 'console plan play-privacy-policy', verified: true },
|
|
{ name: 'Ads', slug: 'ads-declaration', form: 'play-ads', via: 'console plan play-ads', verified: true },
|
|
{ name: 'Sign in details', slug: 'testing-credentials', form: 'play-sign-in-details', via: 'console plan play-sign-in-details', verified: true },
|
|
{ name: 'Content ratings', slug: 'content-rating-overview', form: 'play-content-rating', via: 'console plan play-content-rating (Start questionnaire > content-rating-iarc-questionnaire)', verified: true },
|
|
{ name: 'Target audience and content', slug: 'target-audience-content', form: 'play-target-audience', via: 'console plan play-target-audience', verified: true },
|
|
{ name: 'Data safety', slug: 'data-privacy-security', form: null, via: 'storebound play data-safety (API; no console form to fill; the page has Export to CSV / Import from CSV)', verified: true },
|
|
{ name: 'Advertising ID', slug: 'ad-id-declaration', form: 'play-advertising-id', via: 'console plan play-advertising-id', verified: true },
|
|
{ name: 'Government apps', slug: 'government-apps', form: 'play-government-apps', via: 'console plan play-government-apps', verified: true },
|
|
{ name: 'Financial features', slug: 'finance', form: 'play-financial-features', via: 'console plan play-financial-features', verified: true },
|
|
{ name: 'Health apps', slug: 'health', form: 'play-health-apps', via: 'console plan play-health-apps', verified: true },
|
|
];
|
|
|
|
/** The aria-label of a card's start button on the overview. */
|
|
export const startLabel = (card: string): string => `Start ${card} declaration`;
|
|
|
|
const devOf = (c: ShipConfig, p: PlanIds) => p.playDeveloperId ?? c.play?.developerId ?? null;
|
|
const appOf = (c: ShipConfig, p: PlanIds) => p.playAppId ?? c.play?.appId ?? null;
|
|
/** /console/u/0/developers/<dev>/app/<app>/<path>. */
|
|
export const playAppPage = (dev: string, app: string, path: string): string => `${PLAY}/u/0/developers/${encodeURIComponent(dev)}/app/${encodeURIComponent(app)}/${path}`;
|
|
|
|
function start(config: ShipConfig, p: PlanIds, path: string, reach: string[]): ConsolePlan['start'] {
|
|
const dev = devOf(config, p), app = appOf(config, p);
|
|
if (dev && app) return { url: playAppPage(dev, app, path), deepLink: true, reach: [] };
|
|
return {
|
|
url: dev ? playAppList(PLAY, dev) : PLAY, deepLink: false,
|
|
reach: [...(dev ? [] : ['Pick the developer account if asked (set play.developerId for a deep link)']),
|
|
`the app (${config.play?.packageName ?? config.app.name}) in the app list (set play.appId, the digits after /app/, for a deep link to /${path})`, ...reach],
|
|
};
|
|
}
|
|
|
|
const appContentReach = (card: string) => ['Policy and programs > App content (/app-content/overview)', `the "${card}" card: the button aria-label="${startLabel(card)}" (or its Manage / Edit link)`];
|
|
|
|
export interface Part {
|
|
title: string;
|
|
start: ConsolePlan['start'];
|
|
steps: PlanStep[];
|
|
answers: PlanAnswer[];
|
|
final: string;
|
|
neverPress: string[];
|
|
allowed: ConsolePlan['buttons']['allowed'];
|
|
uiFacts: string[];
|
|
verify: ConsolePlan['verify'];
|
|
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';
|
|
// 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,
|
|
text: status === 'agreement' ? 'the owner ticks it (an agreement)' : value === null || status === 'ask-owner' ? '(you decide)' : value === true ? 'Yes' : value === false ? 'No' : value,
|
|
status, source, ...extra,
|
|
});
|
|
const yesNo = (v: boolean, yes: string, no: string) => (v ? yes : no);
|
|
|
|
function steps(list: (Omit<PlanStep, 'n'> | false | undefined)[]): PlanStep[] {
|
|
return list.filter((s): s is Omit<PlanStep, 'n'> => !!s).map((s, i) => ({ n: i + 1, ...s }));
|
|
}
|
|
|
|
/** Publishing overview's button that sends every saved change to Google: the owner's (verified 2026-10-12). */
|
|
export const SEND_FOR_REVIEW = 'Send changes for review';
|
|
/** Where the owner presses it. */
|
|
export const SEND_FOR_REVIEW_WHERE = 'Publishing overview (left menu): "Submit N changes for review", then "Send changes for review" in the "Send N changes for review?" dialog (a real click)';
|
|
/**
|
|
* A declaration's Save (verified 2026-10-13): it stores the card; nothing goes to Google before the owner presses Send
|
|
* changes for review in Publishing overview. So the agent presses it, reads the card back and goes on.
|
|
*/
|
|
const DECLARATION_SAVE = `Press Save yourself (the agent's button: it stores the declaration; Google gets it only with the next "${SEND_FOR_REVIEW}" in Publishing overview, which stays the owner's). Read the card back.`;
|
|
const declarationButtons = (where: string, extra: ConsolePlan['buttons']['allowed'] = []) => ({
|
|
final: SEND_FOR_REVIEW,
|
|
neverPress: [...new Set([...PLAY_NEVER_PRESS, ...NEVER_PRESS])],
|
|
allowed: [{ button: 'Start <card> declaration / Manage', class: 'navigation', where: 'App content overview: opens the card' }, ...extra, { button: 'Back', class: 'navigation', where },
|
|
{ button: 'Save', class: 'declaration-save', where: `${where}: stores the declaration; Google gets it only with the owner's ${SEND_FOR_REVIEW} (verified 2026-10-13)` }],
|
|
});
|
|
|
|
// ---- Store settings: the App category (no API) ----
|
|
|
|
function storeSettings(config: ShipConfig, p: PlanIds): Part {
|
|
const prof = config.app.profile, game = profileOf(config) === 'game';
|
|
const type = prof ? ans('appType', 'App or game', game ? 'Game' : 'App', 'certain', 'app.profile', { control: 'radio', action: 'pick', label: game ? 'Game' : 'App', where: 'App category (after the first Edit)' })
|
|
: ans('appType', 'App or game', null, 'ask-owner', 'app.profile', { note: 'Set app.profile (game | app | subscription-app).', control: 'radio', where: 'App category' });
|
|
const cat = playCategoryOf(config);
|
|
const list = (game ? PLAY_GAME_CATEGORIES : PLAY_APP_CATEGORIES).join(', ');
|
|
const category = cat
|
|
? ans('category', 'App category', cat.category, cat.source === 'play.category' ? 'certain' : 'owner-check', cat.source, {
|
|
control: 'select', action: 'choose', label: cat.category, where: 'App category > Category dropdown: the [role=option] with exactly this text',
|
|
...(cat.source === 'play.category' ? {} : { note: `Mapped from the App Store category; set play.category to make it explicit (one of ${list}).` }) })
|
|
: ans('category', 'App category', null, 'ask-owner', 'play.category', { control: 'select', action: 'choose', where: 'App category > Category dropdown', note: `Set play.category to one of: ${list}.` });
|
|
return {
|
|
title: 'Play Console: Store settings (App category)',
|
|
start: start(config, p, 'store-settings', ['Grow users > Store presence > Store settings']),
|
|
steps: steps([
|
|
{ text: 'Open Store settings of the app (/app/<appId>/store-settings; deep link when play.appId is known).' },
|
|
{ text: 'Press the FIRST "Edit" on the page (the App category section at the top; the other Edits are contact details and tags).', click: ['Edit'] },
|
|
{ text: 'App or game: pick the material radio (JS click on the inner [role=radio]); the category list depends on it.', answers: ['appType'] },
|
|
{ text: 'Category: open the dropdown, then click the [role=option] whose text is exactly the category of the sheet (e.g. "Strategy"). Read the dropdown back: it must show that name, not "Select a category".', answers: ['category'] },
|
|
{ text: 'Press Save (stores the setting; on an app that is not published nothing is public). Read the section back: App category shows the type and the category, no "Not selected".', click: ['Save'] },
|
|
{ text: 'Verify: read the page text and pipe it to `storebound console verify play-store-settings --from-text -`.' },
|
|
]),
|
|
answers: [type, category],
|
|
final: 'Send changes for review',
|
|
neverPress: [...PLAY_NEVER_PRESS, ...NEVER_PRESS],
|
|
allowed: [
|
|
{ button: 'Edit (the first one: App category)', class: 'navigation', where: 'Store settings: opens the App category section' },
|
|
{ button: 'Save', class: 'draft-save', where: 'Store settings: stores the category. On a published app the change goes out with Google\'s review (with managed publishing: Publishing overview > Send changes for review, the owner\'s)' },
|
|
],
|
|
uiFacts: [
|
|
'Verified 2026-10-12: `play listing` does not set the App category (the Play API\'s edits.details has no category field), so a new app shows "Not selected" until it is set here.',
|
|
'URL: /console/u/0/developers/<developerId>/app/<appId>/store-settings. The first Edit on the page opens App category; Category is a dropdown whose entries are [role=option] elements with the exact category name.',
|
|
'Contact details (email, website, phone) on the same page come from the API (`storebound play listing`): leave them.',
|
|
PLAY_MATERIAL_FACT,
|
|
],
|
|
verify: pageVerify('play-store-settings', ['The App category section shows the app type and the category of the sheet; no "Not selected".']),
|
|
guidance: ['Open: Grow users > Store presence > Store settings > App category > Edit'],
|
|
};
|
|
}
|
|
|
|
// ---- App content declarations ----
|
|
|
|
function privacyPolicy(config: ShipConfig, p: PlanIds): Part {
|
|
const url = config.app.privacyPolicyUrl;
|
|
return {
|
|
title: 'Play Console: Privacy policy (App content)',
|
|
start: start(config, p, 'app-content/privacy-policy', appContentReach('Privacy policy')),
|
|
steps: steps([
|
|
{ text: `Open App content > Privacy policy: ${start(config, p, 'app-content/privacy-policy', []).url}` },
|
|
{ text: 'Type the privacy policy URL into the Privacy policy URL field (replace what is there).', answers: ['url'] },
|
|
{ text: DECLARATION_SAVE },
|
|
{ text: 'Verify: read the page text and pipe it to `storebound console verify play-privacy-policy --from-text -`.' },
|
|
]),
|
|
answers: [url ? ans('url', 'Privacy policy URL', url, 'certain', 'app.privacyPolicyUrl', { control: 'text', action: 'type', label: 'Privacy policy URL', where: 'Privacy policy' })
|
|
: ans('url', 'Privacy policy URL', null, 'ask-owner', 'app.privacyPolicyUrl', { note: 'Set app.privacyPolicyUrl (privacy-policy skill).' })],
|
|
...declarationButtons('Privacy policy'),
|
|
uiFacts: ['Verified 2026-10-12: the card\'s page is /app-content/privacy-policy (one URL field).', 'The URL must be public (no PDF, no sign-in): `storebound validate` checks it.', PLAY_MATERIAL_FACT],
|
|
verify: pageVerify('play-privacy-policy', ['The field shows app.privacyPolicyUrl.']),
|
|
guidance: [`Open: Policy and programs > App content > Privacy policy: ${start(config, p, 'app-content/privacy-policy', []).url}`],
|
|
};
|
|
}
|
|
|
|
/** The ads answer (the same one the target audience plan uses). */
|
|
function adsValue(config: ShipConfig): { v: boolean; src: string } | null {
|
|
const ta = config.console?.targetAudience;
|
|
if (ta?.containsAds !== undefined) return { v: ta.containsAds, src: 'console.targetAudience.containsAds' };
|
|
if (config.ads?.appAdsTxt?.length) return { v: true, src: 'ads.appAdsTxt' };
|
|
return null;
|
|
}
|
|
|
|
function adsDeclaration(config: ShipConfig, p: PlanIds): Part {
|
|
const a = adsValue(config);
|
|
const yes = 'Yes, my app contains ads', no = 'No, my app does not contain ads';
|
|
return {
|
|
title: 'Play Console: Ads (App content)',
|
|
start: start(config, p, 'app-content/ads-declaration', appContentReach('Ads')),
|
|
steps: steps([
|
|
{ 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: 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.' })],
|
|
...declarationButtons('Ads'),
|
|
uiFacts: ['Verified 2026-10-12: /app-content/ads-declaration.', 'Rewarded ads count as ads.', PLAY_MATERIAL_FACT],
|
|
verify: pageVerify('play-ads', ['The selected radio matches the sheet.']),
|
|
guidance: [`Open: Policy and programs > App content > Ads: ${start(config, p, 'app-content/ads-declaration', []).url}`],
|
|
};
|
|
}
|
|
|
|
/** Play's Add instructions dialog: "Any other information required to access your app" holds at most 500 characters. */
|
|
export const PLAY_ACCESS_INFO_MAX = 500;
|
|
/** The name of the one entry a guest-play app declares (no account, no credentials). */
|
|
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';
|
|
const info = 'Any other information required to access your app';
|
|
// a guest-play app with reviewer steps (signInRequired false + instructions): Play rejected such a game as a login
|
|
// wall ("provide valid login credentials") while it was declared unrestricted, so it is declared restricted with
|
|
// 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 = 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.` } : {};
|
|
if (guest) {
|
|
const steps = ra!.instructions!.trim();
|
|
out.push(ans('entryName', 'Name of the instructions entry', GUEST_ENTRY_NAME, 'certain', 'reviewAccess.signInRequired false', { control: 'text', action: 'type', label: GUEST_ENTRY_NAME, where: 'Add instructions: Name' }));
|
|
out.push(ans('credentials', 'Username and password', null, 'certain', 'reviewAccess.signInRequired false', { control: 'text', where: 'Add instructions: Username, Password', text: 'leave both empty (no account)', note: 'Guest play: no credentials exist; type nothing into Username or Password.' }));
|
|
out.push(ans('instructions', 'Instructions for the reviewer', steps, steps.length > PLAY_ACCESS_INFO_MAX ? 'owner-check' : 'certain', 'reviewAccess.instructions', { control: 'text', action: 'type', label: info, where: 'Add instructions: Any other information', ...long(steps) }));
|
|
} else if (ra?.signInRequired) {
|
|
out.push(ra.instructions ? ans('instructions', 'Instructions for the reviewer', ra.instructions, 'certain', 'reviewAccess.instructions', { control: 'text', action: 'type', label: info, where: 'Add instructions', ...long(ra.instructions) })
|
|
: ans('instructions', 'Instructions for the reviewer', null, 'ask-owner', 'reviewAccess.instructions', { note: 'Set reviewAccess.instructions (how to sign in and reach the main features).' }));
|
|
// with reviewDemoAccount in .storebound/credentials.json the agent types it (never printed: not in chat, logs, the
|
|
// plan or a commit); without it the owner types the username and password (or adds it there first)
|
|
out.push(p.demoAccount
|
|
? ans('credentials', 'Username and password of the demo account', null, 'certain', '.storebound/credentials.json reviewDemoAccount', {
|
|
control: 'text', action: 'type', where: 'Add instructions: Username, Password',
|
|
text: 'from .storebound/credentials.json (never printed)',
|
|
note: 'reviewDemoAccount: present. The agent types reviewDemoAccount.username and .password from .storebound/credentials.json straight into the two fields (click, type), without echoing them in chat, logs, the plan or a commit.' })
|
|
: ans('credentials', 'Username and password of the demo account', null, 'ask-owner', '.storebound/credentials.json reviewDemoAccount', {
|
|
control: 'text', where: 'Add instructions: Username, Password',
|
|
note: `reviewDemoAccount: ${p.demoAccount === undefined ? 'not checked' : 'MISSING'}. The owner adds it to .storebound/credentials.json before the run (then the agent types it, never printed), or types the username and password into the card themselves. storebound never prints them.` }));
|
|
}
|
|
// 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 = 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: `"${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: 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 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.', `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.']),
|
|
guidance: [`Open: Policy and programs > App content > Sign in details (App access): ${start(config, p, 'app-content/testing-credentials', []).url}`],
|
|
};
|
|
}
|
|
|
|
/** Play's advertising ID purposes (the Data safety purpose names). */
|
|
const AD_ID_PURPOSES: Record<string, string> = {
|
|
functionality: 'App functionality', analytics: 'Analytics', communications: 'Developer communications', advertising: 'Advertising or marketing',
|
|
fraud: 'Fraud prevention, security, and compliance', personalization: 'Personalization', account: 'Account management',
|
|
};
|
|
|
|
/**
|
|
* "Does your app use advertising ID?" from real signals: console.appContent.advertisingId, ad / attribution SDKs (scan),
|
|
* ads.appAdsTxt, the AD_ID permission in the source or merged AndroidManifest (or its tools:node="remove"). A Data
|
|
* safety Device ID (PSL_DEVICE_ID) alone is not the advertising ID (a random install id is not). No signal: No
|
|
* (certain) when the project was scanned; signals that disagree: ask.
|
|
*/
|
|
export function advertisingIdUse(config: ShipConfig, s: ProjectSignals | undefined): { v: boolean | null; src: string; note?: string } {
|
|
const flag = config.console?.appContent?.advertisingId;
|
|
const yes: string[] = [], no: string[] = [];
|
|
if (flag === true) yes.push('console.appContent.advertisingId'); else if (flag === false) no.push('console.appContent.advertisingId');
|
|
if (config.ads?.appAdsTxt?.length) yes.push('ads.appAdsTxt (ad SDKs read the advertising ID)');
|
|
if (s) {
|
|
if (s.adSdks.length || s.attributionSdks.length) yes.push(`ad / attribution SDKs: ${[...s.adSdks, ...s.attributionSdks].join(', ')}`);
|
|
if (s.adIdRemoved) no.push(`AD_ID removed (tools:node="remove") at ${s.adIdRemoved}`);
|
|
else if (s.adIdPermission) yes.push(`${AD_ID_PERMISSION} at ${s.adIdPermission}`);
|
|
}
|
|
if (yes.length && no.length) return { v: null, src: [...yes, ...no].join('; '), note: `The signals disagree (yes: ${yes.join('; ')}; no: ${no.join('; ')}). Decide and set console.appContent.advertisingId (an SDK that reads it needs the AD_ID permission in the merged manifest).` };
|
|
if (yes.length) return { v: true, src: yes.join('; ') };
|
|
if (no.length) return { v: false, src: no.join('; ') };
|
|
if (s) return { v: false, src: `no ad / attribution SDK, no ${AD_ID_PERMISSION} in the manifests, no ads.appAdsTxt (a Device ID in dataSafety is not the advertising ID)` };
|
|
return { v: null, src: 'console.appContent.advertisingId' };
|
|
}
|
|
|
|
function advertisingId(config: ShipConfig, p: PlanIds): Part {
|
|
const dev = config.dataSafety?.data?.PSL_DEVICE_ID;
|
|
const uses = advertisingIdUse(config, p.signals);
|
|
const out: PlanAnswer[] = [uses.v !== null ? ans('uses', 'Does your app use advertising ID?', uses.v, 'certain', uses.src, { control: 'radio', action: 'pick', label: uses.v ? 'Yes' : 'No', where: 'Advertising ID' })
|
|
: ans('uses', 'Does your app use advertising ID?', null, 'ask-owner', uses.src, { control: 'radio', where: 'Advertising ID', note: uses.note ?? 'Yes when the app or any SDK (ads, analytics, attribution) reads it; set console.appContent.advertisingId.' })];
|
|
if (uses?.v) {
|
|
if (dev) {
|
|
const purposes = new Set([...dev.collect, ...(dev.shared ? dev.share ?? [] : [])]);
|
|
for (const [k, label] of Object.entries(AD_ID_PURPOSES)) out.push(ans(`purpose:${k}`, `Advertising ID purpose: ${label}`, purposes.has(k as never), 'certain', 'dataSafety.data.PSL_DEVICE_ID', { control: 'checkbox', action: purposes.has(k as never) ? 'tick' : 'untick', label, where: 'Advertising ID: why it is used' }));
|
|
} else out.push(ans('purposes', 'Advertising ID purposes', null, 'ask-owner', 'dataSafety.data.PSL_DEVICE_ID', { control: 'checkbox', where: 'Advertising ID', note: 'Add dataSafety.data.PSL_DEVICE_ID with its purposes (store-declarations skill): the same purposes answer this card.' }));
|
|
}
|
|
return {
|
|
title: 'Play Console: Advertising ID (App content)',
|
|
start: start(config, p, 'app-content/ad-id-declaration', appContentReach('Advertising ID')),
|
|
steps: steps([
|
|
{ text: `Open App content > Advertising ID: ${start(config, p, 'app-content/ad-id-declaration', []).url}` },
|
|
{ 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: 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'),
|
|
uiFacts: ['Verified 2026-10-12: /app-content/ad-id-declaration.', 'Yes needs the com.google.android.gms.permission.AD_ID permission in the merged manifest (ad SDKs usually add it); `storebound scan` reports the SDKs.', PLAY_MATERIAL_FACT],
|
|
verify: pageVerify('play-advertising-id', ['The selected radio and the ticked purposes match the sheet.']),
|
|
guidance: [`Open: Policy and programs > App content > Advertising ID: ${start(config, p, 'app-content/ad-id-declaration', []).url}`],
|
|
};
|
|
}
|
|
|
|
function government(config: ShipConfig, p: PlanIds): Part {
|
|
const v = config.console?.appContent?.government;
|
|
return {
|
|
title: 'Play Console: Government apps (App content)',
|
|
start: start(config, p, 'app-content/government-apps', appContentReach('Government apps')),
|
|
steps: steps([
|
|
{ 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: 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).' })],
|
|
...declarationButtons('Government apps'),
|
|
uiFacts: ['Verified 2026-10-12: /app-content/government-apps.', PLAY_MATERIAL_FACT],
|
|
verify: pageVerify('play-government-apps', ['The selected answer matches the sheet.']),
|
|
guidance: [`Open: Policy and programs > App content > Government apps: ${start(config, p, 'app-content/government-apps', []).url}`],
|
|
};
|
|
}
|
|
|
|
/** 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' : 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: 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}` },
|
|
...(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: 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: [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}`],
|
|
};
|
|
}
|
|
|
|
// ---- the first production release (after `play release --production draft`) ----
|
|
|
|
/** The managed publishing warning (Play's default is off: approved means live). */
|
|
export function managedPublishingNote(config: ShipConfig): string {
|
|
return config.play?.managedPublishing === true
|
|
? 'Managed publishing is on (play.managedPublishing): after Google approves, the owner publishes the changes in Publishing overview.'
|
|
: `Managed publishing is ${config.play?.managedPublishing === false ? 'off' : 'off by default (play.managedPublishing not set)'}: the release goes live automatically once Google approves it. To control the moment, the owner turns on Publishing overview > Managed publishing first (and sets play.managedPublishing: true).`;
|
|
}
|
|
|
|
const regionName = (code: string): string => { try { return new Intl.DisplayNames(['en'], { type: 'region' }).of(code) ?? code; } catch { return code; } };
|
|
|
|
function productionCountries(config: ShipConfig, p: PlanIds): Part {
|
|
const list = config.play?.countries;
|
|
const answers: PlanAnswer[] = list?.length
|
|
? list.map(c => ans(`country:${c}`, `Country / region: ${regionName(c)} (${c})`, true, 'certain', 'play.countries', { control: 'checkbox', action: 'tick', label: regionName(c), where: 'Edit countries / regions: the row of that country' }))
|
|
: [ans('all', 'All countries / regions', true, 'certain', 'default (play.countries not set)', { control: 'checkbox', action: 'tick', label: 'Select all rows', where: 'Edit countries / regions: the mat-checkbox aria-label="Select all rows"', note: 'Set play.countries (ISO codes) to limit them.' })];
|
|
return {
|
|
title: 'Play Console: production countries / regions',
|
|
start: start(config, p, 'tracks/production', ['Test and release > Production']),
|
|
steps: steps([
|
|
{ text: 'Open the production track (/app/<appId>/tracks/production) after `storebound play release --production draft` made the draft release.' },
|
|
{ text: 'Open the "Countries / regions" tab and press "Edit countries / regions".', click: ['Countries / regions', 'Edit countries / regions'] },
|
|
{ text: list?.length ? 'Tick exactly the rows of play.countries (material checkboxes: JS click on the inner [role=checkbox], read aria-checked back); untick the rest.' : 'Tick the mat-checkbox aria-label="Select all rows" (every country / region).', answers: answers.map(a => a.id) },
|
|
{ text: 'Press Save. A "Go to Publishing overview?" dialog follows: it only navigates; go on with `storebound console plan play-production-release`.', click: ['Save'] },
|
|
{ text: 'Verify: read the tab\'s text and pipe it to `storebound console verify play-production-countries --from-text -` (or run `storebound validate --play`: it reads the production track\'s countries through the API).' },
|
|
]),
|
|
answers,
|
|
final: 'Send changes for review',
|
|
neverPress: [...PLAY_NEVER_PRESS, ...NEVER_PRESS],
|
|
allowed: [
|
|
{ button: 'Countries / regions (tab), Edit countries / regions', class: 'navigation', where: 'production track: opens the country list' },
|
|
{ button: 'Save', class: 'draft-save', where: 'country list: stores the countries of the draft production release; nothing is public before the owner sends the changes for review' },
|
|
{ button: 'Go to Publishing overview / its dismiss button', class: 'navigation', where: 'the dialog after Save: navigation only' },
|
|
],
|
|
uiFacts: [
|
|
'Verified 2026-10-12: the API\'s edits.countryAvailability is read-only, so the countries are a console step. tracks/production > Countries / regions tab > Edit countries / regions > a mat-checkbox aria-label="Select all rows" (or one per row) > Save > a "Go to Publishing overview?" dialog.',
|
|
'A production track with 0 countries cannot reach anyone: `storebound validate` reports it.',
|
|
PLAY_MATERIAL_FACT,
|
|
],
|
|
verify: pageVerify('play-production-countries', ['The tab lists the countries / regions of the sheet (all of them by default); not 0.']),
|
|
guidance: ['Open: Test and release > Production > Countries / regions > Edit countries / regions'],
|
|
};
|
|
}
|
|
|
|
/** The dialog Publishing overview shows when changes are submitted while a review is running (seen 2026-10-12). */
|
|
export const RESTART_DIALOG = 'Do you want to restart your review?';
|
|
|
|
function productionRelease(config: ShipConfig, p: PlanIds): Part {
|
|
const mp = managedPublishingNote(config);
|
|
return {
|
|
title: 'Play Console: send the first production release for review',
|
|
start: start(config, p, 'tracks/production', ['Test and release > Production']),
|
|
steps: steps([
|
|
{ text: 'Before: the production countries are set (`storebound console plan play-production-countries`) and every App content card is done (`console plan play-app-content`).' },
|
|
{ text: 'Production track > Releases tab > "Edit release" on the draft that `play release --production draft` made.', click: ['Releases', 'Edit release'] },
|
|
{ text: 'Press Next: the review page lists errors and warnings. A missing deobfuscation file is only a warning. Any error: stop and fix it (it names the page).', click: ['Next'] },
|
|
{ text: 'Press Save (stores the release; nothing is sent yet).', click: ['Save'] },
|
|
{ text: 'Open Publishing overview (left menu). It lists "Changes not yet sent for review" and a "Submit N changes for review" button.' },
|
|
{ text: `Stop. The owner presses "Submit N changes for review" and then "Send changes for review" in the "Send N changes for review?" dialog (a real mouse click: a JS .click() did nothing on it). ${mp}`, answers: ['managedPublishing'] },
|
|
{ text: `While a review is already running, "Submit N changes for review" opens "${RESTART_DIALOG}" instead, with "Restart review": that sends the new changes and starts the running review over. It is the owner's button as well (tell the owner a review is running and what restarting means); never the agent's. Cancel closes the dialog and nothing changes.` },
|
|
{ text: 'Verify: read Publishing overview as text and pipe it to `storebound console verify play-production-release --from-text -`; `storebound play status` shows the production release.' },
|
|
]),
|
|
answers: [ans('managedPublishing', 'Managed publishing on?', config.play?.managedPublishing ?? false, config.play?.managedPublishing === undefined ? 'owner-check' : 'certain', config.play?.managedPublishing === undefined ? 'Play default (off)' : 'play.managedPublishing',
|
|
{ note: mp, where: 'Publishing overview > Managed publishing (only read; the owner switches it)' })],
|
|
final: 'Send changes for review',
|
|
neverPress: ['Submit N changes for review', 'Send changes for review', 'Restart review', ...NEVER_PRESS],
|
|
allowed: [
|
|
{ button: 'Releases (tab), Edit release, Next', class: 'navigation', where: 'production track: opens the draft release and its review page' },
|
|
{ button: 'Save', class: 'draft-save', where: 'the release review page: stores the draft release; nothing is sent' },
|
|
{ button: 'Cancel', class: 'navigation', where: `the "${RESTART_DIALOG}" dialog: closes it; the running review goes on unchanged` },
|
|
],
|
|
uiFacts: [
|
|
'Verified 2026-10-12 on a first production release: Releases tab > Edit release > Next (review page; a missing deobfuscation file is only a warning) > Save; then Publishing overview > "Submit N changes for review" > dialog "Send N changes for review?" > "Send changes for review" (the owner\'s final button; a JS .click() did nothing on it, it needs a real click).',
|
|
`Seen 2026-10-12: pressing "Submit N changes for review" while a review is running opens the dialog "${RESTART_DIALOG}" with "Restart review" (the owner\'s final button: the running review starts over with the new changes) and Cancel. Known dialog; agents never press Restart review.`,
|
|
mp,
|
|
PLAY_MATERIAL_FACT,
|
|
],
|
|
verify: pageVerify('play-production-release', ['Publishing overview no longer lists changes not yet sent for review (they are "In review").']),
|
|
guidance: ['Open: Test and release > Production > Releases > Edit release; then Publishing overview'],
|
|
};
|
|
}
|
|
|
|
function publishChanges(config: ShipConfig, p: PlanIds): Part {
|
|
const ready = p.readyChanges ?? null;
|
|
const readyText = ready === null ? 'not read (add --lookup, or run `storebound play status`)' : ready.length ? ready.map(r => `${r.track} ${r.version ?? '?'} (${r.build ?? '?'}) ${r.rollout}`).join('; ') : 'nothing ready to publish per the API';
|
|
return {
|
|
title: 'Play Console: publish the approved changes (managed publishing)',
|
|
start: start(config, p, 'publishing', ['Publishing overview (left menu)']),
|
|
steps: steps([
|
|
{ text: 'Precondition: `storebound play status` shows "production <version>: In review / ready to publish" (managed publishing on, Google approved).' },
|
|
{ text: 'Open Publishing overview. The section "Changes ready to publish" lists every approved change (e.g. the production release and Data safety); read the list as text and compare it with the ready changes below.' },
|
|
{ text: `Stop. The owner presses "${PUBLISH_BUTTON}" (N = the number in the list) and then confirms in its dialog (button "${PUBLISH_CONFIRM}"; a real mouse click, a JS .click() may do nothing). It makes every listed change live at once.`, answers: ['ready'] },
|
|
{ text: 'Verify: `storebound console verify play-publish-changes` (API: no production release is left ready to publish) or pipe the page text to `--from-text -`.' },
|
|
]),
|
|
answers: [ans('ready', 'Changes ready to publish (API)', readyText, 'certain', 'play status (public store page + tracks)', { note: 'The API sees production releases only; Data safety, listing and other non-track changes show in the console list only.', where: 'Publishing overview > Changes ready to publish' })],
|
|
final: PUBLISH_BUTTON,
|
|
neverPress: ['Publish N changes', 'Send changes for review', 'Submit N changes for review', 'Restart review', 'Discard changes', 'Stop managed publishing', 'Turn off managed publishing', 'Halt rollout', 'Unpublish app', 'Deactivate', ...NEVER_PRESS],
|
|
allowed: [
|
|
{ button: 'Publishing overview (left menu), expand Changes ready to publish', class: 'navigation', where: 'read-only: lists the approved changes' },
|
|
],
|
|
uiFacts: [
|
|
'Managed publishing (play.managedPublishing: true): after approval the changes wait in Publishing overview > "Changes ready to publish"; the button is "Publish N changes" (N counts every change, e.g. production full rollout + Data safety) and opens a confirm dialog.',
|
|
'The Developer API has no publishing-state endpoint: storebound infers "ready to publish" from a completed production release the public store page does not show yet.',
|
|
'Never "Discard changes" or "Stop managed publishing" (it would unpublish or drop the approved changes).',
|
|
managedPublishingNote(config),
|
|
PLAY_MATERIAL_FACT,
|
|
],
|
|
verify: { command: 'storebound console verify play-publish-changes', source: 'page-text', capture: PAGE_TEXT, expect: ['No production release is left ready to publish (API), or the page shows no "Publish N changes" button / "Changes ready to publish".'] },
|
|
guidance: ['Open: Publishing overview > Changes ready to publish'],
|
|
};
|
|
}
|
|
|
|
export function playOnlyPart(form: Exclude<PlanOnlyFormId, 'play-app-content' | 'apple-app-group'>, config: ShipConfig, p: PlanIds): Part {
|
|
switch (form) {
|
|
case 'play-production-countries': return productionCountries(config, p);
|
|
case 'play-production-release': return productionRelease(config, p);
|
|
case 'play-publish-changes': return publishChanges(config, p);
|
|
case 'play-store-settings': return storeSettings(config, p);
|
|
case 'play-privacy-policy': return privacyPolicy(config, p);
|
|
case 'play-ads': return adsDeclaration(config, p);
|
|
case 'play-sign-in-details': return signInDetails(config, p);
|
|
case 'play-advertising-id': return advertisingId(config, p);
|
|
case 'play-government-apps': return government(config, p);
|
|
case 'play-financial-features': return featureCard(config, p, 'finance');
|
|
case 'play-health-apps': return featureCard(config, p, 'health');
|
|
case 'play-games-services': return playGamesPart(config, p);
|
|
}
|
|
}
|
|
|
|
/** The overview's start (the App content page itself). */
|
|
export const appContentStart = (config: ShipConfig, p: PlanIds): ConsolePlan['start'] => start(config, p, 'app-content/overview', ['Policy and programs > App content']);
|
|
|
|
/** Capture for `console verify play-app-content`: the cards still to start (their aria-labels) and the page text. */
|
|
export const APP_CONTENT_CAPTURE = 'JSON.stringify({ start: [...document.querySelectorAll(\'[aria-label^="Start "][aria-label$=" declaration"]\')].map(b => b.getAttribute("aria-label")), text: (document.querySelector("main") ?? document.body).innerText })';
|
|
|
|
export type { Part as PlayPart };
|