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

This commit is contained in:
CRTkafa 2026-10-12 08:40:27 +08:00
parent 147cee9721
commit 720cb2a13a
6 changed files with 71 additions and 7 deletions

View file

@ -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 <type> 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

View file

@ -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]",

View file

@ -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 <renders>/<language id>/: 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<void> {
@ -116,7 +123,15 @@ export async function run(ctx: Ctx): Promise<void> {
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<string, LangImages>();
@ -128,6 +143,7 @@ export async function run(ctx: Ctx): Promise<void> {
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<void> {
(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<string, ImageChange[]>();
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;

View file

@ -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 <form>` 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 <form>` 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 <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). |

View file

@ -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`).

View file

@ -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<!-- play: ${code} -->\n${marker}\n### Play short description\n\`\`\`\nShort.\n\`\`\`\n\n### Play full description\n\`\`\`\nFull.\n\`\`\`\n\n`;
const files: Record<string, string> = { 'store/icons/play-512.png': 'icon',
'store/listing.md': sec('English', 'en-US') + sec('German', 'de-DE') + sec('French', 'fr-FR', '<!-- storebound-translation from=en-US status=draft -->\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');