// `storebound console verify
`: checks what a console form shows against the config's answers, read-only. Pure // (the command in cli/console/verify.ts feeds it page text, an input-state JSON or API attributes). // Sources per form: // apple-app-privacy page text only (the App Store Connect API has no App Privacy endpoint): the Data Types // section after each type was saved shows "Edit" and a summary per type: "Used for ", // "Linked to the user’s identity" / "Not linked …", "Used for tracking purposes" / "Not used …" // (verified 2026-10-12). A summary without a linked / tracking line is read as No (marked). // play-app-record the Create app form's input states (JSON from the plan's capture snippet: text fields, the // checked [role=radio] labels, the page text for "Select …" hints) or plain page text // apple-app-record the API (the app by bundle id: name, primary locale, SKU) or page text // apple-age-rating the API (ageRatingDeclaration) // play-content-rating, play-target-audience page text (summary pages: a label line, its answer on the same or the // next line). Modeled, not recorded: rows that cannot be found are "not found", never a match. import type { ShipConfig } from './config'; import { type Answer, consoleAnswers, type FormId, type PlanFormId, type PlanOnlyFormId, valueText } from './console-answers'; import { APP_CONTENT_CARDS, playOnlyPart, startLabel } from './console-play-plans'; import { APPLE_PRIVACY_TYPES } from './app-privacy'; import { typeLabels } from './console-forms'; import { playAppIdInText } from './play-app-id'; import { APPLE_PURPOSES } from './console-answers'; export type RowStatus = 'match' | 'mismatch' | 'not-found' | 'ask-owner'; export interface VerifyRow { item: string; expected: string; seen: string; status: RowStatus; note?: string } export interface VerifyResult { form: PlanFormId; /** play-app-record: the Play Console app id read from the URL after Create app (/app//...). */ playAppId?: string; source: 'page-text' | 'inputs' | 'api'; rows: VerifyRow[]; mismatches: number; matched: number; /** true when nothing mismatches and at least one row matched. */ ok: boolean; notes: string[]; } const lines = (text: string): string[] => text.normalize('NFC').split(/\r?\n| \| /).map(l => l.replace(/\s+/g, ' ').trim()).filter(Boolean); /** Straight and curly quotes as one, case and spaces aside: for comparing labels. */ export const fold = (s: string): string => s.normalize('NFC').replace(/[‘’]/g, "'").replace(/[“”]/g, '"').replace(/\s+/g, ' ').trim().toLowerCase(); function result(form: PlanFormId, source: VerifyResult['source'], rows: VerifyRow[], notes: string[] = []): VerifyResult { const mismatches = rows.filter(r => r.status === 'mismatch').length, matched = rows.filter(r => r.status === 'match').length; return { form, source, rows, mismatches, matched, ok: mismatches === 0 && matched > 0, notes }; } const yn = (v: boolean | null | undefined): string => (v === true ? 'Yes' : v === false ? 'No' : '?'); // ---- App Privacy ---- export interface PrivacyTypeSeen { /** "Set Up " is still shown: not answered yet. */ setUp: boolean; /** Purposes from "Used for …" (Apple's names), null when no such line. */ purposes: string[] | null; linked: boolean | null; tracking: boolean | null; /** linked / tracking were not shown and are read as No. */ inferred: string[]; } /** Every console label of a data type -> Apple's name (Purchases -> Purchase History, Other Data -> Other Data Types). */ const TYPE_OF = new Map(Object.values(APPLE_PRIVACY_TYPES).flatMap(([, n]) => typeLabels(n).map(l => [fold(l), n] as [string, string]))); const PURPOSES_FOLDED = APPLE_PURPOSES.map(p => [fold(p), p] as const); /** The per-type summaries of App Privacy's Data Types section (innerText of the page or of the section). */ export function parseAppPrivacy(text: string): Map { let ls = lines(text); const at = ls.findIndex(l => fold(l) === 'data types'); if (at >= 0) ls = ls.slice(at + 1); const out = new Map(); const entry = (t: string) => { let e = out.get(t); if (!e) { e = { setUp: false, purposes: null, linked: null, tracking: null, inferred: [] }; out.set(t, e); } return e; }; let cur: string | null = null; for (const l of ls) { const f = fold(l); const set = /^set up (.+)$/.exec(f); if (set && TYPE_OF.has(set[1]!)) { cur = TYPE_OF.get(set[1]!)!; entry(cur).setUp = true; continue; } if (TYPE_OF.has(f)) { cur = TYPE_OF.get(f)!; continue; } if (!cur) continue; if (/^used for (?!tracking)/.test(f)) { const rest = f.slice('used for '.length); entry(cur).purposes = PURPOSES_FOLDED.filter(([k]) => rest.includes(k)).map(([, p]) => p); } else if (/^not linked to the user/.test(f)) entry(cur).linked = false; else if (/^linked to the user/.test(f)) entry(cur).linked = true; else if (/^not used for tracking/.test(f)) entry(cur).tracking = false; else if (/^used for tracking/.test(f)) entry(cur).tracking = true; } // a type with a summary but without a linked / tracking line: the console shows only what applies for (const e of out.values()) { if (e.purposes === null || e.setUp) continue; if (e.linked === null) { e.linked = false; e.inferred.push('linked'); } if (e.tracking === null) { e.tracking = false; e.inferred.push('tracking'); } } // a label followed by nothing (a category line equal to a type name, "Purchases" above "Set Up Purchase History") for (const [t, e] of [...out]) if (!e.setUp && e.purposes === null && e.linked === null && e.tracking === null) out.delete(t); return out; } export function verifyAppPrivacy(config: ShipConfig, text: string): VerifyResult { const answers = consoleAnswers('apple-app-privacy', config); const by = new Map(answers.map(a => [a.id, a])); const seen = parseAppPrivacy(text); const rows: VerifyRow[] = []; const notes = ['The App Store Connect API has no App Privacy endpoint: checked from the page text.']; const types = answers.filter(a => a.id.startsWith('type:')); for (const a of types) { const t = a.id.slice(5), s = seen.get(t); if (a.value === false) { if (s) rows.push({ item: t, expected: 'not collected', seen: s.setUp ? 'listed (Set Up)' : 'listed with a summary', status: a.certain ? 'mismatch' : 'ask-owner' }); continue; } if (a.value === null) { if (s) rows.push({ item: t, expected: '(owner decides)', seen: 'listed', status: 'ask-owner', ...(a.note ? { note: a.note } : {}) }); continue; } if (!s) { rows.push({ item: t, expected: 'collected', seen: 'not on the page', status: a.certain ? 'mismatch' : 'ask-owner' }); continue; } if (s.setUp) { rows.push({ item: t, expected: 'set up', seen: `"Set Up ${t}" still shown`, status: 'mismatch' }); continue; } const purposes = answers.filter(x => x.id.startsWith(`purpose:${t}:`)); const want = purposes.filter(x => x.value === true).map(x => x.id.split(':')[2]!); const unsure = purposes.some(x => !x.certain); const got = s.purposes ?? []; const same = want.length === got.length && want.every(p => got.includes(p)); rows.push({ item: `${t}: purposes`, expected: want.join(', ') || '(none)', seen: s.purposes ? got.join(', ') || '(none)' : 'no "Used for" line', status: s.purposes === null ? 'not-found' : unsure && !same ? 'ask-owner' : same ? 'match' : 'mismatch' }); for (const k of ['linked', 'tracking'] as const) { const x = by.get(`${k}:${t}`); const v = s[k]; const label = k === 'linked' ? 'linked to the user' : 'used for tracking'; const inferred = s.inferred.includes(k) ? 'not shown, read as No' : undefined; if (!x || x.value === null) rows.push({ item: `${t}: ${label}`, expected: '(owner decides)', seen: yn(v), status: 'ask-owner', ...(inferred ? { note: inferred } : {}) }); else rows.push({ item: `${t}: ${label}`, expected: yn(x.value as boolean), seen: yn(v), status: v === null ? 'not-found' : v === x.value ? 'match' : 'mismatch', ...(inferred ? { note: inferred } : {}) }); } } // after the owner's Publish (verified 2026-10-12): the page shows "Published by "; the name is never echoed const published = appPrivacyPublished(text); rows.push({ item: 'Published (the owner\'s Publish)', expected: '"Published … ago by …"', seen: published ? 'Published' : 'no "Published …" line yet', status: published ? 'match' : 'not-found', ...(published ? {} : { note: 'a draft until the owner presses Publish (and Publish in the "Publish Your App Privacy Responses?" dialog)' }) }); return result('apple-app-privacy', 'page-text', rows, notes); } /** App Privacy was published: the "Published … ago by …" line is on the page (the owner pressed Publish). */ export const appPrivacyPublished = (text: string): boolean => /\bpublished\b.{0,60}\bago\b|\bpublished (on|at)\b/i.test(text.replace(/\s+/g, ' ')); // ---- Play Create app ---- /** The input states the plan's capture snippet returns. */ export interface InputStates { /** Text inputs by their label (or aria-label). */ fields?: Record; /** Labels of the [role=radio] elements with aria-checked=true (and checked native radios). */ checked?: string[]; /** The visible page text (for "Select …" hints and availability messages). */ text?: string; /** location.href (after Create app: /app//app-dashboard). */ url?: string; } /** The capture snippet an agent runs in the page for `console verify play-app-record --from-text -` (prints JSON). */ export const PLAY_CREATE_CAPTURE = `(() => { const t = e => (e?.innerText ?? e?.textContent ?? '').trim().replace(/\\s+/g, ' '); const name = e => e.getAttribute('aria-label') || t(e.labels?.[0]) || (e.getAttribute('aria-labelledby') || '').split(/\\s+/).map(id => t(document.getElementById(id))).join(' ').trim() || t(e.closest('label')) || t(e.parentElement); const fields = {}; for (const i of document.querySelectorAll('input:not([type]), input[type=text], textarea')) if (i.offsetParent) fields[name(i)] = i.value; const checked = [...document.querySelectorAll('[role=radio][aria-checked=true], input[type=radio]:checked')].map(r => name(r) || t(r.closest('material-radio'))); return JSON.stringify({ url: location.href, fields, checked, text: (document.querySelector('main') ?? document.body).innerText }); })()`; export function parseInputStates(raw: string): InputStates | null { const s = raw.trim(); if (!s.startsWith('{')) return null; try { const j = JSON.parse(s) as Record; const fields = j.fields && typeof j.fields === 'object' ? Object.fromEntries(Object.entries(j.fields as Record).filter(([, v]) => typeof v === 'string')) as Record : undefined; const checked = Array.isArray(j.checked) ? j.checked.filter((x): x is string => typeof x === 'string') : undefined; return { ...(fields ? { fields } : {}), ...(checked ? { checked } : {}), ...(typeof j.text === 'string' ? { text: j.text } : {}), ...(typeof j.url === 'string' ? { url: j.url } : {}) }; } catch { return null; } } /** A "Select …" hint the Create app form shows while a radio group has no value in its model ("Select app or game"). */ export const SELECT_HINT = /^select (app or game|free or paid|an? .+)$/i; export function verifyPlayAppRecord(config: ShipConfig, raw: string): VerifyResult { const answers = consoleAnswers('play-app-record', config).filter(a => !a.agreement); const st = parseInputStates(raw); // after Create app (verified 2026-10-12 / 2026-10-13): the console redirects to /app//app-dashboard; the form // is gone, so only the app id is checked (and handed to the play.appId upsert) const appId = playAppIdInText(st?.url ?? raw); const formShown = st ? Object.keys(st.fields ?? {}).length > 0 : /\b(App name|App package name|Uygulama adı)\b/.test(raw); if (appId && !formShown) { const want = config.play?.appId; const row: VerifyRow = { item: 'Play app id (the URL after Create app)', expected: want ?? '(not in the config yet: play.appId)', seen: appId, status: want && want !== appId ? 'mismatch' : 'match', ...(want && want !== appId ? { note: 'play.appId differs: --write replaces it' } : {}) }; return { ...result('play-app-record', st ? 'inputs' : 'page-text', [row], ['The Create app form is gone: the app was created. --write saves the id into play.appId.']), playAppId: appId }; } const text = st?.text ?? (st ? '' : raw); const ls = lines(text); const rows: VerifyRow[] = []; const fieldValue = (labels: string[]): string | null => { if (st?.fields) for (const [k, v] of Object.entries(st.fields)) if (labels.some(l => fold(k).startsWith(fold(l)))) return v; return null; }; const checked = (st?.checked ?? []).map(fold); for (const a of answers) { const exp = a.value === null ? null : String(a.value); if (a.id === 'name' || a.id === 'packageName') { const labels = a.id === 'name' ? ['App name', 'Uygulama adı'] : ['App package name', 'Package name', 'Uygulama paket adı']; const v = fieldValue(labels); const inText = v === null && exp !== null && ls.some(l => l === exp || l.endsWith(` ${exp}`)); const seen = v ?? (inText ? exp! : null); rows.push({ item: a.label, expected: exp ?? '(owner decides)', seen: seen ?? 'not found', status: exp === null ? 'ask-owner' : seen === null ? 'not-found' : seen === exp ? 'match' : 'mismatch' }); } else if (a.id === 'type' || a.id === 'price') { const opts = a.id === 'type' ? ['App', 'Game'] : ['Free', 'Paid']; const got = st?.checked ? opts.filter(o => checked.some(c => c === fold(o) || c.startsWith(`${fold(o)} `))) : []; const seen = got.length === 1 ? got[0]! : got.length ? got.join(' + ') : null; rows.push({ item: a.label, expected: exp ?? '(owner decides)', seen: seen ?? (st?.checked ? 'nothing selected' : 'not readable from page text: pass the input states (plan capture)'), status: exp === null ? 'ask-owner' : seen === null ? (st?.checked ? 'mismatch' : 'not-found') : seen === exp ? 'match' : 'mismatch' }); } else if (a.id === 'language') { const v = fieldValue(['Default language', 'Varsayılan dil']); const inText = exp !== null && ls.some(l => l.includes(exp)); rows.push({ item: a.label, expected: exp ?? '(owner decides)', seen: v ?? (inText ? exp! : 'not found'), status: exp === null ? 'ask-owner' : v !== null ? (v.includes(exp) ? 'match' : 'mismatch') : inText ? 'match' : 'not-found' }); } } const hints = ls.filter(l => SELECT_HINT.test(l)); rows.push({ item: '"Select …" hints (a radio set on the input but not in the form\'s model)', expected: 'none', seen: hints.length ? hints.join('; ') : 'none', status: hints.length ? 'mismatch' : text ? 'match' : 'not-found', ...(hints.length ? { note: 'click the inner [role=radio] of that material-radio (a JS click), then check again' } : {}) }); if (ls.some(l => /package name (is )?available/i.test(l))) rows.push({ item: 'Check availability', expected: 'Package name available', seen: 'Package name available', status: 'match' }); return result('play-app-record', st ? 'inputs' : 'page-text', rows, st ? [] : ['Page text only: radios cannot be read from text. For a full check pass the JSON of the plan\'s capture snippet.']); } // ---- label / answer pairs from page text (summaries) ---- /** The answer next to a label: on the same line after the label, or the next line. */ function valueAfter(ls: string[], label: RegExp): string | null { for (let i = 0; i < ls.length; i++) { const l = ls[i]!; const m = label.exec(l); if (!m) continue; const rest = l.slice(m.index + m[0].length).replace(/^[\s:–-]+/, '').trim(); return rest || ls[i + 1] || null; } return null; } const yesNoOf = (s: string | null): boolean | null => (s === null ? null : /^(yes|evet)\b/i.test(s) ? true : /^(no|hayır)\b/i.test(s) ? false : null); function labelRows(answers: Answer[], ls: string[], labelOf: (a: Answer) => RegExp | null): VerifyRow[] { const rows: VerifyRow[] = []; for (const a of answers) { if (a.agreement) continue; const re = labelOf(a); if (!re) continue; const raw = valueAfter(ls, re); if (typeof a.value === 'boolean' || a.value === null && raw !== null && yesNoOf(raw) !== null) { const v = yesNoOf(raw); rows.push({ item: a.label, expected: a.value === null ? '(owner decides)' : yn(a.value as boolean), seen: v === null ? (raw ?? 'not found') : yn(v), status: a.value === null ? 'ask-owner' : v === null ? 'not-found' : v === a.value ? 'match' : 'mismatch' }); } else { const exp = a.value === null ? null : String(a.value); rows.push({ item: a.label, expected: exp ?? '(owner decides)', seen: raw ?? 'not found', status: exp === null ? 'ask-owner' : raw === null ? 'not-found' : fold(raw).includes(fold(exp)) ? 'match' : 'mismatch' }); } } return rows; } const escRe = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); export function verifyContentRating(config: ShipConfig, text: string): VerifyResult { const answers = consoleAnswers('play-content-rating', config).filter(a => a.id !== 'email'); const TOPIC: Record = { violence: /violen/i, fear: /(fear|horror|scary)/i, sexuality: /(sexual|nudity)/i, language: /(profan|crude language)/i, controlledSubstances: /(drug|alcohol|tobacco|controlled substance)/i, crudeHumor: /crude humou?r/i, gambling: /real[- ](money|currency)/i, simulatedGambling: /simulated gambling/i, lootBoxes: /(loot box|random(ized)? (paid )?items)/i, userInteraction: /(interact|communicate|exchange)/i, sharesLocation: /location/i, digitalPurchases: /digital (goods|purchases)|purchase digital/i, unrestrictedInternet: /(unrestricted (internet|web)|web browser)/i, }; const rows = labelRows(answers, lines(text), a => (a.id === 'category' ? /^(category|app category)\b/i : a.id.startsWith('q:') ? TOPIC[a.id.slice(2)] ?? null : null)); return result('play-content-rating', 'page-text', rows, ['Modeled summary wording: a question that is not found is reported, never counted as a match.']); } export function verifyTargetAudience(config: ShipConfig, text: string): VerifyResult { const answers = consoleAnswers('play-target-audience', config); const ls = lines(text); const rows: VerifyRow[] = []; const ages = answers.filter(a => a.id.startsWith('age:')); const blob = fold(ls.join('\n')); if (ages.length) { const want = ages.filter(a => a.value === true).map(a => a.label.replace(/^Target age: /, '')); const shown = ages.map(a => a.label.replace(/^Target age: /, '')).filter(l => blob.includes(fold(l)) || blob.includes(fold(l.replace(/^Ages /, '')))); const unsure = ages.some(a => !a.certain); const same = want.length === shown.length && want.every(w => shown.includes(w)); rows.push({ item: 'Target age groups', expected: unsure ? '(owner decides)' : want.join(', '), seen: shown.join(', ') || 'not found', status: unsure ? 'ask-owner' : !shown.length ? 'not-found' : same ? 'match' : 'mismatch' }); } rows.push(...labelRows(answers.filter(a => a.id === 'appeal'), ls, () => /appeal to children/i)); const ads = answers.find(a => a.id === 'ads'); if (ads) { const no = ls.some(l => /(does not|doesn't) contain ads|no,? (my app )?(does not|doesn't) contain ads|^no ads$/i.test(l)); const yes = !no && ls.some(l => /contains ads|^ads$|yes,? my app contains ads/i.test(l)); const seen = no ? false : yes ? true : null; rows.push({ item: ads.label, expected: ads.value === null ? '(owner decides)' : yn(ads.value as boolean), seen: yn(seen), status: ads.value === null ? 'ask-owner' : seen === null ? 'not-found' : seen === ads.value ? 'match' : 'mismatch' }); } return result('play-target-audience', 'page-text', rows, ['Modeled summary wording: rows that are not found are reported, never counted as a match.']); } export function verifyAppRecordText(config: ShipConfig, text: string): VerifyResult { const answers = consoleAnswers('apple-app-record', config).filter(a => ['name', 'language', 'bundleId', 'sku'].includes(a.id)); const rows = labelRows(answers, lines(text), a => new RegExp(`^${escRe(a.label)}\\b`, 'i')); return result('apple-app-record', 'page-text', rows); } /** apple-app-record against the API's app attributes (GET /v1/apps?filter[bundleId]). null: no such app. */ export function verifyAppRecordApi(config: ShipConfig, app: { name?: string; bundleId?: string; sku?: string; primaryLocale?: string } | null): VerifyResult { const answers = consoleAnswers('apple-app-record', config); const by = new Map(answers.map(a => [a.id, a])); if (!app) return result('apple-app-record', 'api', [{ item: 'App record', expected: `an app with bundle id ${config.apple?.bundleId ?? '?'}`, seen: 'none', status: 'mismatch' }]); const locale = config.apple?.primaryLocale ?? config.languages.find(l => l.apple)?.apple ?? null; const row = (item: string, exp: string | null, seen: string | undefined, certain = true): VerifyRow => ({ item, expected: exp ?? '(owner decides)', seen: seen ?? 'not set', status: exp === null || !certain ? 'ask-owner' : seen === undefined ? 'not-found' : seen === exp ? 'match' : 'mismatch' }); const name = by.get('name'), sku = by.get('sku'); return result('apple-app-record', 'api', [ row('Bundle ID', config.apple?.bundleId ?? null, app.bundleId), row('Name', name?.value === null || name === undefined ? null : String(name.value), app.name), row('Primary Language (locale)', locale, app.primaryLocale), row('SKU', sku?.certain ? String(sku.value) : null, app.sku), ]); } /** apple-age-rating against the API's ageRatingDeclaration attributes. */ export function verifyAgeRatingApi(config: ShipConfig, attrs: Record | null): VerifyResult { const answers = consoleAnswers('apple-age-rating', config); if (!attrs) return result('apple-age-rating', 'api', [{ item: 'Age rating declaration', expected: 'set', seen: 'none', status: 'mismatch' }]); const rows = answers.map(a => { const seen = attrs[a.id]; const s = seen === undefined || seen === null ? undefined : String(seen); return { item: a.id, expected: a.certain ? valueText(a) : '(owner decides)', seen: s ?? 'not set', status: (!a.certain ? 'ask-owner' : s === undefined ? 'not-found' : s === String(a.value) ? 'match' : 'mismatch') as RowStatus }; }); return result('apple-age-rating', 'api', rows); } /** * The App content overview: the capture JSON ({ start: the aria-labels of the "Start declaration" buttons still * on the page, text }) or the plain page text. A card whose start button is still there is not done. */ export function verifyAppContent(text: string): VerifyResult { let st: { start: string[] } | null = null; try { const j = JSON.parse(text) as unknown; if (j && typeof j === "object" && Array.isArray((j as { start?: unknown }).start)) st = j as { start: string[] }; } catch { /* page text */ } const pending = new Set((st?.start ?? []).map(s => fold(String(s)))); const rows: VerifyRow[] = APP_CONTENT_CARDS.map(c => { const open = pending.has(fold(startLabel(c.name))); return { item: c.name, expected: 'declared (no Start button)', seen: st ? (open ? `"${startLabel(c.name)}" still shown` : 'no Start button') : 'not readable from page text: pass the plan capture JSON', status: st ? (open ? 'mismatch' : 'match') : 'not-found' }; }); return result('play-app-content', st ? 'inputs' : 'page-text', rows, st ? [] : ['The start buttons are aria-labels, not page text: pass the JSON of the plan\'s capture snippet.']); } /** * Store settings and the single App content declarations, from the page text after Save: text answers (the category, * the privacy policy URL) and option labels must appear; the App category's "Not selected" is a mismatch. Yes / No * radios cannot be told apart in page text: reported, never counted as a match. */ export function verifyPlayDeclaration(form: Exclude, config: ShipConfig, text: string): VerifyResult { const part = playOnlyPart(form, config, {}); const blob = fold(text); const rows: VerifyRow[] = []; for (const a of part.answers) { if (a.status === 'agreement' || a.id === 'credentials') continue; // the demo account is never read back or printed if (a.status === 'ask-owner' || a.value === null) { rows.push({ item: a.question, expected: '(owner decides)', seen: '-', status: 'ask-owner' }); continue; } const want = a.action === 'type' || a.action === 'choose' ? String(a.value) : a.label ?? ''; const plain = !want || /^(yes|no)$/i.test(want) || a.action === 'untick'; if (plain) { rows.push({ item: a.question, expected: a.text, seen: 'not readable from page text', status: 'not-found', note: 'read the control (aria-checked) in the page' }); continue; } const shown = blob.includes(fold(want)); const unset = a.id === 'category' && /not selected|select a category/.test(blob); rows.push({ item: a.question, expected: want, seen: shown ? want : unset ? 'Not selected' : 'not found', status: shown && !unset ? 'match' : unset ? 'mismatch' : 'not-found' }); } return result(form, 'page-text', rows, ['Page text after Save: Yes / No radios and unticked boxes are not readable from text and are never counted as a match.']); } /** The production track's Countries / regions tab: " countries / regions" (0 is a mismatch) and, with * play.countries, each country's English name on the page. */ export function verifyProductionCountries(config: ShipConfig, text: string): VerifyResult { const m = /(\d+)\s+(countries|countries\s*\/\s*regions|regions)\b/i.exec(text); const n = m ? Number(m[1]) : null; const want = config.play?.countries; const rows: VerifyRow[] = [{ item: 'Countries / regions', expected: want?.length ? String(want.length) : 'all (more than 0)', seen: n === null ? 'not found' : String(n), status: n === null ? 'not-found' : n === 0 || (want?.length && n !== want.length) ? 'mismatch' : 'match' }]; const blob = fold(text); for (const c of want ?? []) { let name = c; try { name = new Intl.DisplayNames(['en'], { type: 'region' }).of(c) ?? c; } catch { /* the code */ } rows.push({ item: `${name} (${c})`, expected: 'listed', seen: blob.includes(fold(name)) ? 'listed' : 'not found', status: blob.includes(fold(name)) ? 'match' : 'not-found' }); } return result('play-production-countries', 'page-text', rows); } /** Publishing overview after the owner sent the changes: nothing left "not yet sent for review". */ export function verifyProductionRelease(text: string): VerifyResult { const f = fold(text); const pending = /not yet sent for review|submit \d+ changes? for review/.test(f); const inReview = /in review|changes in review|sent for review/.test(f); const rows: VerifyRow[] = [{ item: 'Changes sent for review', expected: 'sent (in review)', seen: pending ? 'changes not yet sent for review' : inReview ? 'in review' : 'not found', status: pending ? 'mismatch' : inReview ? 'match' : 'not-found', ...(pending ? { note: 'the owner presses "Submit N changes for review", then "Send changes for review" (a real click)' } : {}) }]; return result('play-production-release', 'page-text', rows); } /** Page-text verification of any form that has one. */ export function verifyText(form: PlanFormId, config: ShipConfig, text: string): VerifyResult { if (form === 'play-app-content') return verifyAppContent(text); if (form === 'play-production-countries') return verifyProductionCountries(config, text); if (form === 'play-production-release') return verifyProductionRelease(text); if (form === 'play-store-settings' || form === 'play-privacy-policy' || form === 'play-ads' || form === 'play-sign-in-details' || form === 'play-advertising-id' || form === 'play-government-apps' || form === 'play-financial-features' || form === 'play-health-apps') return verifyPlayDeclaration(form, config, text); switch (form) { case 'apple-app-privacy': return verifyAppPrivacy(config, text); case 'play-app-record': return verifyPlayAppRecord(config, text); case 'play-content-rating': return verifyContentRating(config, text); case 'play-target-audience': return verifyTargetAudience(config, text); case 'apple-app-record': return verifyAppRecordText(config, text); case 'apple-age-rating': throw new Error('console verify apple-age-rating reads the App Store Connect API (no --from-text)'); } } export function verifyTable(r: VerifyResult): string { const w = Math.min(48, Math.max(4, ...r.rows.map(x => x.item.length))); const L = [`${r.form} (${r.source})`, '']; for (const x of r.rows) L.push(` ${x.status.padEnd(9)} ${x.item.padEnd(w)} expected ${x.expected} | page ${x.seen}${x.note ? ` (${x.note})` : ''}`); L.push('', `${r.matched} match, ${r.mismatches} mismatch, ${r.rows.filter(x => x.status === 'not-found').length} not found, ${r.rows.filter(x => x.status === 'ask-owner').length} for the owner`, ...r.notes.map(n => `note: ${n}`)); if (!r.ok) L.push(r.mismatches ? 'MISMATCH: fix the page (or the config), then verify again.' : 'Nothing could be matched: pass the page text of the finished form.'); return L.join('\n'); }