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:
parent
147cee9721
commit
720cb2a13a
6 changed files with 71 additions and 7 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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). |
|
||||
|
|
|
|||
|
|
@ -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`).
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue