From ed8d138a8e0bb229940b1e7c39a96cf615ac7d42 Mon Sep 17 00:00:00 2001 From: CRT Date: Sat, 24 Oct 2026 19:46:57 +0800 Subject: [PATCH] play: a new language is created with everything Play needs to commit it (icon, feature graphic, phone and tablet screenshots); play images creates a missing listing --- CHANGELOG.md | 2 ++ cli/play/images.ts | 55 ++++++++++++++++++++-------- cli/play/listing.ts | 40 +++++++++++++++++---- skills/store-pitfalls/SKILL.md | 22 +++++++----- tests/helpers/fake-stores.ts | 13 +++++++ tests/idempotency.test.ts | 66 ++++++++++++++++++++++++++++++++++ tests/play-post-reads.test.ts | 6 ++-- 7 files changed, 172 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 80f2267..d11434f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ ## Unreleased +- `play listing` for a language Play does not have yet uploads, in the same edit as the text, everything Play requires to commit it: icon, feature graphic, 2+ phone screenshots from `store/renders//` and the tablet sets when the other languages have them (a set the folder lacks falls back to the default language's). The dry run lists the uploads or refuses with the missing file, instead of a 403 "too few screenshots" at commit. `play images` creates a Play listing that does not exist yet (text from the listing file) together with its images instead of skipping the language (pitfall 102). + ## 0.8.48 · 2026-10-24 - Desktop tray: the right-click menu is drawn by the app (the design system's menu look) instead of the OS menu. Each submenu opens as its own small panel; the pin button, Ctrl+click or a middle click keeps a panel open (pinned): it can be dragged, steps aside so it never covers another pinned panel, and comes back after a restart. Values (the apps and their store state, mute) update in place after every store read without closing anything. The native menu stays as a fallback (and on Linux), built from the same menu tree. The left-click tray card is unchanged. diff --git a/cli/play/images.ts b/cli/play/images.ts index de181b2..76ed75c 100644 --- a/cli/play/images.ts +++ b/cli/play/images.ts @@ -2,8 +2,9 @@ // language, not of the whole upload). // storebound play images [--dry] [--only en-US,de-DE] [--src store/renders] [--variant b] [--prune] [--include-drafts] [--not-sent-for-review] // Languages: the ones `play listing` pushes (a status=draft or status=translate section is skipped unless -// --include-drafts) and that have a listing on Play. A language Play has no listing for yet (404 "Listing for language -// ... not found") is skipped with an info line, never an error: run `play listing` first. +// --include-drafts). A language Play has no listing for yet (404 "Listing for language ... not found") is created by +// this run from its text in the listing file, in the same edit as all its images (Play commits a new language only with +// icon, feature graphic and 2+ phone screenshots in it; `play listing` does the same, from either command). // Per Play language, from //: icon (play.icon, 512x512), featureGraphic (feature/1024x500.*) and // phoneScreenshots (play/NN.*, at least 2) are required (Play Console Help answer 9866151). Optional, uploaded only // when present: sevenInchScreenshots (tab7/NN.*), tenInchScreenshots (tab10/NN.*), tvScreenshots (tv/NN.*), @@ -26,7 +27,7 @@ import { draftReason, draftSections } from '../lib/listing-plan'; import { done, log, step } from '../lib/log'; import { playClient } from './client'; import { pulledChecksum } from '../lib/art-pull'; -import { readPlayCopy } from './listing'; +import { listingBody, readPlayCopy } from './listing'; import { variantRoot } from '../shots/lib'; import { resolvedTablets } from '../shots/report'; import { consoleLink } from '../lib/console-links'; @@ -72,16 +73,34 @@ export function missingChange(type: string, remote: number, prune: boolean): Ima /** 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 graphics a NEW Play language needs before its edit can commit (403 "no high resolution icon set for language"): - * the hi-res icon and, when rendered, the feature graphic. `play listing` uploads them in the same edit as the text, - * so listing and images never wait for each other (pitfall 102). */ -export function firstGraphics(ctx: Ctx, code: string): [string, string][] { +/** Everything Play wants in the same edit that creates a NEW language, or the commit is refused (403 "no high + * resolution icon set for language X" / "too few screenshots for language X"): icon, feature graphic, at least 2 phone + * screenshots and, when the app's other languages have them (or play.tablets is true), the two tablet sets. A set the + * language's renders folder lacks falls back to the default language's (a note); what is still missing is an error + * with the exact fix, raised in the dry run, never at commit time (pitfall 102). */ +export interface NewLangImages { code: string; uploads: [string, string[]][]; notes: string[]; errors: string[] } +export function newLanguageImages(ctx: Ctx, code: string, defaultCode: string, tabletsOnPlay: boolean): NewLangImages { const { config, resolve } = ctx.loaded; const icon = resolve(config.play?.icon ?? join(config.icons?.out ?? 'store/icons', 'play-512.png')); - const l = byPlayCode(config.languages).get(code); - const src = resolve(config.renders ?? config.shots?.out ?? 'store/renders'); - const li = langImages(code, l ? join(src, l.id) : join(src, '__none__'), icon, false); - return li.sets.filter(([t]) => t === 'icon' || t === 'featureGraphic').map(([t, files]) => [t, files[0]!] as [string, string]); + const langOf = byPlayCode(config.languages); + const src = variantRoot(resolve(ctx.args.get('src') ?? config.renders ?? config.shots?.out ?? 'store/renders'), ctx.args.get('variant'), config.shots?.variants); + const tablets = config.play?.tablets === undefined ? resolvedTablets(config, src) || tabletsOnPlay : config.play.tablets; + const dirOf = (c: string) => join(src, langOf.get(c)?.id ?? '__none__'); + const own = langImages(code, dirOf(code), icon, tablets); + const def = code === defaultCode ? null : langImages(defaultCode, dirOf(defaultCode), icon, tablets); + const required = ['icon', 'featureGraphic', 'phoneScreenshots', ...(tablets ? PLAY_TABLET_TYPES : [])]; + const sets = new Map(own.sets); + const notes: string[] = [], errors: string[] = []; + for (const type of required) { + const mine = sets.get(type), fb = def?.sets.find(([t]) => t === type)?.[1]; + const short = type === 'phoneScreenshots' || PLAY_TABLET_TYPES.includes(type); + if (mine && (!short || mine.length >= 2)) continue; + if (fb && (!short || fb.length >= 2)) { sets.set(type, fb); notes.push(`${code}: no ${type} of its own, using ${defaultCode}'s (${fb.length}); render ${code}'s with storebound shots render`); continue; } + const why = own.errors.find(e => e.includes(type === 'icon' ? 'no icon' : type)) ?? `${code}: ${type} missing`; + errors.push(`${why}. Play refuses to commit a new language without it (403 too few screenshots / no high resolution icon)`); + } + const uploads = [...sets].filter(([t, f]) => f.length && (tablets || !PLAY_TABLET_TYPES.includes(t))); + return { code, uploads, notes, errors }; } /** The image sets of one language folder (`tablets` = resolved play.tablets). Reads the folder only. */ @@ -180,7 +199,7 @@ export async function run(ctx: Ctx): Promise { (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) - // a language with no listing on Play yet answers 404: skipped (play listing creates it), never fatal + // a language with no listing on Play yet answers 404: created in its edit (below), never fatal const plan = new Map(); const unlisted: string[] = []; await play.read(async id => { @@ -197,14 +216,21 @@ export async function run(ctx: Ctx): Promise { plan.set(code, out); } }); - for (const code of unlisted) log('play', `skipped ${code}: no Play listing yet (run storebound play listing first; a draft section needs review or --include-drafts)`); + // a language with text in the listing file but no Play listing yet: this run creates it (the default text of the file) + // together with all its images in one edit, which Play only commits as a whole (pitfall 102) + const copyOf = new Map(readPlayCopy(ctx).map(c => [c.lang, c])); + for (const code of unlisted) { + const li = sets.get(code)!; + plan.set(code, [...li.sets.map(([type, files]) => imageChange(type, [], files.map(storeSha))), ...li.missing.map(t => missingChange(t, 0, prune))]); + log('play', `${code}: no Play listing yet, this run creates it from the listing file with its images`); + } codes = codes.filter(c => plan.has(c)); const writes = (code: string) => plan.get(code)!.filter(c => c.change === 'replace' || c.change === 'delete'); if (ctx.args.has('dry')) { let changed = 0; for (const [i, code] of codes.entries()) { changed += writes(code).length; - log('play', `${step(i + 1, codes.length, code)}: ${describePlan(plan.get(code)!)}`); + log('play', `${step(i + 1, codes.length, code)}: ${describePlan(plan.get(code)!)}${unlisted.includes(code) ? ' (and creates the listing)' : ''}`); } done('play', `dry run: ${changed ? changed + ' image set(s) would change' : 'every image set is already on Play'}; read-only, nothing uploaded`); return; @@ -221,6 +247,7 @@ export async function run(ctx: Ctx): Promise { const li = sets.get(code)!; await play.edit(async (id, ctl) => { let n = 0, types = 0, deleted = 0; + if (unlisted.includes(code)) await play.call('PUT', `${play.API}/edits/${id}/listings/${code}`, listingBody(copyOf.get(code)!, config.play?.video)); await Promise.all([ ...li.sets.map(async ([type, files]) => { const remote = await remoteOf(id, code, type); diff --git a/cli/play/listing.ts b/cli/play/listing.ts index f0dce56..698a1f1 100644 --- a/cli/play/listing.ts +++ b/cli/play/listing.ts @@ -20,7 +20,7 @@ import type { Ctx } from '../lib/ctx'; import { confirm, need } from '../lib/ctx'; import { done, log, printJson, step, warn } from '../lib/log'; import { playClient } from './client'; -import { firstGraphics } from './images'; +import { newLanguageImages, type NewLangImages } from './images'; import { stdinIsTTY } from '../lib/tty'; /** Play release notes that name iOS, iPhone, App Store, ... are refused before any edit, dry runs included (pitfall 77). */ @@ -140,12 +140,40 @@ export async function run(ctx: Ctx): Promise { if (!a.has('json')) log('play', `${copy.length} listings: ${copy.map(c => c.lang).join(', ')}${config.play?.video ? ` · promo video ${config.play.video}` : ''}`); const play = await playClient(root, pkg); const bodies = copy.map(c => listingBody(c, config.play?.video)); + // A language Play does not have yet only commits with its icon, feature graphic, 2+ phone screenshots (and the + // tablet sets when the other languages have them) in the same edit: planned here so the dry run refuses early. + const fresh = new Map(); + const planFresh = async (id: string, now: PlayListing[]) => { + const missing = bodies.filter(b => !now.some(n => n.language === b.language)).map(b => b.language!); + if (!missing.length) return; + const ref = now.find(n => n.language === details.defaultLanguage) ?? now[0]; + let tabletsOnPlay = false; + if (ref) for (const t of ['sevenInchScreenshots', 'tenInchScreenshots']) { + try { tabletsOnPlay ||= !!((await play.call<{ images?: unknown[] }>('GET', `${play.API}/edits/${id}/listings/${ref.language}/${t}`)).images?.length); } catch { /* no tablet set */ } + } + for (const code of missing) { + const f = newLanguageImages(ctx, code, details.defaultLanguage, tabletsOnPlay); + // the very first listing of an app: Play checks the images at release, not at this commit, so only a warning + if (!now.length && f.errors.length) { f.notes.push(...f.errors.map(e => `warning: ${e}`)); f.errors = []; } + fresh.set(code, f); + } + }; const p = await play.read(async id => { const cur = await play.call>('GET', `${play.API}/edits/${id}/details`); const now = (await play.call<{ listings?: PlayListing[] }>('GET', `${play.API}/edits/${id}/listings`)).listings ?? []; + await planFresh(id, now); return playPlan(cur, details, now, bodies, { skipped, notes, only: a.has('only') }); }); + for (const f of fresh.values()) { + p.notes.push(...f.notes); + if (!f.errors.length) p.notes.push(`${f.code} is new on Play: uploads ${f.uploads.map(([t, fl]) => `${t} (${fl.length})`).join(', ')} in the same edit`); + } const dry = a.has('dry'); + const refuse = [...fresh.values()].flatMap(f => f.errors); + if (refuse.length) { + if (a.has('json')) printJson({ dry, ...p, errors: refuse }); else for (const line of playPlanLines(p)) console.log(line); + throw new Error(['a new Play language needs its images in the same edit:', ...refuse].join('\n')); + } if (a.has('json')) printJson({ dry, ...p }); else for (const line of playPlanLines(p)) console.log(line); if (dry) { done('play', `dry run: ${p.changes ? `${p.changes} change(s)` : 'nothing to change'}; read-only, nothing written`); return; } @@ -168,13 +196,11 @@ export async function run(ctx: Ctx): Promise { for (const [i, b] of todo.entries()) { await play.call('PUT', `${play.API}/edits/${id}/listings/${b.language}`, b); log('play', step(i + 1, todo.length, b.language!)); - // a language Play does not have yet: its edit only commits with a hi-res icon (pitfall 102) + // a language Play does not have yet: its edit only commits with its images in it (pitfall 102) if (!now.some(n => n.language === b.language)) { - const graphics = firstGraphics(ctx, b.language!); - if (!graphics.some(([t]) => t === 'icon')) warn('play', `${b.language}: no 512x512 icon to send with this new language (play.icon or store/icons/play-512.png; storebound icons makes it): Play may refuse the commit with 403 "no high resolution icon"`); - for (const [type, f] of graphics) { - await play.call('POST', `${play.UPLOAD}/edits/${id}/listings/${b.language}/${type}?uploadType=media`, new Uint8Array(readFileSync(f)), { 'Content-Type': /\.png$/i.test(f) ? 'image/png' : 'image/jpeg' }); - log('play', `${b.language}: ${type} uploaded with the new listing`); + for (const [type, files] of fresh.get(b.language!)?.uploads ?? []) { + for (const f of files) await play.call('POST', `${play.UPLOAD}/edits/${id}/listings/${b.language}/${type}?uploadType=media`, new Uint8Array(readFileSync(f)), { 'Content-Type': /.png$/i.test(f) ? 'image/png' : 'image/jpeg' }); + log('play', `${b.language}: ${type} (${files.length}) uploaded with the new listing`); } } } diff --git a/skills/store-pitfalls/SKILL.md b/skills/store-pitfalls/SKILL.md index 4d19934..95f7ac0 100644 --- a/skills/store-pitfalls/SKILL.md +++ b/skills/store-pitfalls/SKILL.md @@ -1239,15 +1239,21 @@ that fails without the fix (see CONTRIBUTING.md). `cli/lib/credentials.ts` resolves relative credential paths from the project and `ascKeyFile` hands xcodebuild an absolute path (`tests/build-sign.test.ts`). -## 102. A new Play language: `play listing` 403 "no high resolution icon set for language", `play images` skips it -- **Symptom:** adding a language (tr-TR) to an existing app, `play listing --yes` failed at the edit commit with 403 - "This app has no high resolution icon set for language tr-TR", while `play images` said "skipped tr-TR: no Play +## 102. A new Play language: commit 403 "no high resolution icon" / "too few screenshots for language", `play images` skips it +- **Symptom:** adding a language (tr-TR) to an existing app, `play listing --write --yes` created the listing and + uploaded the icon and feature graphic, then the commit failed: 403 PERMISSION_DENIED "This app has too few screenshots + for language tr-TR" (earlier: "no high resolution icon set"). `play images` then said "skipped tr-TR: no Play listing yet": each waited for the other. -- **Cause:** Play commits a new language only when its hi-res icon is in the same edit. -- **Do:** update storebound; `play listing` now uploads the icon (and the feature graphic when rendered) for a new - language in the same edit. Then run `play images` for the screenshots. -- **Guard:** `firstGraphics` in `cli/play/images.ts`, used by `cli/play/listing.ts` for languages Play does not have yet; - it stops with the fix when the 512x512 icon is missing. +- **Cause:** Play commits a new language only with its hi-res icon, feature graphic, 2+ phone screenshots (and the + tablet sets when the app's other languages have them) in the SAME edit. +- **Do:** update storebound; `play listing` uploads all of these for a new language in its edit, from + `store/renders//` (a set that folder lacks falls back to the default language's, with a note), and the dry run + lists the uploads or refuses with the exact missing file. `play images` now creates a listing Play does not have yet + (text from the listing file) together with its images, so either command works. Render first if it refuses: + `storebound shots render`. +- **Guard:** `newLanguageImages` in `cli/play/images.ts`, planned in `cli/play/listing.ts` before any write; + `FakePlay.requireNewLangAssets` simulates the 403 (`tests/idempotency.test.ts`). The first listing of an app with no + listing at all only warns. ## 103. "App.entitlements is not referenced by CODE_SIGN_ENTITLEMENTS" on Windows although it is - **Symptom:** on Windows, `apple status` / `apple signing` warned that `ios\App\App\App.entitlements` is not diff --git a/tests/helpers/fake-stores.ts b/tests/helpers/fake-stores.ts index bf5c880..d89233f 100644 --- a/tests/helpers/fake-stores.ts +++ b/tests/helpers/fake-stores.ts @@ -421,6 +421,10 @@ export class FakePlay { priceLimits: Record = {}; /** product id -> 400 message for its PATCH */ rejectProducts: Record = {}; + /** live Play: committing a language that is new in the edit needs its hi-res icon, feature graphic and 2+ phone screenshots + * in the same edit (403 PERMISSION_DENIED), 2+ tablet sets too when `requireTablets` */ + requireNewLangAssets = false; + requireTablets = false; /** validate calls that carried changesNotSentForReview (live Play answers them 400 "Unknown name") */ validateWithNotSent = 0; @@ -445,12 +449,21 @@ export class FakePlay { return json({ error: { code: 400, status: 'INVALID_ARGUMENT', message: 'Changes cannot be sent for review automatically. Please set the query parameter changesNotSentForReview to true. Once committed, the changes in this edit can be sent for review from the Google Play Console UI.', errors: [{ message: 'Changes cannot be sent for review automatically. Please set the query parameter changesNotSentForReview to true.', domain: 'global', reason: 'badRequest' }] } }, 400); } if (method === 'POST' && rest === ':validate') return json({ id }); + if (method === 'POST' && rest === ':commit' && this.requireNewLangAssets) { + for (const lang of Object.keys(e.listings).filter(l => !this.live.listings[l])) { + const n = (t: string) => (e.images[`${lang}/${t}`] ?? []).length; + const miss = n('icon') < 1 ? `This app has no high resolution icon set for language ${lang}` : n('featureGraphic') < 1 ? `This app has no feature graphic set for language ${lang}` + : n('phoneScreenshots') < 2 || (this.requireTablets && (n('sevenInchScreenshots') < 2 || n('tenInchScreenshots') < 2)) ? `This app has too few screenshots for language ${lang}` : ''; + if (miss) return err(403, 'PERMISSION_DENIED', miss); + } + } if (method === 'POST' && rest === ':commit') { this.live = e; this.edits.delete(id!); this.committed.push(id!); return json({ id }); } if (rest === '/details') { if (method === 'PUT') e.details = body as Record; return json(e.details); } if (rest === '/listings') return json({ listings: Object.values(e.listings) }); if ((m = /^\/listings\/([^/]+)$/.exec(rest!))) { if (method === 'PUT') e.listings[m[1]!] = body as Record; return json(e.listings[m[1]!] ?? {}); } if ((m = /^\/listings\/([^/]+)\/(\w+)$/.exec(rest!))) { const k = `${m[1]}/${m[2]}`; + if (this.requireNewLangAssets && method === 'GET' && !e.listings[m[1]!]) return err(404, 'NOT_FOUND', `Listing for language '${m[1]}' not found.`); if (method === 'DELETE') { e.images[k] = []; return json({ deleted: [] }); } if (method === 'POST') { const h = new Bun.CryptoHasher('sha256').update(body as Uint8Array).digest('hex'); (e.images[k] ??= []).push(h); return json({ image: { sha256: h } }); } return json({ images: (e.images[k] ?? []).map(sha256 => ({ sha256 })) }); diff --git a/tests/idempotency.test.ts b/tests/idempotency.test.ts index bef1580..39d22af 100644 --- a/tests/idempotency.test.ts +++ b/tests/idempotency.test.ts @@ -370,6 +370,7 @@ describe('listing, appinfo, data safety and the owner commands: a second run wri test('play listing: one committed edit with only what differs; the second run opens no write edit and commits nothing', async () => { s.play.live.listings['de-DE'] = { language: 'de-DE', title: 'Notizen' }; + s.play.live.listings['en-US'] = { language: 'en-US', title: 'Old' }; const p = project(CONFIG, { 'store/listing.md': LISTING }); const r = await twice(() => playListing(p.ctx([]))); expect(r.first).toEqual(['PUT /androidpublisher/v3/applications/com.example.notes/edits/edit-2/details', 'PUT /androidpublisher/v3/applications/com.example.notes/edits/edit-2/listings/en-US', @@ -380,6 +381,71 @@ describe('listing, appinfo, data safety and the owner commands: a second run wri expect(s.play.live.listings['de-DE']).toEqual({ language: 'de-DE', title: 'Notizen' }); // a language not in the file is left alone }); + // A new Play language (tr-TR next to a live en-US): the commit is refused 403 "too few screenshots" unless the + // screenshots, icon and feature graphic travel in the same edit (pitfall 102) + const trConfig = (): ShipConfig => ({ ...CONFIG, languages: [{ id: 'en', play: 'en-US' }, { id: 'tr', play: 'tr-TR' }] as never }); + const trListing = LISTING + ['', '## Turkish', '', '', '### Play short description', '```', 'Notlar.', '```', '', '### Play full description', '```', 'Herkes icin notlar.', '```', ''].join('\n'); + const renders = (lang: string, base: number, tablets = false): Record => { + const f: Record = { [`store/renders/${lang}/feature/1024x500.png`]: png(base) }; + for (const n of [1, 2]) { f[`store/renders/${lang}/play/0${n}.png`] = png(base + n); if (tablets) for (const t of ['tab7', 'tab10']) f[`store/renders/${lang}/${t}/0${n}.png`] = png(base + n + 5); } + return f; + }; + const liveEn = () => { + s.play.live.listings['en-US'] = { language: 'en-US', title: 'Example Notes', shortDescription: 'Notes.', fullDescription: 'Notes for everyone.' }; + s.play.live.details = { defaultLanguage: 'en-US', contactEmail: 'support@example.com', contactWebsite: 'https://example.com' }; + s.play.requireNewLangAssets = true; + }; + + test('play listing: a new language uploads icon, feature graphic and phone screenshots in the same edit, so the commit passes', async () => { + liveEn(); + const p = project(trConfig(), { 'store/listing.md': trListing, 'store/icons/play-512.png': png(1), ...renders('tr', 40) }); + const r = await capture(() => playListing(p.ctx([]))); + expect(r.exit).toBe(null); + expect(s.play.committed).toHaveLength(1); + expect(s.play.live.images['tr-TR/phoneScreenshots']).toHaveLength(2); + expect(s.play.live.images['tr-TR/icon']).toHaveLength(1); + expect(s.play.live.images['tr-TR/featureGraphic']).toHaveLength(1); + }); + + test('play listing: a new language without its own screenshots uses the default language images; none anywhere is refused in the dry run, nothing written', async () => { + liveEn(); + const base = { 'store/listing.md': trListing, 'store/icons/play-512.png': png(1) }; + const ok = project(trConfig(), { ...base, ...renders('en', 10) }); + const r1 = await capture(() => playListing(ok.ctx([]))); + expect(r1.exit).toBe(null); + expect(r1.out).toContain("no phoneScreenshots of its own, using en-US's"); + expect(s.play.live.images['tr-TR/phoneScreenshots']).toHaveLength(2); + s.play.live.listings = { 'en-US': s.play.live.listings['en-US']! }; + s.play.live.images = {}; + const bad = project(trConfig(), base); + const before = s.play.committed.length; + const mark = s.calls.length; + await expect(playListing(bad.ctx(['--dry']))).rejects.toThrow('tr-TR: no phoneScreenshots'); + expect(s.play.committed).toHaveLength(before); + expect(writes(s.calls.slice(mark))).toEqual([]); + }); + + test('play listing: the dry run names the uploads of a new language; tablet sets are required when the default language has them on Play', async () => { + liveEn(); + s.play.requireTablets = true; + s.play.live.images['en-US/sevenInchScreenshots'] = ['x', 'y']; + const p = project(trConfig(), { 'store/listing.md': trListing, 'store/icons/play-512.png': png(1), ...renders('tr', 40), ...renders('en', 10, true) }); + const r = await capture(() => playListing(p.ctx(['--dry']))); + expect(r.out).toContain('tr-TR is new on Play: uploads'); + expect(r.out).toContain('sevenInchScreenshots (2)'); + expect(s.play.committed).toEqual([]); + }); + + test('play images: a language with text in the listing file but no Play listing yet is created with its images, commit passes', async () => { + liveEn(); + const p = project(trConfig(), { 'store/listing.md': trListing, 'store/icons/play-512.png': png(1), ...renders('en', 10), ...renders('tr', 40) }); + const r = await capture(() => playImages(p.ctx([]))); + expect(r.exit).toBe(null); + expect(r.out).toContain('tr-TR: no Play listing yet, this run creates it'); + expect(s.play.live.listings['tr-TR']!.title).toBeDefined(); + expect(s.play.live.images['tr-TR/phoneScreenshots']).toHaveLength(2); + }); + test('play listing: the store reached the wanted state between plan and edit: the edit is deleted, not committed', async () => { const p = project(CONFIG, { 'store/listing.md': LISTING }); // the plan sees an empty listing; right after it (before the write edit opens) another run makes it current diff --git a/tests/play-post-reads.test.ts b/tests/play-post-reads.test.ts index 6322b37..5572ae1 100644 --- a/tests/play-post-reads.test.ts +++ b/tests/play-post-reads.test.ts @@ -287,7 +287,7 @@ describe('play images: tablet, TV and Wear sets are optional (play.tablets)', () expect(resolvedTablets({ play: {} }, join(phoneOnly().root, 'store/renders'))).toBe(false); }); - test('languages without a Play listing are skipped: a draft section before any call, a 404 listing as info; exit 0', async () => { + test('a draft section is skipped before any call, a language with a 404 listing is created by the run (dry: named); exit 0', async () => { playKey(); const root = mkdtempSync(join(tmpdir(), 'sb-langs-')); const sec = (name: string, code: string, marker = '') => `## ${name}\n\n${marker}\n### Play short description\n\`\`\`\nShort.\n\`\`\`\n\n### Play full description\n\`\`\`\nFull.\n\`\`\`\n\n`; @@ -309,9 +309,9 @@ describe('play images: tablet, TV and Wear sets are optional (play.tablets)', () const out = await capture(() => imagesRun({ loaded, args: parseArgs(['--dry']) })); expect(process.exitCode).toBe(before); expect(out).toContain('skipped fr-FR: no Play listing yet (machine-translated draft, not reviewed'); - expect(out).toContain('skipped de-DE: no Play listing yet (run storebound play listing first'); + expect(out).toContain('de-DE: no Play listing yet, this run creates it from the listing file with its images'); + expect(out).toContain('de-DE: would replace icon (0 -> 1)'); expect(out).toContain('en-US: would replace icon (0 -> 1)'); - expect(out).not.toMatch(/de-DE: would/); expect(calls.some(c => c.url.includes('fr-FR'))).toBe(false); expect(calls.every(readOnly)).toBe(true); expect(noListing(new Error("GET /edits/1/listings/de-DE/icon -> 404 Listing for language 'de-DE' not found."))).toBe(true);