Play console steps without an API as agent plans: play-store-settings (App category; play listing cannot set it), play-app-content (every card in order, one owner batch) and its cards (privacy policy, ads, sign in details, advertising ID, government, financial, health), play-production-countries and play-production-release; IARC plan with the Terms of Use agreement, follow-ups (console.contentRating.details, products[].randomRewards, social) and the Summary's final Save; config play.category / countries / managedPublishing, console.appContent; check, validate, play status and play release reminders; inPageLoop helper and material checkbox clicks; App Privacy published signal; first IAPs via apple iap-submit --with-version. Pitfalls 27, 28

This commit is contained in:
CRTkafa 2026-10-12 10:16:34 +08:00
parent dbd9f2973c
commit b7bfe89151
37 changed files with 1624 additions and 121 deletions

View file

@ -12,7 +12,8 @@
// 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, valueText } from './console-answers';
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 { APPLE_PURPOSES } from './console-answers';
@ -20,7 +21,7 @@ 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: FormId;
form: PlanFormId;
source: 'page-text' | 'inputs' | 'api';
rows: VerifyRow[];
mismatches: number;
@ -34,7 +35,7 @@ const lines = (text: string): string[] => text.normalize('NFC').split(/\r?\n/).m
/** 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: FormId, source: VerifyResult['source'], rows: VerifyRow[], notes: string[] = []): VerifyResult {
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 };
}
@ -122,9 +123,16 @@ export function verifyAppPrivacy(config: ShipConfig, text: string): VerifyResult
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. */
@ -299,8 +307,76 @@ export function verifyAgeRatingApi(config: ShipConfig, attrs: Record<string, unk
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.']);
}
/**
* 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'>, 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') continue;
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: "<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);
}
/** Page-text verification of any form that has one. */
export function verifyText(form: FormId, config: ShipConfig, text: string): VerifyResult {
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);