From 720cb2a13a8f8f38eaa4500811c16f6de843b498 Mon Sep 17 00:00:00 2001 From: CRT Date: Mon, 12 Oct 2026 08:40:27 +0800 Subject: [PATCH] play images: same languages as play listing (drafts skipped unless --include-drafts); a language with no Play listing (404) is skipped with an info line, never fatal --- CHANGELOG.md | 3 +++ cli/data/flags.json | 2 +- cli/play/images.ts | 34 ++++++++++++++++++++++++++++++---- docs/pitfalls.md | 2 +- skills/store-pitfalls/SKILL.md | 4 ++++ tests/play-post-reads.test.ts | 33 ++++++++++++++++++++++++++++++++- 6 files changed, 71 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 015ade2..d87b9e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,9 @@ 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 on Play`; new `--prune` deletes them (shown as `would delete` in the dry run; not exposed over MCP). Pitfall 19. + - Languages: the ones `play listing` pushes. A status=draft / status=translate section is skipped (`skipped de-DE: no + Play listing yet (machine-translated draft, not reviewed ...)`, or `--include-drafts`), and a language Play has no + listing for (404 "Listing for language 'de-DE' not found.") is skipped with an info line instead of aborting. - 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 diff --git a/cli/data/flags.json b/cli/data/flags.json index 5bf7e4f..1ea4eac 100644 --- a/cli/data/flags.json +++ b/cli/data/flags.json @@ -1063,7 +1063,7 @@ "storebound play data-safety [--csv out.csv] [--dry] [--force]" ], "play images": [ - "storebound play images [--dry] [--only en-US,de-DE] [--src store/renders] [--variant b] [--prune]" + "storebound play images [--dry] [--only en-US,de-DE] [--src store/renders] [--variant b] [--prune] [--include-drafts]" ], "play listing": [ "storebound play listing [--dry] [--json] [--only en-US,de-DE] [--include-drafts]", diff --git a/cli/play/images.ts b/cli/play/images.ts index 1c83e99..91e4e17 100644 --- a/cli/play/images.ts +++ b/cli/play/images.ts @@ -1,6 +1,9 @@ // 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). -// storebound play images [--dry] [--only en-US,de-DE] [--src store/renders] [--variant b] [--prune] +// storebound play images [--dry] [--only en-US,de-DE] [--src store/renders] [--variant b] [--prune] [--include-drafts] +// 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. // 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.*), @@ -19,6 +22,7 @@ import { existsSync, readdirSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import type { Ctx } from '../lib/ctx'; import { byPlayCode, need } from '../lib/ctx'; +import { draftReason, draftSections } from '../lib/listing-plan'; import { done, log, step } from '../lib/log'; import { playClient } from './client'; import { readPlayCopy } from './listing'; @@ -106,6 +110,9 @@ export function describePlan(plan: ImageChange[]): string { return parts.join('; '); } +/** A Play 404 for a language with no store listing ("Listing for language 'de-DE' not found."). Pure. */ +export const noListing = (e: unknown): boolean => /-> 404\b/.test(String((e as Error)?.message ?? e)) && /listing/i.test(String((e as Error)?.message ?? e)); + const sha256 = (f: string): string => createHash('sha256').update(readFileSync(f)).digest('hex'); export async function run(ctx: Ctx): Promise { @@ -116,7 +123,15 @@ export async function run(ctx: Ctx): Promise { 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 langOf = byPlayCode(config.languages); - const codes = readPlayCopy(ctx).map(c => c.lang); + // the languages `play listing` pushes: a draft or untranslated section has no Play listing yet (unless --include-drafts) + const drafts = draftSections(readFileSync(resolve(config.play?.listing ?? 'store/listing.md'), 'utf8'), 'play'); + const skipped: string[] = []; + let codes = readPlayCopy(ctx).map(c => c.lang).filter(code => { + const st = drafts.get(code); + if (!st || ctx.args.has('include-drafts')) return true; + skipped.push(`skipped ${code}: no Play listing yet (${draftReason(st)})`); + return false; + }); const tablets = resolvedTablets(config, src); const sets = new Map(); @@ -128,6 +143,7 @@ export async function run(ctx: Ctx): Promise { errors.push(...li.errors); sets.set(code, li); } + for (const line of skipped) log('play', line); 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'}`); @@ -139,15 +155,25 @@ 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 const plan = new Map(); + const unlisted: string[] = []; await play.read(async id => { for (const code of codes) { const li = sets.get(code)!, out: ImageChange[] = []; - for (const [type, files] of li.sets) out.push(imageChange(type, await remoteOf(id, code, type), files.map(sha256))); - for (const type of li.missing) out.push(missingChange(type, (await remoteOf(id, code, type)).length, prune)); + try { + for (const [type, files] of li.sets) out.push(imageChange(type, await remoteOf(id, code, type), files.map(sha256))); + for (const type of li.missing) out.push(missingChange(type, (await remoteOf(id, code, type)).length, prune)); + } catch (e) { + if (!noListing(e)) throw e; + unlisted.push(code); + continue; + } 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)`); + 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; diff --git a/docs/pitfalls.md b/docs/pitfalls.md index 3d86150..179b71b 100644 --- a/docs/pitfalls.md +++ b/docs/pitfalls.md @@ -11,7 +11,7 @@ fails without the fix. |---|---|---|---| | 21 | 2026-10 | Play Data safety: conditional required rows. `play data-safety` for a game with username accounts (`accountCreation: ['PSL_ACM_USER_ID_OTHER_AUTH']`) got 400 "Invalid safety labels declaration: Response missing for PSL_ACCOUNT_DELETION_URL": once account creation is not `PSL_ACM_NONE`, Play requires the delete-account URL and the data-only deletion question (`PSL_SUPPORT_DATA_DELETION_BY_USER`, and `PSL_DATA_DELETION_URL` when it is yes), and refuses `PSL_HAS_OUTSIDE_APP_ACCOUNTS`; storebound wrote none of them. | `dataSafetyRows` writes them from `accounts.deletionUrl` and `dataSafety.partialDeletion` / `partialDeletionUrl`; `DATA_SAFETY_RULES` / `typeRules` / `dataSafetyIssues` in `cli/lib/data-safety.ts` check every conditional row in `check`, `project.declarations` and `play data-safety` (dry and real, before the POST); `tests/pure.test.ts` (conditional required rows), `tests/idempotency.test.ts`, `tests/app-privacy.test.ts`. | | 20 | 2026-10 | Hours of selector-fix cycles of the scripted console engine (`console fill`): the real console kept differing from the model (App Privacy "Set Up" as plain text, purpose checkboxes without an accessible name, two information steps in a type dialog, Play's material radios that ignore the native input). | Agent-first console mode: `console plan
` gives an AI agent the start URL, steps, answer sheet with the console's labels, never-press list and verified UI facts; the agent drives the page with its own browser tool and stops before the final button; `console verify ` checks the result (App Privacy summaries from the page text, Play Create app from input states, API for the App Store record and age rating). The scripted fill stays a fast path (and now handles the verified facts). MCP: `console_plan`, `console_verify` (read-only). `tests/console-plan.test.ts`, `tests/console-flow.test.ts`. | -| 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`). | +| 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; then a 404 "Listing for language 'de-DE' not found." for languages still `status=draft`: `play images` takes the languages `play listing` pushes and skips one with no Play listing as info (`noListing`) (`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`). Confirmed 2026-10-12: the earlier default copy DID sign the owner out of Play Console in their real Chrome. | | 17 | 2026-10 | After the data types were saved, the real App Privacy page shows each type's "Set Up " as a plain `

` 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 " 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: at "; 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). | diff --git a/skills/store-pitfalls/SKILL.md b/skills/store-pitfalls/SKILL.md index 602aeac..55134a6 100644 --- a/skills/store-pitfalls/SKILL.md +++ b/skills/store-pitfalls/SKILL.md @@ -212,6 +212,10 @@ Each entry: **symptom** → cause → what to do → what guards it now. The ful 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. +- **Also:** the next abort was `GET .../listings/de-DE/icon -> 404 "Listing for language 'de-DE' not found."`: 20 + languages were still `status=draft`, so `play listing` had (rightly) not created them. `play images` now takes the same + languages as `play listing` (drafts skipped unless `--include-drafts`) and skips a language with no Play listing + (`skipped de-DE: no Play listing yet`), never failing on it. Run `play listing` before `play images`. - **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`). diff --git a/tests/play-post-reads.test.ts b/tests/play-post-reads.test.ts index 2d5d8bf..29ab182 100644 --- a/tests/play-post-reads.test.ts +++ b/tests/play-post-reads.test.ts @@ -17,7 +17,7 @@ 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, missingChange, missingInfo, run as imagesRun } from '../cli/play/images'; +import { imageChange, missingChange, missingInfo, noListing, 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'; @@ -285,6 +285,37 @@ 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 () => { + 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`; + const files: Record = { 'store/icons/play-512.png': 'icon', + 'store/listing.md': sec('English', 'en-US') + sec('German', 'de-DE') + sec('French', 'fr-FR', '\n') }; + // de-DE has local renders but no listing on Play; fr-FR (draft) has no renders at all and must not fail + for (const l of ['en', 'de']) { files[`store/renders/${l}/feature/1024x500.png`] = 'feature'; for (const n of ['01', '02']) files[`store/renders/${l}/play/${n}.png`] = `play${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' }, { id: 'de', play: 'de-DE' }, { id: 'fr', play: 'fr-FR' }], play: { packageName: PKG } } as unknown as ShipConfig; + const loaded: Loaded = { config, root, path: join(root, 'storebound.config.ts'), resolve: p => join(root, p) }; + const calls = fake(c => { + if (c.method === 'POST' && c.url === `${API}/edits`) return json({ id: 'edit-4' }); + if (c.method === 'GET' && c.url.includes('/listings/de-DE/')) return json({ error: { code: 404, message: "Listing for language 'de-DE' not found.", status: 'NOT_FOUND' } }, 404); + if (c.method === 'GET' && c.url.includes('/listings/en-US/')) return json({}); + if (c.method === 'DELETE' && c.url === `${API}/edits/edit-4`) return new Response(null, { status: 204 }); + return json({ error: { code: 400, message: `unexpected ${c.method} ${c.url}` } }, 400); + }); + const before = process.exitCode; + 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('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); + expect(noListing(new Error('GET /edits/1/listings/en-US/icon -> 500 backend'))).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');