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:
parent
a6dab8f3aa
commit
335a4124ab
23 changed files with 486 additions and 74 deletions
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue