storebound/cli/apple/screenshot-health.ts

147 lines
9.8 KiB
TypeScript

// Screenshots that hold up a submission: App Store Connect answers 409 STATE_ERROR.ASSET_IN_POST_PROCESSING.APP_SCREENSHOT
// to a submit while any screenshot of the version is not COMPLETE. Seen live: a part upload answered with a non-2xx
// behind `Expect: 100-continue` was still committed, and the screenshot then sat in UPLOAD_COMPLETE for hours (never
// processed), blocking the submit. This module names every such screenshot (locale, display type, position, file),
// tells stuck ones (UPLOAD_COMPLETE longer than STUCK_MS) from ones still processing, and replaces only those in
// place (`apple screenshots --repair-stuck`).
//
// Apple gives no timestamp on a screenshot, so "how long in UPLOAD_COMPLETE" comes from a local ledger
// (.storebound/screenshot-states.json: screenshot id -> first time this machine saw it UPLOAD_COMPLETE, or committed it).
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { basename, dirname, join } from 'node:path';
import type { J } from './asc';
/** UPLOAD_COMPLETE longer than this is stuck, not processing (Apple normally finishes in well under a minute). */
export const STUCK_MS = 5 * 60_000;
export interface ShotRow { id: string; locale: string; displayType: string; position: number; fileName: string; state: string; checksum?: string | null }
export type PendingKind = 'processing' | 'stuck' | 'failed' | 'awaiting-upload';
export interface Pending extends ShotRow { kind: PendingKind; ageMs?: number }
/** The submit / validate error this module explains. Pure. */
export const isScreenshotProcessing409 = (e: unknown): boolean =>
/-> 409\b/.test(String((e as Error)?.message ?? e)) && /ASSET_IN_POST_PROCESSING(\.APP_SCREENSHOT)?/.test(String((e as Error)?.message ?? e));
const ledgerFile = (root: string): string => join(root, '.storebound', 'screenshot-states.json');
export function readLedger(root: string): Record<string, number> {
try { return JSON.parse(readFileSync(ledgerFile(root), 'utf8')) as Record<string, number>; } catch { return {}; }
}
/** Notes the first time each UPLOAD_COMPLETE screenshot of `rows` was seen (a known one keeps its time; one in another
* state is dropped; screenshots not in `rows` are left as they are). */
export function updateLedger(root: string, rows: { id: string; state: string }[], now = Date.now()): Record<string, number> {
const next: Record<string, number> = { ...readLedger(root) };
for (const r of rows) { if (r.state === 'UPLOAD_COMPLETE') next[r.id] = next[r.id] ?? now; else delete next[r.id]; }
try { mkdirSync(dirname(ledgerFile(root)), { recursive: true }); writeFileSync(ledgerFile(root), JSON.stringify(next, null, 2) + '\n'); } catch { /* read-only project: ages stay unknown */ }
return next;
}
/** Records a screenshot this run just committed (its UPLOAD_COMPLETE clock starts now). */
export function noteCommitted(root: string, id: string, now = Date.now()): void {
const l = readLedger(root);
l[id] = l[id] ?? now;
try { mkdirSync(dirname(ledgerFile(root)), { recursive: true }); writeFileSync(ledgerFile(root), JSON.stringify(l, null, 2) + '\n'); } catch { /* ignore */ }
}
/** Every screenshot that is not COMPLETE, classified. A screenshot with no known first-seen time counts as processing
* (the next look starts its clock). Pure. */
export function pendingScreenshots(rows: ShotRow[], seen: Record<string, number>, now: number, stuckMs = STUCK_MS): Pending[] {
const out: Pending[] = [];
for (const r of rows) {
if (r.state === 'COMPLETE') continue;
if (r.state === 'FAILED') { out.push({ ...r, kind: 'failed' }); continue; }
if (r.state === 'AWAITING_UPLOAD') { out.push({ ...r, kind: 'awaiting-upload' }); continue; }
const at = seen[r.id];
const ageMs = at === undefined ? undefined : now - at;
out.push({ ...r, kind: r.state === 'UPLOAD_COMPLETE' && ageMs !== undefined && ageMs > stuckMs ? 'stuck' : 'processing', ...(ageMs !== undefined ? { ageMs } : {}) });
}
return out;
}
/** Can --repair-stuck replace it (stuck, failed or a stranded reservation)? Processing ones are left to finish. */
export const repairable = (p: Pending): boolean => p.kind !== 'processing';
const mins = (ms: number): string => `${Math.round(ms / 60_000)} min`;
/** One line per pending screenshot. Pure. */
export function describePending(list: Pending[]): string[] {
return list.map(p => `${p.locale} ${p.displayType} #${p.position} ${p.fileName}: ${p.state}${p.ageMs !== undefined ? ` for ${mins(p.ageMs)}` : ''} -> ${
p.kind === 'stuck' ? 'STUCK (not processing)' : p.kind === 'failed' ? 'FAILED' : p.kind === 'awaiting-upload' ? 'stranded reservation (never uploaded)' : 'still processing'}`);
}
/** The explanation for a 409 ASSET_IN_POST_PROCESSING: what holds the submit and what to do. Pure. */
export function explain409(list: Pending[]): string {
if (!list.length) return 'App Store Connect says a screenshot is still processing, but every screenshot of the version now reads COMPLETE: run the same command again.';
const bad = list.filter(repairable);
return `App Store Connect refuses the submit while ${list.length} screenshot(s) are not COMPLETE:\n${describePending(list).map(l => ' ' + l).join('\n')}\n`
+ (bad.length ? `${bad.length} will never finish on their own: storebound apple screenshots --repair-stuck --dry, then without --dry (replaces only these, at their positions), then submit again.`
: `They are still processing (under ${mins(STUCK_MS)}): run the same command again in a few minutes.`);
}
/** Every screenshot of one version, with its locale, display type and 1-based position. */
export async function versionScreenshotRows(c: { all: (p: string) => Promise<J[]> }, versionId: string): Promise<ShotRow[]> {
const rows: ShotRow[] = [];
for (const loc of await c.all(`/v1/appStoreVersions/${versionId}/appStoreVersionLocalizations?limit=50`))
for (const set of await c.all(`/v1/appStoreVersionLocalizations/${loc.id}/appScreenshotSets?limit=50`))
rows.push(...setRows(await c.all(`/v1/appScreenshotSets/${set.id}/appScreenshots?limit=50`), loc.attributes.locale, set.attributes.screenshotDisplayType));
return rows;
}
export const setRows = (shots: J[], locale: string, displayType: string): ShotRow[] => shots.map((s, i) => ({
id: s.id, locale, displayType, position: i + 1, fileName: s.attributes?.fileName ?? '?', state: s.attributes?.assetDeliveryState?.state ?? 'UNKNOWN', checksum: s.attributes?.sourceFileChecksum,
}));
/** Request headers of one upload part, without `Expect` (100-continue): seen live, a part sent with it got a non-2xx
* that looked like progress and the asset never processed. Pure. */
export function partHeaders(op: { requestHeaders?: { name: string; value: string }[] }): Record<string, string> {
const h: Record<string, string> = {};
for (const x of op.requestHeaders ?? []) if (x.name.toLowerCase() !== 'expect') h[x.name] = x.value;
return h;
}
export interface RepairStep { id: string; position: number; fileName: string; file: string | null; kind: PendingKind }
/** What --repair-stuck does in one set: each repairable screenshot with the local file of the same name (else the file
* at its position). Pure. */
export function repairPlan(shots: ShotRow[], pending: Pending[], files: string[]): RepairStep[] {
const ids = new Set(pending.filter(repairable).map(p => p.id));
return shots.filter(s => ids.has(s.id)).map(s => ({
id: s.id, position: s.position, fileName: s.fileName, kind: pending.find(p => p.id === s.id)!.kind,
file: files.find(f => basename(f) === s.fileName) ?? files[s.position - 1] ?? null,
}));
}
export interface RepairClient {
asc: (method: string, path: string, body?: unknown) => Promise<J>;
all: (path: string) => Promise<J[]>;
uploadAsset: (type: string, rel: Record<string, unknown>, file: string) => Promise<string>;
}
/** Replaces the planned screenshots of one set at their own positions: delete, upload the same file, put the order
* back, then read the set again and check the count did not change. Re-runnable: a step whose screenshot is gone
* already is skipped, and a set that still holds the same count afterwards is left as it is. */
export async function repairSet(c: RepairClient, setId: string, steps: RepairStep[], say: (m: string) => void = () => {}): Promise<{ replaced: number }> {
const before = (await c.all(`/v1/appScreenshotSets/${setId}/appScreenshots?limit=50`)).map(x => x.id as string);
const order = [...before];
let replaced = 0;
for (const s of steps) {
const at = order.indexOf(s.id);
if (at < 0) continue; // replaced by an earlier run
if (!s.file || !existsSync(s.file)) throw new Error(`#${s.position} ${s.fileName}: no local file to re-upload (looked for the same name in the renders); nothing changed for it`);
await c.asc('DELETE', `/v1/appScreenshots/${s.id}`);
const id = await c.uploadAsset('appScreenshots', { appScreenshotSet: { data: { type: 'appScreenshotSets', id: setId } } }, s.file);
order[at] = id;
replaced++;
say(`#${s.position} ${s.fileName}: replaced (${s.kind})`);
}
if (replaced) {
const now = (await c.all(`/v1/appScreenshotSets/${setId}/appScreenshots?limit=50`)).map(x => x.id as string);
if (now.join() !== order.join() && order.every(id => now.includes(id)))
await c.asc('PATCH', `/v1/appScreenshotSets/${setId}/relationships/appScreenshots`, { data: order.map(id => ({ type: 'appScreenshots', id })) });
const after = (await c.all(`/v1/appScreenshotSets/${setId}/appScreenshots?limit=50`)).map(x => x.id as string);
if (after.length !== before.length) throw new Error(`set ${setId}: ${before.length} screenshots before the repair, ${after.length} after: re-run storebound apple screenshots to restore the set`);
if (after.join() !== order.join()) throw new Error(`set ${setId}: order after the repair differs from the original positions: re-run storebound apple screenshots`);
}
return { replaced };
}