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

@ -10,14 +10,17 @@
// 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 { 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 { join } from 'node:path';
import { collectPlayHealth } from '../cli/health';
import { parseArgs } from '../cli/lib/args';
import type { Loaded, ShipConfig } from '../cli/lib/config';
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 { type Existing, productChanges, run as productsRun } from '../cli/play/products';
import { toMoney } from '../cli/lib/money';
@ -171,7 +174,7 @@ describe('play images --dry: hashes compared, nothing uploaded', () => {
const calls = fake(c => {
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+)$/);
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 });
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', () => {
// 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',