storebound/cli/lib/console-verify.ts

496 lines
36 KiB
TypeScript

// `storebound console verify <form>`: 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 <purposes>",
// "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 { verifyPlayGames } from './console-games-plan';
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';
import type { AppGroupGap } from './targets';
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/<appId>/...). */
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 <type>" 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<string, string>(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<string, PrivacyTypeSeen> {
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<string, PrivacyTypeSeen>();
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 <when> by <who>"; 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<string, string>;
/** 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/<appId>/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<string, unknown>;
const fields = j.fields && typeof j.fields === 'object' ? Object.fromEntries(Object.entries(j.fields as Record<string, unknown>).filter(([, v]) => typeof v === 'string')) as Record<string, string> : 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/<appId>/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<string, RegExp> = {
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<string, unknown> | 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 <Name> 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.']);
}
/** One labelled control read from the DOM by INPUT_STATES_CAPTURE. */
export interface InputState { label: string; role: 'radio' | 'checkbox' | 'option' | string; checked: boolean; group?: string }
export { INPUT_STATES_CAPTURE } from './console-play-plans';
/** The input states of a capture JSON ({ states, text }); null for plain page text. */
export function parseStates(raw: string): { states: InputState[]; text: string } | null {
const s = raw.trim();
if (!s.startsWith('{')) return null;
try {
const j = JSON.parse(s) as { states?: unknown; text?: unknown };
if (!Array.isArray(j.states)) return null;
const states = j.states.filter((x): x is InputState => !!x && typeof x === 'object' && typeof (x as InputState).label === 'string' && typeof (x as InputState).checked === 'boolean');
return { states, text: typeof j.text === 'string' ? j.text : '' };
} catch { return null; }
}
/**
* Store settings and the single App content declarations, from the page text after Save: text answers (the category,
* the privacy policy URL) and option labels must appear; the App category's "Not selected" is a mismatch. Yes / No
* radios cannot be told apart in page text: reported, never counted as a match.
*/
export function verifyPlayDeclaration(form: Exclude<PlanOnlyFormId, 'play-app-content' | 'apple-app-group'>, config: ShipConfig, text: string): VerifyResult {
const part = playOnlyPart(form, config, {});
const st = parseStates(text);
const blob = fold(st ? st.text : text);
const rows: VerifyRow[] = [];
const radios = st?.states.filter(x => /radio/.test(x.role)) ?? [];
const radioQuestions = part.answers.filter(a => a.control === 'radio').length;
for (const a of part.answers) {
if (a.status === 'agreement' || a.id === 'credentials') continue; // the demo account is never read back or printed
if (a.status === 'ask-owner' || a.value === null) { rows.push({ item: a.question, expected: '(owner decides)', seen: '-', status: 'ask-owner' }); continue; }
const labels = [a.label, ...(a.altLabels ?? [])].filter((l): l is string => !!l);
const want = a.action === 'type' || a.action === 'choose' ? String(a.value) : a.label ?? '';
if (st && (a.control === 'radio' || a.control === 'checkbox') && labels.length) {
// input states from the DOM: the control with this (or an older) label, checked as the sheet says
let pool = a.control === 'radio' ? radios : st.states.filter(x => /checkbox|option/.test(x.role));
if (a.control === 'radio') {
const byQ = pool.filter(x => x.group && fold(x.group).startsWith(fold(a.question)));
if (byQ.length) pool = byQ;
else if (radioQuestions > 1) pool = []; // several radio questions: only an exact question group tells them apart
}
const hit = pool.find(x => labels.some(l => fold(x.label) === fold(l)));
const wantOn = a.action !== 'untick';
const picked = a.control === 'radio' ? pool.filter(x => x.checked).map(x => x.label) : [];
rows.push(hit
? { item: a.question, expected: `${labels[0]} ${wantOn ? 'checked' : 'unchecked'}`, seen: `${hit.label} ${hit.checked ? 'checked' : 'unchecked'}${!hit.checked && picked.length ? ` (checked: ${picked.join(', ')})` : ''}`, status: hit.checked === wantOn ? 'match' : 'mismatch' }
: { item: a.question, expected: `${labels[0]} ${wantOn ? 'checked' : 'unchecked'}`, seen: 'no control with that label', status: 'not-found' });
continue;
}
// an older page shows a long labelled radio instead of Yes / No: read that from text when it is there
const older = a.control === 'radio' && a.action !== 'untick' ? (a.altLabels ?? []).find(l => !/^(yes|no)$/i.test(l) && blob.includes(fold(l))) : undefined;
if (older) { rows.push({ item: a.question, expected: want, seen: older, status: 'match' }); continue; }
const plain = !want || /^(yes|no)$/i.test(want) || a.action === 'untick';
if (plain) { rows.push({ item: a.question, expected: a.text, seen: 'not readable from page text', status: 'not-found', note: 'pass the input states: run the plan\'s verify.capture snippet and `--from -`' }); continue; }
const shownAs = a.action === 'type' || a.action === 'choose' ? (blob.includes(fold(want)) ? want : null) : labels.find(l => blob.includes(fold(l))) ?? null;
const unset = a.id === 'category' && /not selected|select a category/.test(blob);
rows.push({ item: a.question, expected: want, seen: shownAs ?? (unset ? 'Not selected' : 'not found'), status: shownAs && !unset ? 'match' : unset ? 'mismatch' : 'not-found' });
}
return result(form, st ? 'inputs' : 'page-text', rows, st ? ['Input states from the DOM (aria-checked, checked, aria-selected).'] : ['Page text after Save: Yes / No radios and unticked boxes are not readable from text and are never counted as a match; pass the input states (plan verify.capture, `--from -`).']);
}
/** The production track's Countries / regions tab: "<N> countries / regions" (0 is a mismatch) and, with
* 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);
}
/** Publishing overview as text (or the API's ready list): nothing left to publish. */
export function verifyPublishChanges(text: string): VerifyResult {
const f = fold(text);
const pending = /publish \d+ changes?|changes ready to publish/.test(f) && !/no changes ready to publish|0 changes ready/.test(f);
const rows: VerifyRow[] = [{ item: 'Changes ready to publish', expected: 'none left', seen: pending ? 'changes ready to publish' : 'none listed', status: pending ? 'mismatch' : 'match',
...(pending ? { note: 'the owner presses "Publish N changes" and confirms' } : {}) }];
return result('play-publish-changes', 'page-text', rows);
}
/** The same check from the API's ready list (`readyToPublish`). */
export function verifyPublishChangesApi(ready: { track: string; version: string | null; build: string | null; rollout: string }[] & { undetermined?: boolean }): VerifyResult {
const seen = ready.map(r => `${r.track} ${r.version ?? '?'} (${r.build ?? '?'}) ${r.rollout}`).join('; ');
const rows: VerifyRow[] = [{ item: 'Production releases ready to publish', expected: 'none left', seen: ready.length ? seen : ready.undetermined ? 'none proven (the store page does not show an older live version; not determined)' : 'none', status: ready.length ? 'mismatch' : 'match',
...(ready.length ? { note: 'the owner presses "Publish N changes" and confirms (storebound console plan play-publish-changes)' } : {}) }];
return result('play-publish-changes', 'api', rows, ['The API sees production releases only; Data safety and other non-track changes are not readable.',
...(ready.undetermined ? ['A release counts as ready only when the public store page shows an OLDER version; a 404, failed read or same version is never ready. Check Publishing overview ("Last published on ...") or pass the page text with --from-text -.'] : [])]);
}
/**
* apple-app-group (read-only, API): one row per (bundle id, App Group). match: the group is on the newest readable
* profile or named by the capability settings; mismatch: the evidence shows it missing; not-found: nothing readable
* proves it (after the portal Save the old profiles are invalid: run `storebound apple signing ensure --refresh-profile`,
* then verify again). ok only when every row matches.
*/
export function verifyAppGroupsApi(targets: { bundleId: string; appGroups: string[] }[], verdicts: { gaps: AppGroupGap[]; unproven: AppGroupGap[]; ok: string[] }): VerifyResult {
const rows: VerifyRow[] = [];
for (const t of targets.filter(x => x.appGroups.length)) {
const gap = verdicts.gaps.find(g => g.bundleId === t.bundleId), un = verdicts.unproven.find(g => g.bundleId === t.bundleId);
for (const g of t.appGroups) {
const item = `${t.bundleId}: ${g}`;
if (gap?.groups.includes(g)) rows.push({ item, expected: 'on the App ID (profile carries it)', seen: 'missing', status: 'mismatch', ...(gap.why ? { note: gap.why } : {}) });
else if (un?.groups.includes(g)) rows.push({ item, expected: 'on the App ID (profile carries it)', seen: 'cannot be proven', status: 'not-found', note: 'no ACTIVE profile shows it: run storebound apple signing ensure --refresh-profile, then verify again' });
else rows.push({ item, expected: 'on the App ID (profile carries it)', seen: 'present', status: 'match' });
}
}
const r = result('apple-app-group', 'api', rows, ['Reads the App Store Connect API: the App Groups capability and the newest readable provisioning profile of each bundle id.']);
return rows.some(x => x.status === 'not-found') ? { ...r, ok: false } : r;
}
/** 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-publish-changes') return verifyPublishChanges(text);
if (form === 'play-games-services') return result(form, 'page-text', verifyPlayGames(config, 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)');
case 'apple-app-group': throw new Error('console verify apple-app-group 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');
}