validate: removed-from-sale IAPs are no blockers; shots capture --device landscape (Mac / Chromebook frames from a web build); playwright lookup says why each place failed

This commit is contained in:
CRTkafa 2026-11-03 23:14:34 +07:00
parent cdd9321794
commit d0f568ebc2
13 changed files with 230 additions and 48 deletions

View file

@ -202,7 +202,9 @@ export interface ShotsConfig {
/** Tablet frames: default 1032 x 1352 @2x (13" iPad minus its 24 px bar) with apple.ipad, else 800 x 1240 @2x. */
tablet?: { w: number; h: number; dpr?: number; bar?: number };
/** Landscape layout (Mac, Chromebook, and the Play tablet slots with playTablets): copy on one side, the frame on the
* other. Frames: <frames>/<lang>/landscape/<name>.png at `size` CSS px (default 1440 x 900 @2x). frame: 'window' (a
* other. Frames: <frames>/<lang>/landscape/<name>.png at `size` CSS px (default 1440 x 900 @2x = 2880 x 1800), made by
* `shots capture --device landscape` (web source: a desktop viewport, no touch, no status bar crop; ios-sim / android-emu
* have no landscape device, use a web build or a folder). frame: 'window' (a
* Mac window around the game, the default for mac) or 'none' (the bare frame, the default for Play sizes). side:
* where the copy goes (default 'left'). playTablets: render tab7 / tab10 landscape (1920 x 1080 / 2560 x 1440). */
landscape?: { frame?: 'window' | 'none'; side?: 'left' | 'right'; playTablets?: boolean; size?: { w: number; h: number; dpr?: number } };
@ -217,7 +219,7 @@ export interface ShotsConfig {
/** One raw frame. Web: `path` (+ query). Simulator / emulator: `url` deep link and / or `args` / `extras`. */
/** Web capture: a selector, candidates tried in order, or per layout { phone, tablet, any } (own list first). Prefer data
* attributes (`[data-shot="shop-row"]`); `text=Shop` and `role=button[name="Shop"]` work as fallbacks. */
export type ShotSelector = string | string[] | { phone?: string | string[]; tablet?: string | string[]; any?: string | string[] };
export type ShotSelector = string | string[] | { phone?: string | string[]; tablet?: string | string[]; landscape?: string | string[]; any?: string | string[] };
export interface ShotSpec {
/** Web: wait (up to the waitFor timeout) until one of these matches, before waitFor; a miss names the selector and the size. */
@ -235,7 +237,10 @@ export interface ShotSpec {
delayMs?: number;
/** Web: JS run in the page before the screenshot. */
eval?: string[];
device?: 'phone' | 'tablet' | 'both';
/** Which frames this shot makes: phone (default), tablet, both (phone + tablet), landscape (<lang>/landscape/<name>.png at
* shots.landscape.size, web source only). When the project renders a landscape size (apple.mac, play.chromebook,
* shots.landscape.playTablets), shots without a device and 'both' shots also make a landscape frame. */
device?: 'phone' | 'tablet' | 'both' | 'landscape';
/** Web, capture.session "per-language": how this shot is reached in the already booted page. "spa" (default):
* history.pushState to its path + a popstate event, then waitFor / eval (switch scenes from those); "reload":
* page.goto in the same page. A failing waitFor there falls back to a fresh page. */

View file

@ -163,6 +163,7 @@ export interface PlaySnapshot {
const EDITABLE = ['PREPARE_FOR_SUBMISSION', 'DEVELOPER_REJECTED', 'REJECTED', 'METADATA_REJECTED', 'INVALID_BINARY'];
const IN_FLIGHT = ['WAITING_FOR_REVIEW', 'IN_REVIEW', 'PENDING_DEVELOPER_RELEASE', 'PROCESSING_FOR_DISTRIBUTION', 'READY_FOR_DISTRIBUTION', 'READY_FOR_SALE', 'ACCEPTED'];
const IAP_OK = ['READY_TO_SUBMIT', 'WAITING_FOR_REVIEW', 'IN_REVIEW', 'APPROVED'];
const IAP_OFF_SALE = ['DEVELOPER_REMOVED_FROM_SALE', 'REMOVED_FROM_SALE'];
/** Age rating questions whose null means "not answered" (kidsAgeBand null = not a kids app). */
// Left null on apps App Review accepted: gracRatingClassificationNumber (a Korean GRAC rating number, for apps GRAC
// rated), socialMediaAgeRestricted (asked only when socialMedia is true). socialMedia itself is a newer question that
@ -281,12 +282,17 @@ export function appleRules(s: AppleSnapshot): Finding[] {
const iaps = new Map(s.iaps.map(i => [i.productId, i] as const));
for (const id of s.expectedProducts) if (!iaps.has(id)) add('blocker', 'iap', `${id}: not on App Store Connect`, 'storebound apple iap');
const expected = new Set(s.expectedProducts);
for (const i of s.iaps) {
// a product the developer took off sale (old consumables, a retired unlock) does not block a submission; it matters
// only when the config still sells it
if (IAP_OFF_SALE.includes(i.state)) { if (expected.has(i.productId)) add('warn', 'iap', `${i.productId}: ${i.state}, but the config still sells it`, 'put it back on sale in App Store Connect, or drop it from products'); continue; }
if (!IAP_OK.includes(i.state)) add('blocker', 'iap', `${i.productId}: ${i.state}`, i.state === 'MISSING_METADATA' ? 'storebound apple iap + apple iap-review' : 'see the review-rejections skill');
else if (!i.reviewScreenshot && i.state !== 'APPROVED') add('blocker', 'iap', `${i.productId}: no review screenshot`, 'storebound apple iap-review');
}
for (const g of s.firstProductGaps ?? []) add(g.level === 'error' ? 'blocker' : 'warn', 'iap-first', g.msg, g.fix);
if (s.iaps.length && !f.some(x => x.code === 'iap')) add('ok', 'iap', `${s.iaps.length} in-app purchases ready`);
const onSale = s.iaps.filter(i => !IAP_OFF_SALE.includes(i.state)).length, offSale = s.iaps.length - onSale;
if (s.iaps.length && !f.some(x => x.code === 'iap')) add('ok', 'iap', `${onSale} in-app purchases ready${offSale ? ` (${offSale} removed from sale, not checked)` : ''}`);
f.push(...appleSubscriptionRules(s));
for (const m of s.capabilityMismatches ?? []) add('warn', 'capabilities', m.msg, m.fix);