play images: tablet, TV and Wear sets are optional; play.tablets switch (default: tablet renders exist); a missing optional set never deletes Play's images without --prune

A phone-only project aborted with 'no sevenInchScreenshots'. Play requires the 512 icon, the 1024x500 feature graphic and at least 2 screenshots (Play Console Help answer 9866151). play images uploads what exists and prints an info line for missing optional sets; play.tablets true makes missing tablet sets an error in shots render/check/gallery, validate and play images. Pitfall 19.
This commit is contained in:
CRTkafa 2026-10-12 08:21:03 +08:00
parent 18fde37b9b
commit a9261a8052
18 changed files with 364 additions and 83 deletions

View file

@ -2,6 +2,17 @@
## Unreleased ## Unreleased
- Fix (`play images`): a phone-only project no longer aborts with "no sevenInchScreenshots ... (render first)". Play
requires the 512 icon, the 1024 x 500 feature graphic and at least 2 screenshots (Play Console Help answer 9866151);
7" / 10" tablet screenshots are optional. `play images` (dry and real) uploads what exists (icon, feature graphic,
phone screenshots; tablet, TV and Wear sets only when rendered) and prints `tablet screenshots: none (optional; the
app isn't eligible for tablet featuring without them)`. Missing required sets are still errors, with the fix.
- New `play.tablets?: boolean` (default: true when tablet renders exist, else false). `false`: `shots render`,
`shots check`, `shots gallery`, `validate` and `play images` never ask for tablet sets. `true`: rendered, and
missing ones are errors (local renders and the listing on Play).
- A missing optional set never deletes the screenshots Play has for it: the dry run shows `keeps <type> on Play`;
new `--prune` deletes them (shown as `would delete` in the dry run; not exposed over MCP). Pitfall 19.
- Safety (`console fill`): storebound no longer copies the owner's Chrome cookies by default. A copied Google session - Safety (`console fill`): storebound no longer copies the owner's Chrome cookies by default. A copied Google session
reached Play Console once, then Google answered `/v3/signin/rejected`: Google binds its session cookies to the reached Play Console once, then Google answered `/v3/signin/rejected`: Google binds its session cookies to the
device (Chrome's Device Bound Session Credentials) and signs out sessions whose cookies look stolen, which can sign device (Chrome's Device Bound Session Credentials) and signs out sessions whose cookies look stolen, which can sign

View file

@ -45,7 +45,7 @@ export const COMMANDS: Record<string, [() => Promise<Mod>, string]> = {
'play products': [() => import('./play/products'), 'one-time products: create / update, regional prices, activate'], 'play products': [() => import('./play/products'), 'one-time products: create / update, regional prices, activate'],
'play subscriptions': [() => import('./play/subscriptions'), 'auto-renewable subscriptions: listings, base plan, regional prices, intro offer, activate (--csv prices sheet; asks first)'], 'play subscriptions': [() => import('./play/subscriptions'), 'auto-renewable subscriptions: listings, base plan, regional prices, intro offer, activate (--csv prices sheet; asks first)'],
'play listing': [() => import('./play/listing'), 'app details + title / short / full description per language; pull [--write]: live listings back into the listing file (diff first)'], 'play listing': [() => import('./play/listing'), 'app details + title / short / full description per language; pull [--write]: live listings back into the listing file (diff first)'],
'play images': [() => import('./play/images'), 'icon, feature graphic, phone / 7" / 10" screenshots per language; --variant <id>'], 'play images': [() => import('./play/images'), 'icon, feature graphic, phone screenshots per language, optional tablet / TV / Wear sets when rendered (play.tablets); --variant <id>, --prune'],
'play data-safety': [() => import('./play/data-safety'), '[--force] Data safety form (CSV import through the API; skipped when unchanged since the last upload)'], 'play data-safety': [() => import('./play/data-safety'), '[--force] Data safety form (CSV import through the API; skipped when unchanged since the last upload)'],
'play release': [() => import('./play/release'), 'upload an AAB and put it on tracks with release notes'], 'play release': [() => import('./play/release'), 'upload an AAB and put it on tracks with release notes'],
'play build': [() => import('./play/build'), 'gradle bundleRelease (Flutter: flutter build appbundle) with the next free versionCode and the upload key from env / .storebound; --dry'], 'play build': [() => import('./play/build'), 'gradle bundleRelease (Flutter: flutter build appbundle) with the next free versionCode and the upload key from env / .storebound; --dry'],

View file

@ -74,6 +74,7 @@
"jobs", "jobs",
"name", "name",
"off", "off",
"prune",
"src", "src",
"variant", "variant",
"write" "write"
@ -120,6 +121,7 @@
"include-drafts", "include-drafts",
"jobs", "jobs",
"name", "name",
"prune",
"src", "src",
"traffic", "traffic",
"variant", "variant",
@ -152,6 +154,7 @@
"apple screenshots": [ "apple screenshots": [
"include-drafts", "include-drafts",
"jobs", "jobs",
"prune",
"src", "src",
"variant", "variant",
"write" "write"
@ -369,6 +372,7 @@
], ],
"play images": [ "play images": [
"include-drafts", "include-drafts",
"prune",
"src", "src",
"variant", "variant",
"write" "write"
@ -599,6 +603,7 @@
"no-scan", "no-scan",
"out", "out",
"play", "play",
"prune",
"react-native", "react-native",
"src", "src",
"unity", "unity",
@ -760,6 +765,7 @@
"production", "production",
"profile", "profile",
"project", "project",
"prune",
"q", "q",
"rating", "rating",
"react-native", "react-native",
@ -1027,7 +1033,7 @@
"storebound play data-safety [--csv out.csv] [--dry] [--force]" "storebound play data-safety [--csv out.csv] [--dry] [--force]"
], ],
"play images": [ "play images": [
"storebound play images [--dry] [--only en-US,de-DE] [--src store/renders] [--variant b]" "storebound play images [--dry] [--only en-US,de-DE] [--src store/renders] [--variant b] [--prune]"
], ],
"play listing": [ "play listing": [
"storebound play listing [--dry] [--json] [--only en-US,de-DE] [--include-drafts]", "storebound play listing [--dry] [--json] [--only en-US,de-DE] [--include-drafts]",

View file

@ -509,6 +509,11 @@ export interface ShipConfig {
name?: string; name?: string;
/** Free (true) or paid (false) on Play. A free app can never become paid later; storebound flags it for the owner. */ /** Free (true) or paid (false) on Play. A free app can never become paid later; storebound flags it for the owner. */
free?: boolean; free?: boolean;
/** Play 7" and 10" tablet screenshots (renders tab7/ and tab10/). Optional on Play (required: icon, feature graphic,
* at least 2 phone screenshots), but without them the app is not eligible for tablet featuring. Default: true when
* tablet renders exist under the renders folder, else false. false = `shots render` / `shots check`, `validate` and
* `play images` never ask for them; true = missing tablet sets are errors. */
tablets?: boolean;
/** Promo video: a YouTube URL (public or unlisted, ads off, embeddable). Play takes no video upload. Sent by `play listing`. */ /** Promo video: a YouTube URL (public or unlisted, ads off, embeddable). Play takes no video upload. Sent by `play listing`. */
video?: string; video?: string;
/** Cloud Storage bucket of the Play Console report exports, e.g. "pubsite_prod_rev_<number>" (Play Console > /** Cloud Storage bucket of the Play Console report exports, e.g. "pubsite_prod_rev_<number>" (Play Console >

View file

@ -98,6 +98,8 @@ export interface PlaySnapshot {
expectedProducts: string[]; expectedProducts: string[];
/** Local renders (Play sizes only), checked before upload. */ /** Local renders (Play sizes only), checked before upload. */
renders?: RendersCheck; renders?: RendersCheck;
/** Resolved play.tablets: true = 7" and 10" tablet screenshots are required too. */
tablets?: boolean;
/** Play subscriptions and their base plans (validate reads them when the config has subscriptions). */ /** Play subscriptions and their base plans (validate reads them when the config has subscriptions). */
subscriptions?: { productId: string; basePlans: { basePlanId: string; state?: string }[] }[]; subscriptions?: { productId: string; basePlans: { basePlanId: string; state?: string }[] }[];
expectedSubscriptions?: { id: string; basePlanId: string }[]; expectedSubscriptions?: { id: string; basePlanId: string }[];
@ -186,7 +188,13 @@ export function appleRules(s: AppleSnapshot): Finding[] {
return sortFindings(f); return sortFindings(f);
} }
// Play Console Help, "Add preview assets to showcase your app" (answer 9866151, checked October 2026): an app icon and a
// feature graphic are required, and at least 2 screenshots; 7" / 10" tablet screenshots are optional (play.tablets).
export const PLAY_REQUIRED_IMAGES: Record<string, number> = { icon: 1, featureGraphic: 1, phoneScreenshots: 2 }; export const PLAY_REQUIRED_IMAGES: Record<string, number> = { icon: 1, featureGraphic: 1, phoneScreenshots: 2 };
/** Play tablet image types, required only with play.tablets (resolved: explicit, or tablet renders exist). */
export const PLAY_TABLET_IMAGES: Record<string, number> = { sevenInchScreenshots: 1, tenInchScreenshots: 1 };
/** Image types validate requires on Play for a project. */
export const playRequiredImages = (tablets: boolean | undefined): Record<string, number> => (tablets ? { ...PLAY_REQUIRED_IMAGES, ...PLAY_TABLET_IMAGES } : PLAY_REQUIRED_IMAGES);
/** app-ads.txt lines compared without spaces and case (the format is case-insensitive except the ids, which we keep). */ /** app-ads.txt lines compared without spaces and case (the format is case-insensitive except the ids, which we keep). */
export const normAdsLine = (l: string) => l.split('#')[0]!.split(',').map(p => p.trim().toLowerCase()).filter(Boolean).join(','); export const normAdsLine = (l: string) => l.split('#')[0]!.split(',').map(p => p.trim().toLowerCase()).filter(Boolean).join(',');
@ -200,7 +208,8 @@ export function playRules(s: PlaySnapshot): Finding[] {
for (const l of s.listings) { for (const l of s.listings) {
const miss = (['title', 'shortDescription', 'fullDescription'] as const).filter(k => !l[k]); const miss = (['title', 'shortDescription', 'fullDescription'] as const).filter(k => !l[k]);
if (miss.length) add('blocker', 'listing', `${l.language}: missing ${miss.join(', ')}`, 'storebound play listing'); if (miss.length) add('blocker', 'listing', `${l.language}: missing ${miss.join(', ')}`, 'storebound play listing');
for (const [type, n] of Object.entries(PLAY_REQUIRED_IMAGES)) if ((l.images[type] ?? 0) < n) add('blocker', 'images', `${l.language}: ${type} ${l.images[type] ?? 0}/${n}`, 'storebound play images'); for (const [type, n] of Object.entries(playRequiredImages(s.tablets))) if ((l.images[type] ?? 0) < n)
add('blocker', 'images', `${l.language}: ${type} ${l.images[type] ?? 0}/${n}${PLAY_TABLET_IMAGES[type] ? ' (play.tablets is on)' : ''}`, 'storebound play images');
} }
if (s.listings.length && !f.some(x => x.code === 'listing' || x.code === 'images')) add('ok', 'listing', `${s.listings.length} listings with text and graphics`); if (s.listings.length && !f.some(x => x.code === 'listing' || x.code === 'images')) add('ok', 'listing', `${s.listings.length} listings with text and graphics`);

View file

@ -153,7 +153,7 @@ export const TOOLS: ToolDef[] = [
description: 'Google Play app details + title / short / full description per language from the listing file. dry_run prints the plan (old -> new per field); draft languages are skipped (owner terminal run with --include-drafts). Not public until the next review / publish.', description: 'Google Play app details + title / short / full description per language from the listing file. dry_run prints the plan (old -> new per field); draft languages are skipped (owner terminal run with --include-drafts). Not public until the next review / publish.',
props: { only }, args: i => opt('only', i.only) }, props: { only }, args: i => opt('only', i.only) },
{ name: 'play_images', title: 'Upload Play graphics', command: ['play', 'images'], write: true, destructive: true, network: true, idempotent: true, { name: 'play_images', title: 'Upload Play graphics', command: ['play', 'images'], write: true, destructive: true, network: true, idempotent: true,
description: 'Replace icon, feature graphic and screenshots per Play language from the renders folder (or an A/B variant folder). Slow (minutes): prefer the terminal with a log for many languages.', description: 'Replace icon, feature graphic and phone screenshots per Play language from the renders folder (or an A/B variant folder); optional 7" / 10" tablet, TV and Wear sets only when rendered (play.tablets). A missing optional set never deletes what Play has (--prune is an owner terminal run). Slow (minutes): prefer the terminal with a log for many languages.',
props: { only, variant }, args: i => [...opt('only', i.only), ...opt('variant', i.variant)] }, props: { only, variant }, args: i => [...opt('only', i.only), ...opt('variant', i.variant)] },
{ name: 'play_products', title: 'Sync Play one-time products', command: ['play', 'products'], write: true, network: true, idempotent: true, { name: 'play_products', title: 'Sync Play one-time products', command: ['play', 'products'], write: true, network: true, idempotent: true,
description: 'Create one-time products with regional prices and activate them; update listing text of existing ones. dry_run shows per product what would change. Price, region or activation changes of existing products are the owner\'s (terminal confirmation): here they are skipped with a warning.', description: 'Create one-time products with regional prices and activate them; update listing text of existing ones. dry_run shows per product what would change. Price, region or activation changes of existing products are the owner\'s (terminal confirmation): here they are skipped with a warning.',

View file

@ -1,13 +1,17 @@
// Play store graphics per language, one edit per language (a Console save in parallel then only costs a retry of that // Play store graphics per language, one edit per language (a Console save in parallel then only costs a retry of that
// language, not of the whole upload). // language, not of the whole upload).
// storebound play images [--dry] [--only en-US,de-DE] [--src store/renders] [--variant b] // storebound play images [--dry] [--only en-US,de-DE] [--src store/renders] [--variant b] [--prune]
// Per Play language, from <renders>/<language id>/: icon (play.icon, 512x512), featureGraphic (feature/1024x500.*), // Per Play language, from <renders>/<language id>/: icon (play.icon, 512x512), featureGraphic (feature/1024x500.*) and
// phoneScreenshots (play/NN.*), sevenInchScreenshots (tab7/NN.*), tenInchScreenshots (tab10/NN.*). A .jpg next to a // phoneScreenshots (play/NN.*, at least 2) are required (Play Console Help answer 9866151). Optional, uploaded only
// .png wins (JPEG q92 uploads about 4x faster; `storebound shots jpeg` makes them). Play's images are compared with // when present: sevenInchScreenshots (tab7/NN.*), tenInchScreenshots (tab10/NN.*), tvScreenshots (tv/NN.*),
// the local files first (sha256, in order): only a changed image type is cleared and uploaded, in one edit per // wearScreenshots (wear/NN.*). play.tablets (default: tablet renders exist) = true makes the two tablet sets required,
// language that is committed whole or not at all, so a re-run changes nothing and never doubles an image. --variant <id> uploads <renders>/<id>/<lang>/... (an A/B variant from // false skips them. A missing optional set is an info line, never an abort, and never deletes what Play already has for
// shots.variants) to the main listing: Play store listing experiments themselves are set up in Play Console (no API). // that type unless --prune is given (the dry run shows it). A .jpg next to a .png wins (JPEG q92 uploads about 4x
// Uploads are slow: run in the background with a log file. // faster; `storebound shots jpeg` makes them). Play's images are compared with the local files first (sha256, in
// order): only a changed image type is cleared and uploaded, in one edit per language that is committed whole or not
// at all, so a re-run changes nothing and never doubles an image. --variant <id> uploads <renders>/<id>/<lang>/... (an
// A/B variant from shots.variants) to the main listing: Play store listing experiments themselves are set up in Play
// Console (no API). Uploads are slow: run in the background with a log file.
// --dry is read-only: one throwaway edit, the images Play has now (their sha256) compared with the local files per // --dry is read-only: one throwaway edit, the images Play has now (their sha256) compared with the local files per
// language and type, then the edit is deleted. Nothing is uploaded or cleared, not even inside that edit. // language and type, then the edit is deleted. Nothing is uploaded or cleared, not even inside that edit.
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
@ -19,8 +23,11 @@ import { done, log, step } from '../lib/log';
import { playClient } from './client'; import { playClient } from './client';
import { readPlayCopy } from './listing'; import { readPlayCopy } from './listing';
import { variantRoot } from '../shots/lib'; import { variantRoot } from '../shots/lib';
import { resolvedTablets } from '../shots/report';
export const PLAY_IMAGE_DIRS = { phoneScreenshots: 'play', sevenInchScreenshots: 'tab7', tenInchScreenshots: 'tab10' } as const; export const PLAY_IMAGE_DIRS = { phoneScreenshots: 'play', sevenInchScreenshots: 'tab7', tenInchScreenshots: 'tab10', tvScreenshots: 'tv', wearScreenshots: 'wear' } as const;
export const PLAY_TABLET_TYPES: readonly string[] = ['sevenInchScreenshots', 'tenInchScreenshots'];
const OPTIONAL_TYPES = ['sevenInchScreenshots', 'tenInchScreenshots', 'tvScreenshots', 'wearScreenshots'];
/** NN.png / NN.jpg in a dir, sorted, JPEG preferred over PNG of the same number. */ /** NN.png / NN.jpg in a dir, sorted, JPEG preferred over PNG of the same number. */
export function numberedImages(dir: string): string[] { export function numberedImages(dir: string): string[] {
@ -35,7 +42,9 @@ export function numberedImages(dir: string): string[] {
return [...byNum.keys()].sort((a, b) => Number(a) - Number(b)).map(n => join(dir, byNum.get(n)!)); return [...byNum.keys()].sort((a, b) => Number(a) - Number(b)).map(n => join(dir, byNum.get(n)!));
} }
export interface ImageChange { type: string; change: 'same' | 'replace'; remote: number; local: number } /** same / replace: a local set against Play's. keep / delete / none: an optional set with no local files (Play's images
* are kept, deleted with --prune, or Play has none either). */
export interface ImageChange { type: string; change: 'same' | 'replace' | 'keep' | 'delete' | 'none'; remote: number; local: number }
/** One image type: what Play has (sha256 of each uploaded file, in order) against the local files' sha256. Play keeps /** One image type: what Play has (sha256 of each uploaded file, in order) against the local files' sha256. Play keeps
* the uploaded bytes' hash, so equal lists mean an upload would change nothing. Pure. */ * the uploaded bytes' hash, so equal lists mean an upload would change nothing. Pure. */
@ -44,79 +53,143 @@ export function imageChange(type: string, remote: { sha256?: string }[], local:
return { type, change: same ? 'same' : 'replace', remote: remote.length, local: local.length }; return { type, change: same ? 'same' : 'replace', remote: remote.length, local: local.length };
} }
/** An optional type with no local files: Play's images stay unless `prune`. Pure. */
export function missingChange(type: string, remote: number, prune: boolean): ImageChange {
return { type, change: !remote ? 'none' : prune ? 'delete' : 'keep', remote, local: 0 };
}
/** What one language uploads: the sets with files, the optional types without, and errors (missing required sets). */
export interface LangImages { sets: [string, string[]][]; missing: string[]; errors: string[] }
/** The image sets of one language folder (`tablets` = resolved play.tablets). Reads the folder only. */
export function langImages(code: string, dir: string, icon: string, tablets: boolean): LangImages {
const out: LangImages = { sets: [], missing: [], errors: [] };
if (existsSync(icon)) out.sets.push(['icon', [icon]]);
else out.errors.push(`${code}: no icon at ${icon} (512x512, required; make it: storebound icons, or set play.icon)`);
const feature = ['jpg', 'jpeg', 'png'].map(e => join(dir, `feature/1024x500.${e}`)).find(existsSync);
if (feature) out.sets.push(['featureGraphic', [feature]]);
else out.errors.push(`${code}: no featureGraphic in ${join(dir, 'feature')} (1024x500, required; render first: storebound shots render)`);
for (const [type, sub] of Object.entries(PLAY_IMAGE_DIRS)) {
const tablet = PLAY_TABLET_TYPES.includes(type);
if (tablet && !tablets) { out.missing.push(type); continue; }
const files = numberedImages(join(dir, sub)).slice(0, 8);
if (!files.length) {
if (type === 'phoneScreenshots') out.errors.push(`${code}: no phoneScreenshots in ${join(dir, sub)} (at least 2, required; render first: storebound shots render)`);
else if (tablet) out.errors.push(`${code}: play.tablets is true but there are no ${type} in ${join(dir, sub)} (render them: storebound shots render --size tab7,tab10, or set play.tablets: false)`);
else out.missing.push(type);
continue;
}
if ((type === 'phoneScreenshots' || tablet) && files.length < 2) out.errors.push(`${code}: ${type} needs at least 2 images (${files.length} in ${join(dir, sub)})`);
out.sets.push([type, files]);
}
return out;
}
/** Info lines for the optional sets no language has locally. Pure. */
export function missingInfo(missing: ReadonlySet<string>): string[] {
const out: string[] = [];
if (PLAY_TABLET_TYPES.some(t => missing.has(t))) out.push("tablet screenshots: none (optional; the app isn't eligible for tablet featuring without them)");
if (missing.has('tvScreenshots') && missing.has('wearScreenshots')) out.push('TV / Wear screenshots: none (optional; only for Android TV / Wear OS apps)');
else for (const t of ['tvScreenshots', 'wearScreenshots']) if (missing.has(t)) out.push(`${t}: none (optional)`);
return out;
}
/** One language's plan as text: "would replace a (1 -> 2), b (0 -> 2); would delete c (...); keeps d (...)". Pure. */
export function describePlan(plan: ImageChange[]): string {
const of = (k: ImageChange['change']) => plan.filter(c => c.change === k);
const parts = [
of('replace').length ? 'would replace ' + of('replace').map(c => `${c.type} (${c.remote} -> ${c.local})`).join(', ') : '',
of('delete').length ? 'would delete ' + of('delete').map(c => `${c.type} (${c.remote} on Play, none locally; --prune)`).join(', ') : '',
].filter(Boolean);
if (!parts.length) parts.push(of('keep').length ? 'nothing to upload' : 'unchanged');
for (const c of of('keep')) parts.push(`keeps ${c.type} on Play (${c.remote}, none locally; --prune deletes them)`);
return parts.join('; ');
}
const sha256 = (f: string): string => createHash('sha256').update(readFileSync(f)).digest('hex'); const sha256 = (f: string): string => createHash('sha256').update(readFileSync(f)).digest('hex');
export async function run(ctx: Ctx): Promise<void> { export async function run(ctx: Ctx): Promise<void> {
const { config, root, resolve } = ctx.loaded; const { config, root, resolve } = ctx.loaded;
const pkg = need(config.play?.packageName, 'play.packageName'); const pkg = need(config.play?.packageName, 'play.packageName');
const prune = ctx.args.has('prune');
const src = variantRoot(resolve(ctx.args.get('src') ?? config.renders ?? config.shots?.out ?? 'store/renders'), ctx.args.get('variant'), config.shots?.variants); const src = variantRoot(resolve(ctx.args.get('src') ?? config.renders ?? config.shots?.out ?? 'store/renders'), ctx.args.get('variant'), config.shots?.variants);
if (ctx.args.get('variant')) log('play', `variant ${ctx.args.get('variant')}: ${src}`); if (ctx.args.get('variant')) log('play', `variant ${ctx.args.get('variant')}: ${src}`);
const icon = resolve(config.play?.icon ?? join(config.icons?.out ?? 'store/icons', 'play-512.png')); const icon = resolve(config.play?.icon ?? join(config.icons?.out ?? 'store/icons', 'play-512.png'));
const langOf = byPlayCode(config.languages); const langOf = byPlayCode(config.languages);
const codes = readPlayCopy(ctx).map(c => c.lang); const codes = readPlayCopy(ctx).map(c => c.lang);
const tablets = resolvedTablets(config, src);
const sets = new Map<string, [string, string[]][]>(); const sets = new Map<string, LangImages>();
const errors: string[] = [];
for (const code of codes) { for (const code of codes) {
const l = langOf.get(code); const l = langOf.get(code);
if (!l) throw new Error(`${code}: no entry in languages[] has this Play code`); if (!l) throw new Error(`${code}: no entry in languages[] has this Play code`);
const dir = join(src, l.id); const li = langImages(code, join(src, l.id), icon, tablets);
const feature = ['jpg', 'jpeg', 'png'].map(e => join(dir, `feature/1024x500.${e}`)).find(existsSync); errors.push(...li.errors);
const set: [string, string[]][] = [['icon', [icon]], ['featureGraphic', feature ? [feature] : []], sets.set(code, li);
...Object.entries(PLAY_IMAGE_DIRS).map(([type, sub]) => [type, numberedImages(join(dir, sub)).slice(0, 8)] as [string, string[]])];
for (const [type, files] of set) {
if (!files.length || !files.every(existsSync)) throw new Error(`${code}: no ${type} in ${dir} (render first: storebound shots render)`);
if (type.endsWith('Screenshots') && files.length < 2) throw new Error(`${code}: ${type} needs at least 2 images`);
}
sets.set(code, set);
} }
log('play', `${codes.length} languages, ${[...sets.values()][0]?.reduce((n, [, f]) => n + f.length, 0) ?? 0} images each`); if (errors.length) throw new Error(errors.join('\n'));
const first = [...sets.values()][0];
log('play', `${codes.length} languages: ${first?.sets.map(([t, f]) => `${t} (${f.length})`).join(', ') ?? 'nothing'}`);
for (const line of missingInfo(new Set(OPTIONAL_TYPES.filter(t => [...sets.values()].every(s => s.missing.includes(t)))))) log('play', line);
if (config.play?.tablets === false) log('play', 'play.tablets is false: tablet screenshots are not uploaded');
const play = await playClient(root, pkg); const play = await playClient(root, pkg);
// one read-only edit: what Play has now (sha256 per image, in order) against the local files, per language and type const remoteOf = async (id: string, code: string, type: string) =>
(await play.call<{ images?: { sha256?: string }[] }>('GET', `${play.API}/edits/${id}/listings/${code}/${type}`)).images ?? [];
// one read-only edit: what Play has now (sha256 per image, in order) against the local files, per language and type;
// for an optional type with no local files only how many Play has (kept, or deleted with --prune)
const plan = new Map<string, ImageChange[]>(); const plan = new Map<string, ImageChange[]>();
await play.read(async id => { await play.read(async id => {
for (const code of codes) { for (const code of codes) {
const out: ImageChange[] = []; const li = sets.get(code)!, out: ImageChange[] = [];
for (const [type, files] of sets.get(code)!) { for (const [type, files] of li.sets) out.push(imageChange(type, await remoteOf(id, code, type), files.map(sha256)));
const remote = (await play.call<{ images?: { sha256?: string }[] }>('GET', `${play.API}/edits/${id}/listings/${code}/${type}`)).images ?? []; for (const type of li.missing) out.push(missingChange(type, (await remoteOf(id, code, type)).length, prune));
out.push(imageChange(type, remote, files.map(sha256)));
}
plan.set(code, out); plan.set(code, out);
} }
}); });
const changedTypes = (code: string) => plan.get(code)!.filter(c => c.change === 'replace'); const writes = (code: string) => plan.get(code)!.filter(c => c.change === 'replace' || c.change === 'delete');
if (ctx.args.has('dry')) { if (ctx.args.has('dry')) {
let changed = 0; let changed = 0;
for (const [i, code] of codes.entries()) { for (const [i, code] of codes.entries()) {
const rep = changedTypes(code); changed += writes(code).length;
changed += rep.length; log('play', `${step(i + 1, codes.length, code)}: ${describePlan(plan.get(code)!)}`);
log('play', `${step(i + 1, codes.length, code)}: ${rep.length ? 'would replace ' + rep.map(c => `${c.type} (${c.remote} -> ${c.local})`).join(', ') : 'unchanged'}`);
} }
done('play', `dry run: ${changed ? changed + ' image set(s) would be replaced' : 'every image set is already on Play'}; read-only, nothing uploaded`); done('play', `dry run: ${changed ? changed + ' image set(s) would change' : 'every image set is already on Play'}; read-only, nothing uploaded`);
return; return;
} }
// only languages with a changed image type get an edit, and in it only the changed types are cleared and uploaded. for (const code of codes) for (const c of plan.get(code)!.filter(x => x.change === 'keep'))
// The edit is all or nothing: a failure (or an upload without a clear answer) deletes it, and the redo starts from log('play', `${code}: keeps ${c.type} on Play (${c.remote}, none locally; --prune deletes them)`);
// what Play has, so a re-run never doubles an image. // only languages with a changed image type get an edit, and in it only the changed types are cleared and uploaded
const todo = codes.filter(code => changedTypes(code).length); // (with --prune also the optional types that have no local files are cleared). The edit is all or nothing: a failure
// (or an upload without a clear answer) deletes it, and the redo starts from what Play has, so a re-run never doubles
// an image.
const todo = codes.filter(code => writes(code).length);
if (!todo.length) { done('play', 'every image set is already on Play: nothing uploaded'); return; } if (!todo.length) { done('play', 'every image set is already on Play: nothing uploaded'); return; }
for (const [i, code] of todo.entries()) { for (const [i, code] of todo.entries()) {
const set = sets.get(code)!; const li = sets.get(code)!;
await play.edit(async (id, ctl) => { await play.edit(async (id, ctl) => {
let n = 0, types = 0; let n = 0, types = 0, deleted = 0;
await Promise.all(set.map(async ([type, files]) => { await Promise.all([
const remote = (await play.call<{ images?: { sha256?: string }[] }>('GET', `${play.API}/edits/${id}/listings/${code}/${type}`)).images ?? []; ...li.sets.map(async ([type, files]) => {
if (imageChange(type, remote, files.map(sha256)).change === 'same') return; const remote = await remoteOf(id, code, type);
types++; if (imageChange(type, remote, files.map(sha256)).change === 'same') return;
if (remote.length) await play.call('DELETE', `${play.API}/edits/${id}/listings/${code}/${type}`); types++;
for (const f of files) { if (remote.length) await play.call('DELETE', `${play.API}/edits/${id}/listings/${code}/${type}`);
await play.call('POST', `${play.UPLOAD}/edits/${id}/listings/${code}/${type}?uploadType=media`, new Uint8Array(readFileSync(f)), for (const f of files) {
{ 'Content-Type': /\.png$/i.test(f) ? 'image/png' : 'image/jpeg' }); await play.call('POST', `${play.UPLOAD}/edits/${id}/listings/${code}/${type}?uploadType=media`, new Uint8Array(readFileSync(f)),
n++; { 'Content-Type': /\.png$/i.test(f) ? 'image/png' : 'image/jpeg' });
} n++;
})); }
}),
...(prune ? li.missing.map(async type => {
if (!(await remoteOf(id, code, type)).length) return;
types++; deleted++;
await play.call('DELETE', `${play.API}/edits/${id}/listings/${code}/${type}`);
}) : []),
]);
if (!types) ctl.unchanged(); if (!types) ctl.unchanged();
log('play', `${step(i + 1, todo.length, code)}: ${n} images in ${types} set(s)`); log('play', `${step(i + 1, todo.length, code)}: ${n} images in ${types - deleted} set(s)${deleted ? `, ${deleted} set(s) deleted (--prune)` : ''}`);
}); });
} }
done('play', 'images done'); done('play', 'images done');

View file

@ -3,7 +3,8 @@
// Per image: pixel size for its store type (Apple: a listed size, portrait or landscape; Play screenshots: 320-3840 px, // Per image: pixel size for its store type (Apple: a listed size, portrait or landscape; Play screenshots: 320-3840 px,
// at most 2:1; feature graphic exactly 1024x500), no alpha (both stores), Play: JPEG or 24-bit PNG, 8 MB per screenshot. // at most 2:1; feature graphic exactly 1024x500), no alpha (both stores), Play: JPEG or 24-bit PNG, 8 MB per screenshot.
// --variants also checks the A/B variant folders (<renders>/<variant>/<lang>/...). // --variants also checks the A/B variant folders (<renders>/<variant>/<lang>/...).
// Per set: Play 2-8 phone screenshots, Apple 1-10 per display type, 13" iPad set when apple.ipad, feature graphic. // Per set: Play 2-8 phone screenshots, Apple 1-10 per display type, 13" iPad set when apple.ipad, feature graphic,
// Play 7" / 10" tablet sets only when play.tablets (default: tablet renders exist; optional on Play).
// Per app preview: 886x1920 / 1200x1600, H.264, <= 30 fps, 15-30 s, stereo AAC track, <= 500 MB, <= 3 per set. // Per app preview: 886x1920 / 1200x1600, H.264, <= 30 fps, 15-30 s, stereo AAC track, <= 500 MB, <= 3 per set.
// Text overflow flags come from the last `shots render` (report.json). Frames: with capture.source "folder" every frame // Text overflow flags come from the last `shots render` (report.json). Frames: with capture.source "folder" every frame
// the scenes use must exist with the right aspect. Local font files (shots.fonts paths) without a license note: warn. // the scenes use must exist with the right aspect. Local font files (shots.fonts paths) without a license note: warn.
@ -16,43 +17,45 @@ import { log, printJson } from '../lib/log';
import { checkFrames, type FrameIssue } from './devices'; import { checkFrames, type FrameIssue } from './devices';
import { defaultSizes, frameDims, SIZES } from './lib'; import { defaultSizes, frameDims, SIZES } from './lib';
import type { LocalIssue, RendersCheck } from '../lib/validate-rules'; import type { LocalIssue, RendersCheck } from '../lib/validate-rules';
import { allIssues, requiredSets, type RendersScan, scanRenders, storeOf } from './report'; import { allIssues, requiredSets, type RendersScan, resolvedTablets, scanRenders, storeOf } from './report';
import { imageInfo } from './spec'; import { imageInfo } from './spec';
import { fontsDirOf, readManifest } from './fonts'; import { fontsDirOf, readManifest } from './fonts';
import { localFontIssues } from './local-fonts'; import { localFontIssues } from './local-fonts';
/** Frame files the scenes (and the feature graphic) use, per language and device class. */ /** Frame files the scenes (and the feature graphic) use, per language and device class. */
export function framesNeeded(config: ShipConfig, langs: string[]): { lang: string; device: 'phone' | 'tablet'; name: string }[] { export function framesNeeded(config: ShipConfig, langs: string[], tablets?: boolean): { lang: string; device: 'phone' | 'tablet'; name: string }[] {
const shots = config.shots!; const shots = config.shots!;
const sizes = defaultSizes(config); const sizes = defaultSizes(config, tablets);
const devices: ('phone' | 'tablet')[] = ['phone', ...(sizes.some(s => SIZES[s]!.device === 'tablet') ? ['tablet' as const] : [])]; const devices: ('phone' | 'tablet')[] = ['phone', ...(sizes.some(s => SIZES[s]!.device === 'tablet') ? ['tablet' as const] : [])];
const scene = [...new Set(shots.scenes.flatMap(s => s.frames.map(f => f.src)))]; const scene = [...new Set(shots.scenes.flatMap(s => s.frames.map(f => f.src)))];
const phone = [...new Set([...scene, ...(shots.feature?.frame && sizes.includes('feature') ? [shots.feature.frame] : [])])]; const phone = [...new Set([...scene, ...(shots.feature?.frame && sizes.includes('feature') ? [shots.feature.frame] : [])])];
return langs.flatMap(lang => devices.flatMap(device => (device === 'phone' ? phone : scene).map(name => ({ lang, device, name })))); return langs.flatMap(lang => devices.flatMap(device => (device === 'phone' ? phone : scene).map(name => ({ lang, device, name }))));
} }
export function checkFrameFolder(config: ShipConfig, dir: string, langs: string[]): FrameIssue[] { export function checkFrameFolder(config: ShipConfig, dir: string, langs: string[], tablets?: boolean): FrameIssue[] {
const found = new Map<string, { w: number; h: number }>(); const found = new Map<string, { w: number; h: number }>();
for (const lang of langs) for (const device of ['phone', 'tablet']) { for (const lang of langs) for (const device of ['phone', 'tablet']) {
const d = join(dir, lang, device); const d = join(dir, lang, device);
if (!existsSync(d)) continue; if (!existsSync(d)) continue;
for (const f of readdirSync(d).filter(x => /\.png$/i.test(x))) { const i = imageInfo(readFileSync(join(d, f))); found.set(`${lang}/${device}/${f}`, i); } for (const f of readdirSync(d).filter(x => /\.png$/i.test(x))) { const i = imageInfo(readFileSync(join(d, f))); found.set(`${lang}/${device}/${f}`, i); }
} }
return checkFrames(found, framesNeeded(config, langs), frameDims(config)); return checkFrames(found, framesNeeded(config, langs, tablets), frameDims(config));
} }
export function scanProject(ctx: Ctx): RendersScan { export function scanProject(ctx: Ctx): RendersScan {
const { config, resolve } = ctx.loaded; const { config, resolve } = ctx.loaded;
const langs = ctx.args.list('lang').length ? ctx.args.list('lang') : config.languages.map(l => l.id); const langs = ctx.args.list('lang').length ? ctx.args.list('lang') : config.languages.map(l => l.id);
const dir = resolve(ctx.args.get('src') ?? config.renders ?? config.shots?.out ?? 'store/renders'); const dir = resolve(ctx.args.get('src') ?? config.renders ?? config.shots?.out ?? 'store/renders');
return scanRenders(dir, { langs, variants: ctx.args.has('variants') ? Object.keys(config.shots?.variants ?? {}) : [], required: lang => requiredSets(config, lang) }); const tablets = resolvedTablets(config, dir);
return scanRenders(dir, { langs, variants: ctx.args.has('variants') ? Object.keys(config.shots?.variants ?? {}) : [], required: lang => requiredSets(config, lang, tablets) });
} }
/** Local renders check split per store, for `validate` (base renders only, no variants). null = no renders folder. */ /** Local renders check split per store, for `validate` (base renders only, no variants). null = no renders folder. */
export function rendersCheck(config: ShipConfig, dir: string): Record<'apple' | 'play', RendersCheck> | null { export function rendersCheck(config: ShipConfig, dir: string): Record<'apple' | 'play', RendersCheck> | null {
if (!existsSync(dir)) return null; if (!existsSync(dir)) return null;
const langs = config.languages.map(l => l.id); const langs = config.languages.map(l => l.id);
const scan = scanRenders(dir, { langs, required: lang => requiredSets(config, lang) }); const tablets = resolvedTablets(config, dir);
const scan = scanRenders(dir, { langs, required: lang => requiredSets(config, lang, tablets) });
const out = { apple: { dir, images: 0, issues: [] as LocalIssue[] }, play: { dir, images: 0, issues: [] as LocalIssue[] } }; const out = { apple: { dir, images: 0, issues: [] as LocalIssue[] }, play: { dir, images: 0, issues: [] as LocalIssue[] } };
for (const img of scan.images) out[storeOf(img.rel)].images++; for (const img of scan.images) out[storeOf(img.rel)].images++;
for (const v of scan.videos) out[storeOf(v.rel)].images++; for (const v of scan.videos) out[storeOf(v.rel)].images++;
@ -65,7 +68,8 @@ export async function run(ctx: Ctx): Promise<void> {
const scan = scanProject(ctx); const scan = scanProject(ctx);
const issues = [...allIssues(scan), ...localFontIssues(readManifest(fontsDirOf(root))).map(issue => ({ where: '.storebound/fonts/fonts.json', issue }))]; const issues = [...allIssues(scan), ...localFontIssues(readManifest(fontsDirOf(root))).map(issue => ({ where: '.storebound/fonts/fonts.json', issue }))];
const cap = config.shots?.capture; const cap = config.shots?.capture;
const frameIssues = cap?.source === 'folder' ? checkFrameFolder(config, resolve(cap.folder ?? config.shots!.frames), scan.langs) : []; const tablets = resolvedTablets(config, resolve(ctx.args.get('src') ?? config.renders ?? config.shots?.out ?? 'store/renders'));
const frameIssues = cap?.source === 'folder' ? checkFrameFolder(config, resolve(cap.folder ?? config.shots!.frames), scan.langs, tablets) : [];
if (ctx.args.has('json')) { if (ctx.args.has('json')) {
printJson({ images: scan.images.length, videos: scan.videos.length, issues, frames: frameIssues }); printJson({ images: scan.images.length, videos: scan.videos.length, issues, frames: frameIssues });
} else { } else {

View file

@ -11,7 +11,7 @@ import { join, relative } from 'node:path';
import type { Ctx } from '../lib/ctx'; import type { Ctx } from '../lib/ctx';
import { done, log, printJson, warn } from '../lib/log'; import { done, log, printJson, warn } from '../lib/log';
import { SIZES } from './lib'; import { SIZES } from './lib';
import { allIssues, requiredSets, type RendersScan, scanRenders } from './report'; import { allIssues, requiredSets, type RendersScan, resolvedTablets, scanRenders } from './report';
import { type Issue, IMAGE_SPECS, VIDEO_TARGETS } from './spec'; import { type Issue, IMAGE_SPECS, VIDEO_TARGETS } from './spec';
export interface GalleryTile { export interface GalleryTile {
@ -241,7 +241,8 @@ export async function run(ctx: Ctx): Promise<void> {
const dir = resolve(a.get('src') ?? config.renders ?? config.shots?.out ?? 'store/renders'); const dir = resolve(a.get('src') ?? config.renders ?? config.shots?.out ?? 'store/renders');
const out = a.get('out') ? resolve(a.get('out')!) : join(dir, 'index.html'); const out = a.get('out') ? resolve(a.get('out')!) : join(dir, 'index.html');
const variants = a.has('no-variants') ? [] : Object.keys(config.shots?.variants ?? {}); const variants = a.has('no-variants') ? [] : Object.keys(config.shots?.variants ?? {});
const scan = scanRenders(dir, { langs, variants, required: lang => requiredSets(config, lang) }); const tablets = resolvedTablets(config, dir);
const scan = scanRenders(dir, { langs, variants, required: lang => requiredSets(config, lang, tablets) });
if (!scan.images.length && !scan.videos.length) warn('shots', `no renders in ${dir} (storebound shots render)`); if (!scan.images.length && !scan.videos.length) warn('shots', `no renders in ${dir} (storebound shots render)`);
const data = galleryData(scan, { at: join(out, '..'), title: `${config.app.name} · store renders`, exists: existsSync }); const data = galleryData(scan, { at: join(out, '..'), title: `${config.app.name} · store renders`, exists: existsSync });
// --json: the data only (the desktop gallery), with the renders folder relative to the app folder; writes nothing // --json: the data only (the desktop gallery), with the renders folder relative to the app folder; writes nothing

View file

@ -116,12 +116,18 @@ export function frameDims(config: Pick<ShipConfig, 'apple' | 'shots'>): Record<'
return { phone: { ...DEFAULT_PHONE, ...config.shots?.phone }, tablet: { ...tablet, ...config.shots?.tablet } }; return { phone: { ...DEFAULT_PHONE, ...config.shots?.phone }, tablet: { ...tablet, ...config.shots?.tablet } };
} }
/** Play tablet size keys (7" and 10"): optional on Play, switched by play.tablets. */
export const PLAY_TABLET_SIZES: readonly string[] = ['tab7', 'tab10'];
/** Size keys this project renders by default: Apple sizes only with an apple section (iPad only with apple.ipad, the /** Size keys this project renders by default: Apple sizes only with an apple section (iPad only with apple.ipad, the
* 6.3" iPhone only with apple.iphoneMedium), * 6.3" iPhone only with apple.iphoneMedium),
* Play sizes only with a play section (both when neither is configured). */ * Play sizes only with a play section (both when neither is configured); the Play tablet sizes only when `tablets`
export function defaultSizes(config: Pick<ShipConfig, 'apple' | 'play'>): string[] { * (resolved play.tablets, see resolvedTablets in report.ts; unset here = play.tablets ?? true). */
export function defaultSizes(config: Pick<ShipConfig, 'apple' | 'play'>, tablets?: boolean): string[] {
const apple = !!config.apple || !config.play, play = !!config.play || !config.apple; const apple = !!config.apple || !config.play, play = !!config.play || !config.apple;
return Object.keys(SIZES).filter(k => (k === 'ipad13' ? apple && !!config.apple?.ipad : k === 'iphone-medium' ? apple && !!config.apple?.iphoneMedium : k.startsWith('ios') ? apple : play)); const tabs = tablets ?? config.play?.tablets ?? true;
return Object.keys(SIZES).filter(k => (k === 'ipad13' ? apple && !!config.apple?.ipad : k === 'iphone-medium' ? apple && !!config.apple?.iphoneMedium
: k.startsWith('ios') ? apple : PLAY_TABLET_SIZES.includes(k) ? play && tabs : play));
} }
/** Scenes of a variant (its order / subset, palette overrides), or the base scenes. */ /** Scenes of a variant (its order / subset, palette overrides), or the base scenes. */

View file

@ -4,7 +4,8 @@
// Out: <shots.out>/<lang>/<size>/NN.png (24-bit RGB: Play rejects alpha / palette PNGs), <lang>/feature/1024x500.png, // Out: <shots.out>/<lang>/<size>/NN.png (24-bit RGB: Play rejects alpha / palette PNGs), <lang>/feature/1024x500.png,
// <lang>/sheet.png (contact sheet of every size packed into a grid, for a quick review) and <shots.out>/report.json (text overflow flags // <lang>/sheet.png (contact sheet of every size packed into a grid, for a quick review) and <shots.out>/report.json (text overflow flags
// per image, read by `shots check` and `shots gallery`). Variants go to <shots.out>/<variant>/<lang>/... // per image, read by `shots check` and `shots gallery`). Variants go to <shots.out>/<variant>/<lang>/...
// Default sizes: defaultSizes() (ipad13 only with apple.ipad). Needs playwright + ffmpeg. // Default sizes: defaultSizes() (ipad13 only with apple.ipad; tab7 / tab10 only with play.tablets, default: tablet renders
// already exist). Needs playwright + ffmpeg.
// Fonts: the per-language stacks from .storebound/fonts/fonts.json (`shots fonts`). Before rendering, every character // Fonts: the per-language stacks from .storebound/fonts/fonts.json (`shots fonts`). Before rendering, every character
// of the copy is checked against the cmaps of the downloaded faces (stale fonts fail with the language and the // of the copy is checked against the cmaps of the downloaded faces (stale fonts fail with the language and the
// characters); while rendering, the page measures which face drew every character, and report.json records it per // characters); while rendering, the page measures which face drew every character, and report.json records it per
@ -25,7 +26,7 @@ import { defaultConcurrency, pool, progressLine } from './capture-web';
import { shotsStyle } from '../lib/config'; import { shotsStyle } from '../lib/config';
import { type CopyFile, SIZES, copyText, defaultSizes, frameDims, mergeCopy, palette, renderDir, scriptFont, sheetLayout, validateCopy, variantScenes } from './lib'; import { type CopyFile, SIZES, copyText, defaultSizes, frameDims, mergeCopy, palette, renderDir, scriptFont, sheetLayout, validateCopy, variantScenes } from './lib';
import { faceFilePath, fontsRoute, resolveShotFonts, staleLocalFonts } from './local-fonts'; import { faceFilePath, fontsRoute, resolveShotFonts, staleLocalFonts } from './local-fonts';
import { type RenderReport, mergeReport, readReport } from './report'; import { type RenderReport, mergeReport, readReport, resolvedTablets } from './report';
const TYPES: Record<string, string> = { html: 'text/html; charset=utf-8', png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', const TYPES: Record<string, string> = { html: 'text/html; charset=utf-8', png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg',
webp: 'image/webp', woff2: 'font/woff2', woff: 'font/woff', ttf: 'font/ttf', otf: 'font/otf', css: 'text/css', svg: 'image/svg+xml' }; webp: 'image/webp', woff2: 'font/woff2', woff: 'font/woff', ttf: 'font/ttf', otf: 'font/otf', css: 'text/css', svg: 'image/svg+xml' };
@ -35,7 +36,10 @@ export async function run(ctx: Ctx): Promise<void> {
const shots = need(config.shots, 'shots'); const shots = need(config.shots, 'shots');
const a = ctx.args; const a = ctx.args;
const langs = a.list('lang').length ? a.list('lang') : config.languages.map(l => l.id); const langs = a.list('lang').length ? a.list('lang') : config.languages.map(l => l.id);
const sizes = a.list('size').length ? a.list('size') : defaultSizes(config); const tablets = resolvedTablets(config, resolve(shots.out));
const sizes = a.list('size').length ? a.list('size') : defaultSizes(config, tablets);
if (config.play && !tablets && !a.list('size').length)
log('shots', `Play tablet screenshots: not rendered (${config.play.tablets === false ? 'play.tablets is false' : 'no tablet renders yet; set play.tablets: true to render tab7 / tab10'}; optional on Play)`);
for (const s of sizes) if (!SIZES[s]) throw new Error(`unknown size ${s} (${Object.keys(SIZES).join(', ')})`); for (const s of sizes) if (!SIZES[s]) throw new Error(`unknown size ${s} (${Object.keys(SIZES).join(', ')})`);
const variants: (string | undefined)[] = a.has('variants') ? [undefined, ...Object.keys(shots.variants ?? {})] : [a.get('variant')]; const variants: (string | undefined)[] = a.has('variants') ? [undefined, ...Object.keys(shots.variants ?? {})] : [a.get('variant')];
const baseCopy = JSON.parse(readFileSync(resolve(shots.copy), 'utf8')) as CopyFile; const baseCopy = JSON.parse(readFileSync(resolve(shots.copy), 'utf8')) as CopyFile;

View file

@ -3,7 +3,7 @@
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
import { spawnSync } from 'node:child_process'; import { spawnSync } from 'node:child_process';
import { SIZES } from './lib'; import { PLAY_TABLET_SIZES, SIZES } from './lib';
import { fontIssues, type FontUsage } from './glyphs'; import { fontIssues, type FontUsage } from './glyphs';
import { checkCount, checkImage, checkVideo, type ImageInfo, imageInfo, type Issue, IMAGE_SPECS, parseProbe, PREVIEW_RULES, type ProbeInfo, VIDEO_TARGETS } from './spec'; import { checkCount, checkImage, checkVideo, type ImageInfo, imageInfo, type Issue, IMAGE_SPECS, parseProbe, PREVIEW_RULES, type ProbeInfo, VIDEO_TARGETS } from './spec';
@ -86,13 +86,29 @@ export function ffprobe(file: string): ProbeInfo | null {
} }
/** Which sets the stores require for this config (missing ones are errors, others only checked when present). With a /** Which sets the stores require for this config (missing ones are errors, others only checked when present). With a
* language id: Apple sets only when that language has an App Store locale, Play sets only when it has a Play code. */ * language id: Apple sets only when that language has an App Store locale, Play sets only when it has a Play code.
* Play tablets (tab7, tab10) only when `tablets` (resolved play.tablets; unset here = play.tablets === true). */
export function requiredSets(c: { apple?: { ipad?: boolean; iphoneMedium?: boolean } | undefined; play?: unknown; languages?: { id: string; apple?: string; play?: string | string[] }[] }, export function requiredSets(c: { apple?: { ipad?: boolean; iphoneMedium?: boolean } | undefined; play?: unknown; languages?: { id: string; apple?: string; play?: string | string[] }[] },
lang?: string): Record<string, boolean> { lang?: string, tablets?: boolean): Record<string, boolean> {
const l = lang === undefined ? undefined : c.languages?.find(x => x.id === lang); const l = lang === undefined ? undefined : c.languages?.find(x => x.id === lang);
const apple = !!c.apple && (!l || !!l.apple); const apple = !!c.apple && (!l || !!l.apple);
const play = !!c.play && (!l || (Array.isArray(l.play) ? l.play.length > 0 : !!l.play)); const play = !!c.play && (!l || (Array.isArray(l.play) ? l.play.length > 0 : !!l.play));
return { ios69: apple, ...(c.apple?.iphoneMedium ? { 'iphone-medium': apple } : {}), ipad13: apple && !!c.apple?.ipad, play, feature: play }; const tabs = play && (tablets ?? (c.play as { tablets?: boolean } | undefined)?.tablets === true);
return { ios69: apple, ...(c.apple?.iphoneMedium ? { 'iphone-medium': apple } : {}), ipad13: apple && !!c.apple?.ipad, play, feature: play,
...(tabs ? { tab7: true, tab10: true } : {}) };
}
/** Whether a language (or variant/language) folder under the renders dir has Play tablet renders (tab7/ or tab10/ with
* NN.png|jpg), for the play.tablets default. Reads the folder tree two levels deep. */
export function tabletRendersExist(dir: string): boolean {
const sub = (d: string): string[] => { try { return readdirSync(d, { withFileTypes: true }).filter(e => e.isDirectory()).map(e => join(d, e.name)); } catch { return []; } };
const has = (d: string) => PLAY_TABLET_SIZES.some(s => existsSync(join(d, s)) && readdirSync(join(d, s)).some(f => /^\d+\.(png|jpe?g)$/i.test(f)));
return sub(dir).some(l => has(l) || sub(l).some(has));
}
/** play.tablets for a project, with the default (tablet renders exist under `rendersDir`) filled in. */
export function resolvedTablets(config: { play?: { tablets?: boolean } }, rendersDir: string): boolean {
return config.play?.tablets ?? tabletRendersExist(rendersDir);
} }
export const allIssues = (s: RendersScan): { where: string; issue: Issue }[] => [ export const allIssues = (s: RendersScan): { where: string; issue: Issue }[] => [

View file

@ -23,8 +23,9 @@ import { checkUrl } from './lib/http';
import { collectHealth } from './health'; import { collectHealth } from './health';
import { healthFindings } from './lib/health'; import { healthFindings } from './lib/health';
import { log, printJson, warn } from './lib/log'; import { log, printJson, warn } from './lib/log';
import { type AppleSnapshot, appleRules, type Finding, formatFindings, PLAY_REQUIRED_IMAGES, type PlaySnapshot, playRules, type ProfileFacts, rendersFindings, sortFindings } from './lib/validate-rules'; import { type AppleSnapshot, appleRules, type Finding, formatFindings, type PlaySnapshot, playRequiredImages, playRules, type ProfileFacts, rendersFindings, sortFindings } from './lib/validate-rules';
import { rendersCheck } from './shots/check'; import { rendersCheck } from './shots/check';
import { resolvedTablets } from './shots/report';
import { runScan } from './lib/scan'; import { runScan } from './lib/scan';
import type { ScanFinding } from './lib/scan/rules'; import type { ScanFinding } from './lib/scan/rules';
import { playClient } from './play/client'; import { playClient } from './play/client';
@ -115,6 +116,7 @@ export async function collectApple(ctx: Ctx): Promise<AppleSnapshot> {
export async function collectPlay(ctx: Ctx): Promise<PlaySnapshot> { export async function collectPlay(ctx: Ctx): Promise<PlaySnapshot> {
const { config, root } = ctx.loaded; const { config, root } = ctx.loaded;
const play = await playClient(root, config.play!.packageName); const play = await playClient(root, config.play!.packageName);
const tablets = resolvedTablets(config, ctx.loaded.resolve(ctx.args.get('src') ?? config.renders ?? config.shots?.out ?? 'store/renders'));
const e = await play.call<{ id: string }>('POST', `${play.API}/edits`, {}); const e = await play.call<{ id: string }>('POST', `${play.API}/edits`, {});
log('validate', `play: read-only edit ${e.id} (deleted afterwards)`); log('validate', `play: read-only edit ${e.id} (deleted afterwards)`);
try { try {
@ -123,7 +125,7 @@ export async function collectPlay(ctx: Ctx): Promise<PlaySnapshot> {
const listings: PlaySnapshot['listings'] = []; const listings: PlaySnapshot['listings'] = [];
for (const l of raw) { for (const l of raw) {
const images: Record<string, number> = {}; const images: Record<string, number> = {};
for (const type of Object.keys(PLAY_REQUIRED_IMAGES)) for (const type of Object.keys(playRequiredImages(tablets)))
images[type] = ((await play.call<{ images?: unknown[] }>('GET', `${play.API}/edits/${e.id}/listings/${l.language}/${type}`)).images ?? []).length; images[type] = ((await play.call<{ images?: unknown[] }>('GET', `${play.API}/edits/${e.id}/listings/${l.language}/${type}`)).images ?? []).length;
listings.push({ ...l, images }); listings.push({ ...l, images });
} }
@ -148,7 +150,7 @@ export async function collectPlay(ctx: Ctx): Promise<PlaySnapshot> {
} while (page); } while (page);
} }
return { return {
subscriptions, expectedSubscriptions: wantSubs.map(({ sub }) => ({ id: sub.id, basePlanId: basePlanId(sub) })), tablets, subscriptions, expectedSubscriptions: wantSubs.map(({ sub }) => ({ id: sub.id, basePlanId: basePlanId(sub) })),
profile: await collectProfile(ctx.loaded), profile: await collectProfile(ctx.loaded),
defaultLanguage: details.defaultLanguage ?? config.play?.defaultLanguage ?? 'en-US', defaultLanguage: details.defaultLanguage ?? config.play?.defaultLanguage ?? 'en-US',
expectedLanguages: config.languages.flatMap(playCodes), listings, tracks, products, expectedLanguages: config.languages.flatMap(playCodes), listings, tracks, products,

View file

@ -9,6 +9,7 @@ fails without the fix.
| # | date | lesson | guard | | # | date | lesson | guard |
|---|---|---|---| |---|---|---|---|
| 19 | 2026-10 | `play images --dry` aborted with "en-US: no sevenInchScreenshots ... (render first)" on a phone-only project: Play's 7" and 10" tablet screenshots are optional (required: 512 icon, 1024x500 feature graphic, at least 2 screenshots; Play Console Help answer 9866151), but every image type was treated as required. | `play images` uploads what exists (icon, feature graphic, phone screenshots, tablet / TV / Wear only when rendered), prints an info line for missing optional sets, never deletes what Play has for them without `--prune`; `play.tablets` (default: tablet renders exist) switches tablets for `shots render` / `shots check` / `shots gallery` / `validate` / `play images`, and `true` with missing renders is an error (`cli/play/images.ts` `langImages`, `resolvedTablets` in `cli/shots/report.ts`, `tests/play-post-reads.test.ts`). |
| 18 | 2026-10 | `console fill play-app-record` with a copy of the owner's Chrome cookies reached the Play account chooser once, then Google answered `/v3/signin/rejected` and the chooser: Google session cookies are device-bound (Chrome's Device Bound Session Credentials) and Google signs out sessions whose cookies look stolen, which can also sign the owner out of their real Chrome. | Never copy Google cookies by default: storebound's own profile is the default (the owner signs in once by hand), started by storebound as a normal Chrome (no `--enable-automation`, `navigator.webdriver` false) and attached over CDP; `--use-chrome-session google\|apple` is an explicit opt-in that warns; `/signin/rejected` is said plainly (`cli/console/session.ts`, `tests/console-session.test.ts`). | | 18 | 2026-10 | `console fill play-app-record` with a copy of the owner's Chrome cookies reached the Play account chooser once, then Google answered `/v3/signin/rejected` and the chooser: Google session cookies are device-bound (Chrome's Device Bound Session Credentials) and Google signs out sessions whose cookies look stolen, which can also sign the owner out of their real Chrome. | Never copy Google cookies by default: storebound's own profile is the default (the owner signs in once by hand), started by storebound as a normal Chrome (no `--enable-automation`, `navigator.webdriver` false) and attached over CDP; `--use-chrome-session google\|apple` is an explicit opt-in that warns; `/signin/rejected` is said plainly (`cli/console/session.ts`, `tests/console-session.test.ts`). |
| 17 | 2026-10 | After the data types were saved, the real App Privacy page shows each type's "Set Up <type>" as a plain `<p>` in a div with a click handler (no button role, no link) and puts the types in a box after the Data Types heading's box: role-based locators and the "nearest heading" check found nothing, so no type was set up. A "?" (More information) button sits next to each type. | `setUpAllowed` / `setUpTexts` (`cli/lib/console-forms.ts`: exact text "Set Up <type>" of a type storebound answers, in the Data Types section, outside dialogs) and `setUpClick` / `setUpType` / `flowWhere` (last heading before the control) in `cli/console/fill.ts`; the per-type dialog steps are `page` sections, so an unknown step dumps and pauses instead of ending the run; `tests/fixtures/console/apple-app-privacy-setup.html` (structure of the real page) and `tests/console-flow.test.ts`. | | 17 | 2026-10 | After the data types were saved, the real App Privacy page shows each type's "Set Up <type>" as a plain `<p>` in a div with a click handler (no button role, no link) and puts the types in a box after the Data Types heading's box: role-based locators and the "nearest heading" check found nothing, so no type was set up. A "?" (More information) button sits next to each type. | `setUpAllowed` / `setUpTexts` (`cli/lib/console-forms.ts`: exact text "Set Up <type>" of a type storebound answers, in the Data Types section, outside dialogs) and `setUpClick` / `setUpType` / `flowWhere` (last heading before the control) in `cli/console/fill.ts`; the per-type dialog steps are `page` sections, so an unknown step dumps and pauses instead of ending the run; `tests/fixtures/console/apple-app-privacy-setup.html` (structure of the real page) and `tests/console-flow.test.ts`. |
| 16 | 2026-10 | `console fill play-app-record` with the copied Chrome cookies printed only "still waiting for the sign-in" for 5+ minutes on a signed-in Play Console: Google pages embed `accounts.google.com` frames (the account widget, cookie rotation), and the detector counted any frame on a sign-in host. | `signInReason`: a sign-in page only by its top-level URL, a visible password / email-entry form in the main frame, or a visible Apple sign-in frame (idmsa / appleid); other frames never count. Each detection logs "sign-in detected because: <rule> at <url>"; heartbeats name the page; a copied session that lands on a sign-in URL is said plainly ("did not carry over"); the watchdog dumps the sign-in wait once after `--step-timeout`, `--debug-dump` up to 3 times (`tests/console-fill.test.ts`, chooser / home with the widget frames, a top-level accounts.google.com page). | | 16 | 2026-10 | `console fill play-app-record` with the copied Chrome cookies printed only "still waiting for the sign-in" for 5+ minutes on a signed-in Play Console: Google pages embed `accounts.google.com` frames (the account widget, cookie rotation), and the detector counted any frame on a sign-in host. | `signInReason`: a sign-in page only by its top-level URL, a visible password / email-entry form in the main frame, or a visible Apple sign-in frame (idmsa / appleid); other frames never count. Each detection logs "sign-in detected because: <rule> at <url>"; heartbeats name the page; a copied session that lands on a sign-in URL is said plainly ("did not carry over"); the watchdog dumps the sign-in wait once after `--step-timeout`, `--debug-dump` up to 3 times (`tests/console-fill.test.ts`, chooser / home with the widget frames, a top-level accounts.google.com page). |

View file

@ -43,6 +43,10 @@ deleting products. Sending a first release for review is the owner's action in t
`--include-drafts` only on the owner's word); the real run asks in a terminal and PUTs only what changes. `--include-drafts` only on the owner's word); the real run asks in a terminal and PUTs only what changes.
7. **API · Graphics.** Render (`store-screenshots` skill), `storebound shots jpeg`, then 7. **API · Graphics.** Render (`store-screenshots` skill), `storebound shots jpeg`, then
`storebound play images > .storebound/play-images.log 2>&1 &` and follow the log (it is slow: one edit per language). `storebound play images > .storebound/play-images.log 2>&1 &` and follow the log (it is slow: one edit per language).
Required: the 512 x 512 icon, the 1024 x 500 feature graphic and at least 2 phone screenshots (Play Console Help
answer 9866151). 7" / 10" tablet screenshots are optional (no tablet featuring without them): a phone-only project
gets an info line, not an error; `play.tablets: true` renders and requires them, `false` never asks. Tablet
screenshots already on Play stay unless the owner runs `--prune`.
8. **API · In-app products.** `store-iap-setup` skill (`storebound play products`). Needs a build with the BILLING 8. **API · In-app products.** `store-iap-setup` skill (`storebound play products`). Needs a build with the BILLING
permission on any track first. permission on any track first.
9. **API · Data safety.** Answers in `dataSafety` of the config, `storebound play data-safety`, then check the summary 9. **API · Data safety.** Answers in `dataSafety` of the config, `storebound play data-safety`, then check the summary

View file

@ -190,6 +190,22 @@ Each entry: **symptom** → cause → what to do → what guards it now. The ful
https://developer.chrome.com/docs/web-platform/device-bound-session-credentials , https://developer.chrome.com/docs/web-platform/device-bound-session-credentials ,
https://knowledge.workspace.google.com/admin/security/investigate-and-take-action-on-suspicious-session-cookies https://knowledge.workspace.google.com/admin/security/investigate-and-take-action-on-suspicious-session-cookies
## 19. `play images` aborts on missing tablet screenshots
- **Symptom:** `storebound play images --dry` on a phone-only project (renders with feature, ios65, ios69 and play, no
tab7 / tab10) stopped with `en-US: no sevenInchScreenshots in .../store/renders/en (render first: storebound shots
render)`.
- **Cause:** every Play image type was treated as required. Play requires only the 512 x 512 icon, the 1024 x 500
feature graphic and at least 2 screenshots (Play Console Help, "Add preview assets to showcase your app", answer
9866151); 7" / 10" tablet, TV and Wear screenshots are optional (without tablet screenshots the app is not eligible
for tablet featuring).
- **Do:** nothing special: `play images` uploads what exists and prints `tablet screenshots: none (optional; ...)`. To
render and require tablets set `play.tablets: true`; to never ask for them, `play.tablets: false` (default: true
when tablet renders exist). A missing optional set never deletes the tablet screenshots Play already has; the dry run
shows `keeps sevenInchScreenshots on Play`, and only `--prune` (owner's call) deletes them.
- **Guard:** `langImages` / `missingChange` in `cli/play/images.ts`, `resolvedTablets` in `cli/shots/report.ts`;
`tests/play-post-reads.test.ts` (phone-only fixture: dry plan, info line, exit 0; `tablets: true` without renders is
an error; no tablet DELETE without `--prune`).
## Adding a lesson ## Adding a lesson
Every bug found in real use gets an entry in `docs/pitfalls.md` and here, and, where possible, a check or a test Every bug found in real use gets an entry in `docs/pitfalls.md` and here, and, where possible, a check or a test
that fails without the fix (see CONTRIBUTING.md). that fails without the fix (see CONTRIBUTING.md).

View file

@ -16,8 +16,8 @@ iOS Simulator capture needs macOS + Xcode (`xcrun`); Android capture needs `adb`
| ios65 | 1284 x 2778 | App Store 6.5" (`APP_IPHONE_65`) | | ios65 | 1284 x 2778 | App Store 6.5" (`APP_IPHONE_65`) |
| ipad13 | 2064 x 2752 | App Store 13" iPad (`APP_IPAD_PRO_3GEN_129`), required when the app runs on iPad; only with `apple.ipad: true` | | ipad13 | 2064 x 2752 | App Store 13" iPad (`APP_IPAD_PRO_3GEN_129`), required when the app runs on iPad; only with `apple.ipad: true` |
| play | 1080 x 1920 | Play phone (2-8, ratio at most 2:1) | | play | 1080 x 1920 | Play phone (2-8, ratio at most 2:1) |
| tab7 | 1080 x 1920 | Play 7" tablet (tablet frames, Android tablet body) | | tab7 | 1080 x 1920 | Play 7" tablet (tablet frames, Android tablet body); optional, see `play.tablets` |
| tab10 | 1440 x 2560 | Play 10" tablet | | tab10 | 1440 x 2560 | Play 10" tablet; optional, see `play.tablets` |
| feature | 1024 x 500 | Play feature graphic (required) | | feature | 1024 x 500 | Play feature graphic (required) |
Sources (re-check before a release; Apple renames display classes often): Sources (re-check before a release; Apple renames display classes often):
@ -42,6 +42,13 @@ to the 13" iPad screen (1032 x 1352 CSS px @2x below a 24 px bar), so an `iPad P
simulator screenshot fits after the bar is cropped. Play tablet sizes (tab7 / tab10) reuse the same tablet frames, drawn simulator screenshot fits after the bar is cropped. Play tablet sizes (tab7 / tab10) reuse the same tablet frames, drawn
in a plain Android tablet body instead of the iPad one. in a plain Android tablet body instead of the iPad one.
**Play tablets are optional.** Play requires the 512 x 512 icon, the 1024 x 500 feature graphic and at least 2
screenshots (answer 9866151 above); 7" / 10" tablet screenshots are optional, but without them the app is not eligible
for tablet featuring. `play.tablets` decides: unset = true when tablet renders (tab7/ or tab10/) already exist, else
false; `false` = `shots render`, `shots check`, `shots gallery`, `validate` and `play images` never ask for them;
`true` = they are rendered, and missing ones are errors. A phone-only project needs no setting: `play images` uploads
the icon, feature graphic and phone screenshots and prints `tablet screenshots: none (optional; ...)`.
## Steps ## Steps
1. **Screenshot switches.** Give the app or game params for screenshots: language, demo data or a fixed seed, no ads / 1. **Screenshot switches.** Give the app or game params for screenshots: language, demo data or a fixed seed, no ads /
analytics / purchase UI, skip onboarding or the tutorial, jump to a screen or level, freeze at a moment. Frames must analytics / purchase UI, skip onboarding or the tutorial, jump to a screen or level, freeze at a moment. Frames must
@ -119,6 +126,8 @@ in a plain Android tablet body instead of the iPad one.
only issues, search) and light / dark. Tiles with store check errors are outlined red, warnings amber, with the only issues, search) and light / dark. Tiles with store check errors are outlined red, warnings amber, with the
message under the tile. Show the owner this page (or a screenshot of it) before uploading. message under the tile. Show the owner this page (or a screenshot of it) before uploading.
10. **Upload.** `storebound play images` and `storebound apple screenshots` (both idempotent, background + log). 10. **Upload.** `storebound play images` and `storebound apple screenshots` (both idempotent, background + log).
`play images` uploads only the sets that exist (tablet / TV / Wear when rendered) and never deletes a set Play has
but the renders lack: the dry run shows `keeps <type> on Play`; `--prune` (only on the owner's word) deletes them.
## A/B variants and store experiments ## A/B variants and store experiments
`shots.variants.<id>`: `scenes` (order / subset), `copy` (a JSON merged over the base copy, only what changes), `shots.variants.<id>`: `scenes` (order / subset), `copy` (a JSON merged over the base copy, only what changes),

View file

@ -10,14 +10,17 @@
// existing product, even when a run would change a regional price or replace the listings. It names the changes. // existing product, even when a run would change a regional price or replace the listings. It names the changes.
import { afterEach, describe, expect, test } from 'bun:test'; import { afterEach, describe, expect, test } from 'bun:test';
import { createHash, generateKeyPairSync } from 'node:crypto'; import { createHash, generateKeyPairSync } from 'node:crypto';
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { join } from 'node:path'; import { join } from 'node:path';
import { collectPlayHealth } from '../cli/health'; import { collectPlayHealth } from '../cli/health';
import { parseArgs } from '../cli/lib/args'; import { parseArgs } from '../cli/lib/args';
import type { Loaded, ShipConfig } from '../cli/lib/config'; import type { Loaded, ShipConfig } from '../cli/lib/config';
import { healthFindings, healthTable, issuesQuery, metricQuery } from '../cli/lib/health'; import { healthFindings, healthTable, issuesQuery, metricQuery } from '../cli/lib/health';
import { imageChange, run as imagesRun } from '../cli/play/images'; import { imageChange, missingChange, missingInfo, run as imagesRun } from '../cli/play/images';
import { defaultSizes } from '../cli/shots/lib';
import { requiredSets, resolvedTablets } from '../cli/shots/report';
import { playRequiredImages } from '../cli/lib/validate-rules';
import { detailsChanges, listingChanges, run as listingRun } from '../cli/play/listing'; import { detailsChanges, listingChanges, run as listingRun } from '../cli/play/listing';
import { type Existing, productChanges, run as productsRun } from '../cli/play/products'; import { type Existing, productChanges, run as productsRun } from '../cli/play/products';
import { toMoney } from '../cli/lib/money'; import { toMoney } from '../cli/lib/money';
@ -171,7 +174,7 @@ describe('play images --dry: hashes compared, nothing uploaded', () => {
const calls = fake(c => { const calls = fake(c => {
if (c.method === 'POST' && c.url === `${API}/edits`) return json({ id: 'edit-2' }); if (c.method === 'POST' && c.url === `${API}/edits`) return json({ id: 'edit-2' });
const m = c.url.match(/\/edits\/edit-2\/listings\/en-US\/(\w+)$/); const m = c.url.match(/\/edits\/edit-2\/listings\/en-US\/(\w+)$/);
if (c.method === 'GET' && m) return json(remote[m[1]!]!.length ? { images: remote[m[1]!]!.map(s => image(sha(s))) } : {}); if (c.method === 'GET' && m) return json(remote[m[1]!]?.length ? { images: remote[m[1]!]!.map(s => image(sha(s))) } : {});
if (c.method === 'DELETE' && c.url === `${API}/edits/edit-2`) return new Response(null, { status: 204 }); if (c.method === 'DELETE' && c.url === `${API}/edits/edit-2`) return new Response(null, { status: 204 });
return json({ error: { code: 400, message: 'unexpected' } }, 400); return json({ error: { code: 400, message: 'unexpected' } }, 400);
}); });
@ -186,6 +189,117 @@ describe('play images --dry: hashes compared, nothing uploaded', () => {
}); });
}); });
describe('play images: tablet, TV and Wear sets are optional (play.tablets)', () => {
const sha = (s: string) => createHash('sha256').update(s).digest('hex');
const LISTING_MD = '## English\n<!-- play: en-US -->\n\n### Play short description\n```\nShort.\n```\n\n### Play full description\n```\nFull.\n```\n';
/** A phone-only project like STAR DASH: renders/en has feature, ios65, ios69 and play; no tab7 / tab10. */
function phoneOnly(play: Record<string, unknown> = {}): Loaded {
const root = mkdtempSync(join(tmpdir(), 'sb-tabs-'));
const files: Record<string, string> = { 'store/icons/play-512.png': 'icon', 'store/renders/en/feature/1024x500.png': 'feature', 'store/listing.md': LISTING_MD };
for (const sub of ['play', 'ios65', 'ios69']) for (const n of ['01', '02']) files[`store/renders/en/${sub}/${n}.png`] = `${sub}${n}`;
for (const [p, body] of Object.entries(files)) { mkdirSync(join(root, p, '..'), { recursive: true }); writeFileSync(join(root, p), body); }
const config = { app: { name: 'Example Game' }, languages: [{ id: 'en', play: 'en-US' }], play: { packageName: PKG, ...play } } as unknown as ShipConfig;
return { config, root, path: join(root, 'storebound.config.ts'), resolve: p => join(root, p) };
}
/** Play double: images per type (Play already has 7" tablet screenshots), edits, uploads, commit; records calls. */
function store(remote: Record<string, string[]>) {
return fake(c => {
if (c.method === 'POST' && c.url === `${API}/edits`) return json({ id: 'edit-3' });
const m = c.url.match(/\/edits\/edit-3\/listings\/en-US\/(\w+)(\?.*)?$/);
if (c.method === 'GET' && m) return json(remote[m[1]!]?.length ? { images: remote[m[1]!]!.map(s => image(sha(s))) } : {});
if (c.method === 'DELETE' && m) { remote[m[1]!] = []; return json({ deleted: [] }); }
if (c.method === 'POST' && m) return json({ image: image(sha('x')) });
if (c.method === 'POST' && c.url.endsWith(':commit')) return json({ id: 'edit-3' });
if (c.method === 'DELETE' && c.url === `${API}/edits/edit-3`) return new Response(null, { status: 204 });
return json({ error: { code: 400, message: `unexpected ${c.method} ${c.url}` } }, 400);
});
}
async function capture(fn: () => Promise<void>): Promise<string> {
const lines: string[] = [];
const log = console.log;
console.log = (...a: unknown[]) => { lines.push(a.join(' ')); };
try { await fn(); } finally { console.log = log; }
return lines.join('\n');
}
test('phone-only renders: the dry plan lists icon, feature graphic and phone screenshots, an info line for tablets, exit 0', async () => {
playKey();
const loaded = phoneOnly();
const calls = store({ sevenInchScreenshots: ['old701', 'old702'] });
const before = process.exitCode;
const out = await capture(() => imagesRun({ loaded, args: parseArgs(['--dry']) }));
expect(process.exitCode).toBe(before);
expect(out).toContain('1 languages: icon (1), featureGraphic (1), phoneScreenshots (2)');
expect(out).toContain("tablet screenshots: none (optional; the app isn't eligible for tablet featuring without them)");
expect(out).toContain('TV / Wear screenshots: none (optional; only for Android TV / Wear OS apps)');
expect(out).toContain('en-US: would replace icon (0 -> 1), featureGraphic (0 -> 1), phoneScreenshots (0 -> 2); keeps sevenInchScreenshots on Play (2, none locally; --prune deletes them)');
expect(calls.every(readOnly)).toBe(true);
});
test('--dry --prune shows the tablet set Play has as a delete; still read-only', async () => {
playKey();
const calls = store({ icon: ['icon'], featureGraphic: ['feature'], phoneScreenshots: ['play01', 'play02'], sevenInchScreenshots: ['old701'] });
const out = await capture(() => imagesRun({ loaded: phoneOnly(), args: parseArgs(['--dry', '--prune']) }));
expect(out).toContain('en-US: would delete sevenInchScreenshots (1 on Play, none locally; --prune)');
expect(calls.every(readOnly)).toBe(true);
});
test('a real run uploads the phone sets and never deletes the tablet screenshots on Play without --prune', async () => {
playKey();
const remote: Record<string, string[]> = { sevenInchScreenshots: ['old701', 'old702'] };
const calls = store(remote);
await capture(() => imagesRun({ loaded: phoneOnly(), args: parseArgs([]) }));
const writes = calls.filter(c => !readOnly(c)).map(c => `${c.method} ${c.url.replace(API, '').replace(/^.*\/upload\/androidpublisher\/v3\/applications\/[^/]+/, 'UPLOAD').replace(/\?.*$/, '')}`);
expect(writes.filter(w => w.startsWith('POST UPLOAD'))).toHaveLength(4);
expect(writes.some(w => w.includes('sevenInch') || w.includes('tenInch'))).toBe(false);
expect(writes.at(-1)).toBe('POST /edits/edit-3:commit');
expect(remote.sevenInchScreenshots).toHaveLength(2);
});
test('play.tablets: true with no tablet renders is a clear error before any store call', async () => {
playKey();
const calls = store({});
await expect(imagesRun({ loaded: phoneOnly({ tablets: true }), args: parseArgs(['--dry']) }))
.rejects.toThrow(/en-US: play.tablets is true but there are no sevenInchScreenshots in .*tab7 \(render them: storebound shots render --size tab7,tab10, or set play.tablets: false\)/);
expect(calls).toEqual([]);
});
test('a missing required set is still an error with the fix', async () => {
playKey();
const loaded = phoneOnly();
rmSync(join(loaded.root, 'store/renders/en/play'), { recursive: true });
store({});
await expect(imagesRun({ loaded, args: parseArgs(['--dry']) })).rejects.toThrow(/en-US: no phoneScreenshots in .*play \(at least 2, required; render first: storebound shots render\)/);
});
test('play.tablets: false skips tablet renders that exist; the default follows the renders', async () => {
playKey();
const loaded = phoneOnly({ tablets: false });
for (const n of ['01', '02']) { mkdirSync(join(loaded.root, 'store/renders/en/tab7'), { recursive: true }); writeFileSync(join(loaded.root, `store/renders/en/tab7/${n}.png`), `t${n}`); }
store({});
const out = await capture(() => imagesRun({ loaded, args: parseArgs(['--dry']) }));
expect(out).toContain('play.tablets is false: tablet screenshots are not uploaded');
expect(out).not.toContain('sevenInchScreenshots (0 -> 2)');
expect(resolvedTablets({ play: {} }, join(loaded.root, 'store/renders'))).toBe(true);
expect(resolvedTablets({ play: { tablets: false } }, join(loaded.root, 'store/renders'))).toBe(false);
expect(resolvedTablets({ play: {} }, join(phoneOnly().root, 'store/renders'))).toBe(false);
});
test('pure parts: missing optional sets, required sets and render sizes follow play.tablets', () => {
expect(missingChange('tenInchScreenshots', 0, false).change).toBe('none');
expect(missingChange('tenInchScreenshots', 3, false).change).toBe('keep');
expect(missingChange('tenInchScreenshots', 3, true).change).toBe('delete');
expect(missingInfo(new Set(['tvScreenshots']))).toEqual(['tvScreenshots: none (optional)']);
const cfg = { play: { packageName: PKG } };
expect(requiredSets(cfg).tab7).toBeUndefined();
expect(requiredSets(cfg, undefined, true)).toMatchObject({ tab7: true, tab10: true });
expect(defaultSizes(cfg as Pick<ShipConfig, 'apple' | 'play'>, false)).toEqual(['play', 'feature']);
expect(defaultSizes(cfg as Pick<ShipConfig, 'apple' | 'play'>, true)).toEqual(['play', 'tab7', 'tab10', 'feature']);
expect(playRequiredImages(false)).toEqual({ icon: 1, featureGraphic: 1, phoneScreenshots: 2 });
expect(Object.keys(playRequiredImages(true))).toContain('tenInchScreenshots');
});
});
describe('play products --dry: what would change on an existing product', () => { describe('play products --dry: what would change on an existing product', () => {
// one product of oneTimeProducts.list, as answered (3 of 174 regions; Money without nanos for whole prices) // one product of oneTimeProducts.list, as answered (3 of 174 regions; Money without nanos for whole prices)
const CUR: Existing = { productId: 'com.example.game.no_ads', const CUR: Existing = { productId: 'com.example.game.no_ads',