Guarded read-only run of the Play POST read paths: errorIssues:search in UTC, issues failure keeps the vitals, read-only listing / images dry runs

A live run where only the token exchange, edit insert / delete and Reporting :query were let through:
- health / validate: errorIssues:search answered 400 "Unsupported timezone" for America/Los_Angeles (it takes
  hour-aligned UTC only); the failure dropped the crash and ANR rates and the reason was cut off in the query string.
  Issues are asked in UTC; a failing issues search is a vitals-issues warning; Reporting errors leave out the query.
- play listing --dry / play images --dry wrote into the edit (PUT details and listings, image clears and uploads,
  :validate) before discarding it. Now one throwaway edit is read (details, listings, image sha256) and compared
  with the local copy, then deleted.
- play.read(): one helper for read-only edits (status, next-version-code), always deleted, warns if the delete fails;
  next-version-code counts distinct codes.
- tests on sanitized recorded answers (tests/play-post-reads.test.ts)
This commit is contained in:
CRTkafa 2026-10-11 08:41:55 +08:00
parent 5d485e01b5
commit b4d924b132
11 changed files with 314 additions and 29 deletions

View file

@ -2,6 +2,20 @@
## Unreleased ## Unreleased
- Fixes from a guarded read-only run of the Play paths that need POST / DELETE (edit insert and delete, Reporting API
`:query`, nothing else let through) against a live account:
- `health` / `validate`: Play's top issues search (`errorIssues:search`) answered 400 "Unsupported timezone": it
takes only hour-aligned UTC intervals, unlike the metric timelines (America/Los_Angeles days). It now asks in UTC.
A failing issues search no longer throws away the crash and ANR rates already read (a `vitals-issues` warning and
"not read" in the table instead of "vitals not readable"), and Reporting API errors leave out the query string so
the reason is not cut off in tables.
- `play listing --dry` and `play images --dry` are read-only now: one throwaway edit, the current details,
listings and images (sha256 of each uploaded file) compared with the local copy, the edit deleted. Before, the
dry runs wrote into the edit (PUT details and listings; for images every image set cleared and uploaded) and
called `:validate` before discarding it. The output says per language what would change.
- `play status`, `play next-version-code`: the read-only edit goes through one helper that always deletes it and
warns when the delete fails; `next-version-code` counts distinct version codes (one code on two tracks is one).
- Fixes from a second read-only run (GET only) of the desktop read paths against live accounts: `analytics snapshot` - Fixes from a second read-only run (GET only) of the desktop read paths against live accounts: `analytics snapshot`
no longer writes a snapshot when every store failed (no ONGOING report request yet, no `play.reportsBucket`); it no longer writes a snapshot when every store failed (no ONGOING report request yet, no `play.reportsBucket`); it
exits with both reasons, so the desktop dry run shows them. `analytics compare --json` adds `errors` (why a store has exits with both reasons, so the desktop dry run shows them. `analytics compare --json` adds `errors` (why a store has

View file

@ -32,9 +32,14 @@ export async function collectPlayHealth(root: string, pkg: string, days: number)
log('health', 'play: per device model (28 days) and top issues'); log('health', 'play: per device model (28 days) and top issues');
const crashDev = parseMetricRows(await g.json('POST', `${app}/crashRateMetricSet:query`, metricQuery(['userPerceivedCrashRate', 'distinctUsers'], end, 28, ['deviceModel']))); const crashDev = parseMetricRows(await g.json('POST', `${app}/crashRateMetricSet:query`, metricQuery(['userPerceivedCrashRate', 'distinctUsers'], end, 28, ['deviceModel'])));
const anrDev = parseMetricRows(await g.json('POST', `${app}/anrRateMetricSet:query`, metricQuery(['userPerceivedAnrRate', 'distinctUsers'], end, 28, ['deviceModel']))); const anrDev = parseMetricRows(await g.json('POST', `${app}/anrRateMetricSet:query`, metricQuery(['userPerceivedAnrRate', 'distinctUsers'], end, 28, ['deviceModel'])));
const issues = parseIssues(await g.json('GET', `${app}/errorIssues:search?${issuesQuery(end, days)}`)); // the top issues are extra: a failure there must not hide the crash and ANR rates already read
let issues: PlayHealth['issues'] = [], issuesError: string | undefined;
try { issues = parseIssues(await g.json('GET', `${app}/errorIssues:search?${issuesQuery(end, days)}`)); } catch (e) {
issuesError = (e as Error).message;
warn('health', `play: top issues not read: ${issuesError.slice(0, 200)}`);
}
const start = ymd(new Date(Date.UTC(end.year, end.month - 1, end.day)), days); const start = ymd(new Date(Date.UTC(end.year, end.month - 1, end.day)), days);
return { window: { start: ymdString(start), end: ymdString(end), days }, crash, anr, devices: deviceBreakdown(crashDev, anrDev), issues }; return { window: { start: ymdString(start), end: ymdString(end), days }, crash, anr, devices: deviceBreakdown(crashDev, anrDev), issues, ...(issuesError ? { issuesError } : {}) };
} }
export async function collectAppleHealth(root: string, bundleId: string, appIdHint: string | undefined, days: number): Promise<AppleHealth> { export async function collectAppleHealth(root: string, bundleId: string, appIdHint: string | undefined, days: number): Promise<AppleHealth> {

View file

@ -7,6 +7,8 @@
// https://developers.google.com/play/developer/reporting/reference/rest/v1beta1/vitals.anrrate // https://developers.google.com/play/developer/reporting/reference/rest/v1beta1/vitals.anrrate
// https://developers.google.com/play/developer/reporting/reference/rest/v1beta1/vitals.errors.issues/search // https://developers.google.com/play/developer/reporting/reference/rest/v1beta1/vitals.errors.issues/search
// DAILY timelines only take the America/Los_Angeles time zone, dates without hours; endTime is exclusive. // DAILY timelines only take the America/Los_Angeles time zone, dates without hours; endTime is exclusive.
// errorIssues:search is the other way round: its interval must be hour-aligned and in UTC (America/Los_Angeles
// answers 400 "Unsupported timezone").
// Bad behavior thresholds (user-perceived, 28-day average, checked daily): crash 1.09 %, ANR 0.47 % overall; 8 % on a // Bad behavior thresholds (user-perceived, 28-day average, checked daily): crash 1.09 %, ANR 0.47 % overall; 8 % on a
// single phone model. https://developer.android.com/topic/performance/vitals and Play Console Help answer 9844486 // single phone model. https://developer.android.com/topic/performance/vitals and Play Console Help answer 9844486
// https://support.google.com/googleplay/android-developer/answer/9844486 // https://support.google.com/googleplay/android-developer/answer/9844486
@ -68,14 +70,16 @@ export function parseMetricRows(resp: { rows?: ApiRow[] }): MetricRow[] {
})).sort((a, b) => a.date.localeCompare(b.date)); })).sort((a, b) => a.date.localeCompare(b.date));
} }
/** errorIssues:search query string: the top issues of a window, most reports first. */ /** errorIssues:search query string: the top issues of a window, most reports first. The interval is UTC midnights
* (the API takes only hour-aligned UTC times here, unlike the DAILY metric timelines). */
export function issuesQuery(end: Ymd, days: number, o: { type?: 'CRASH' | 'ANR'; pageSize?: number } = {}): string { export function issuesQuery(end: Ymd, days: number, o: { type?: 'CRASH' | 'ANR'; pageSize?: number } = {}): string {
const start = ymd(new Date(Date.UTC(end.year, end.month - 1, end.day)), days); const start = ymd(new Date(Date.UTC(end.year, end.month - 1, end.day)), days);
const p = new URLSearchParams(); const p = new URLSearchParams();
for (const [k, v] of Object.entries({ start, end })) { for (const [k, v] of Object.entries({ start, end })) {
const x = v as Ymd; const x = v as Ymd;
p.set(`interval.${k}Time.year`, String(x.year)); p.set(`interval.${k}Time.month`, String(x.month)); p.set(`interval.${k}Time.day`, String(x.day)); p.set(`interval.${k}Time.year`, String(x.year)); p.set(`interval.${k}Time.month`, String(x.month)); p.set(`interval.${k}Time.day`, String(x.day));
p.set(`interval.${k}Time.timeZone.id`, LA.id); p.set(`interval.${k}Time.hours`, '0');
p.set(`interval.${k}Time.timeZone.id`, 'UTC');
} }
if (o.type) p.set('filter', `errorIssueType = ${o.type}`); if (o.type) p.set('filter', `errorIssueType = ${o.type}`);
p.set('orderBy', 'errorReportCount desc'); p.set('orderBy', 'errorReportCount desc');
@ -97,6 +101,8 @@ export interface PlayHealth {
/** Worst device models by user-perceived crash / ANR rate over the window (optional). */ /** Worst device models by user-perceived crash / ANR rate over the window (optional). */
devices?: { model: string; crash?: number; anr?: number; users?: number }[]; devices?: { model: string; crash?: number; anr?: number; users?: number }[];
issues: Issue[]; issues: Issue[];
/** errorIssues:search failed: the rates above are still valid, only the issue list is missing. */
issuesError?: string;
} }
export interface AppleHealth { export interface AppleHealth {
@ -193,6 +199,7 @@ export function healthFindings(h: HealthSnapshot, o: { unreadable?: Level } = {}
f.push({ store: 'play', level: levelFor(crash, PLAY_THRESHOLDS.crash), code: 'vitals-crash', msg: `user-perceived crash rate (28 d) ${pct(crash)} (bad behavior threshold ${pct(PLAY_THRESHOLDS.crash)})`, fix }); f.push({ store: 'play', level: levelFor(crash, PLAY_THRESHOLDS.crash), code: 'vitals-crash', msg: `user-perceived crash rate (28 d) ${pct(crash)} (bad behavior threshold ${pct(PLAY_THRESHOLDS.crash)})`, fix });
f.push({ store: 'play', level: levelFor(anr, PLAY_THRESHOLDS.anr), code: 'vitals-anr', msg: `user-perceived ANR rate (28 d) ${pct(anr)} (bad behavior threshold ${pct(PLAY_THRESHOLDS.anr)})`, fix }); f.push({ store: 'play', level: levelFor(anr, PLAY_THRESHOLDS.anr), code: 'vitals-anr', msg: `user-perceived ANR rate (28 d) ${pct(anr)} (bad behavior threshold ${pct(PLAY_THRESHOLDS.anr)})`, fix });
} }
if (play.issuesError) f.push({ store: 'play', level: 'warn', code: 'vitals-issues', msg: `top crash / ANR issues not read: ${play.issuesError.slice(0, 200)}`, fix: 'Play Console > Android vitals > Crashes and ANRs' });
for (const d of play.devices ?? []) { for (const d of play.devices ?? []) {
const worst = Math.max(d.crash ?? 0, d.anr ?? 0); const worst = Math.max(d.crash ?? 0, d.anr ?? 0);
const lv = levelFor(worst, PLAY_THRESHOLDS.perDevice); const lv = levelFor(worst, PLAY_THRESHOLDS.perDevice);
@ -220,7 +227,7 @@ export function healthTable(h: HealthSnapshot): string {
rows.push(['play', `crash rate (user-perceived, ${p.window.days} d avg)`, pct(weightedAverage(p.crash, 'userPerceivedCrashRate')), '']); rows.push(['play', `crash rate (user-perceived, ${p.window.days} d avg)`, pct(weightedAverage(p.crash, 'userPerceivedCrashRate')), '']);
rows.push(['play', 'ANR rate (user-perceived, 28 d)', pct(lastValue(p.anr, 'userPerceivedAnrRate28dUserWeighted')), pct(PLAY_THRESHOLDS.anr)]); rows.push(['play', 'ANR rate (user-perceived, 28 d)', pct(lastValue(p.anr, 'userPerceivedAnrRate28dUserWeighted')), pct(PLAY_THRESHOLDS.anr)]);
rows.push(['play', `ANR rate (user-perceived, ${p.window.days} d avg)`, pct(weightedAverage(p.anr, 'userPerceivedAnrRate')), '']); rows.push(['play', `ANR rate (user-perceived, ${p.window.days} d avg)`, pct(weightedAverage(p.anr, 'userPerceivedAnrRate')), '']);
rows.push(['play', 'top issues', p.issues.length, '']); rows.push(['play', 'top issues', p.issuesError ? 'not read' : p.issues.length, '']);
} else if (h.play) rows.push(['play', 'error', h.play.error.slice(0, 80), '']); } else if (h.play) rows.push(['play', 'error', h.play.error.slice(0, 80), '']);
if (h.apple && !('error' in h.apple)) { if (h.apple && !('error' in h.apple)) {
for (const m of h.apple.metrics.slice(0, 12)) rows.push(['apple', `${m.metric} (${m.version})`, `${m.value} ${m.unit}`.trim(), m.goal ?? '']); for (const m of h.apple.metrics.slice(0, 12)) rows.push(['apple', `${m.metric} (${m.version})`, `${m.value} ${m.unit}`.trim(), m.goal ?? '']);

View file

@ -1,7 +1,7 @@
// Google Play Developer API (androidpublisher v3) client: JSON + media calls with retry, and the edit lifecycle. // Google Play Developer API (androidpublisher v3) client: JSON + media calls with retry, and the edit lifecycle.
import { playServiceAccount } from '../lib/credentials'; import { playServiceAccount } from '../lib/credentials';
import { tokenSource } from '../lib/google-auth'; import { tokenSource } from '../lib/google-auth';
import { log } from '../lib/log'; import { log, warn } from '../lib/log';
import { errorBody } from '../lib/http'; import { errorBody } from '../lib/http';
export type PlayClient = Awaited<ReturnType<typeof playClient>>; export type PlayClient = Awaited<ReturnType<typeof playClient>>;
@ -52,6 +52,16 @@ export async function playClient(root: string, packageName: string) {
return { return {
API, UPLOAD, packageName, call, API, UPLOAD, packageName, call,
/**
* Read-only edit: opens one, runs fn (GETs only), then deletes it, also on error. Never validated or committed.
* A failed delete is reported (Play drops an unused edit on its own after a while, but it should not linger).
*/
async read<T>(fn: (id: string) => Promise<T>): Promise<T> {
const e = await call<{ id: string }>('POST', `${API}/edits`, {});
try { return await fn(e.id); } finally {
await call('DELETE', `${API}/edits/${e.id}`).catch(err => warn('play', `read-only edit not deleted: ${(err as Error).message.slice(0, 200)}`));
}
},
/** /**
* Opens an edit, runs fn, then commits (or validates and discards when dry). Any Console save or other edit commit * Opens an edit, runs fn, then commits (or validates and discards when dry). Any Console save or other edit commit
* invalidates an open edit ("A change was made to the application outside of this Edit"); then the whole edit is * invalidates an open edit ("A change was made to the application outside of this Edit"); then the whole edit is

View file

@ -20,12 +20,13 @@ export function googleClient(root: string, scope: string) {
async json<T = unknown>(method: string, url: string, body?: unknown): Promise<T> { async json<T = unknown>(method: string, url: string, body?: unknown): Promise<T> {
const r = await send(method, url, body); const r = await send(method, url, body);
const t = await r.text(); const t = await r.text();
if (!r.ok) throw new Error(`${method} ${url.replace(/^https:\/\/[^/]+/, '')} -> ${r.status} ${errorBody(t, 800)}`); // path without the query: a long query string (errorIssues:search) pushed the API's reason out of tables
if (!r.ok) throw new Error(`${method} ${url.replace(/^https:\/\/[^/]+/, '').replace(/\?.*$/, '')} -> ${r.status} ${errorBody(t, 800)}`);
return (t ? JSON.parse(t) : {}) as T; return (t ? JSON.parse(t) : {}) as T;
}, },
async bytes(url: string): Promise<Uint8Array> { async bytes(url: string): Promise<Uint8Array> {
const r = await send('GET', url); const r = await send('GET', url);
if (!r.ok) throw new Error(`GET ${url.replace(/^https:\/\/[^/]+/, '')} -> ${r.status} ${errorBody(await r.text(), 300)}`); if (!r.ok) throw new Error(`GET ${url.replace(/^https:\/\/[^/]+/, '').replace(/\?.*$/, '')} -> ${r.status} ${errorBody(await r.text(), 300)}`);
return new Uint8Array(await r.arrayBuffer()); return new Uint8Array(await r.arrayBuffer());
}, },
}; };

View file

@ -7,6 +7,9 @@
// before uploading, so re-runs are idempotent. --variant <id> uploads <renders>/<id>/<lang>/... (an A/B variant from // before uploading, so re-runs are idempotent. --variant <id> uploads <renders>/<id>/<lang>/... (an A/B variant from
// shots.variants) to the main listing: Play store listing experiments themselves are set up in Play Console (no API). // shots.variants) to the main listing: Play store listing experiments themselves are set up in Play Console (no API).
// Uploads are slow: run in the background with a log file. // Uploads are slow: run in the background with a log file.
// --dry is read-only: one throwaway edit, the images Play has now (their sha256) compared with the local files per
// language and type, then the edit is deleted. Nothing is uploaded or cleared, not even inside that edit.
import { createHash } from 'node:crypto';
import { existsSync, readdirSync, readFileSync } from 'node:fs'; import { existsSync, readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
import type { Ctx } from '../lib/ctx'; import type { Ctx } from '../lib/ctx';
@ -31,6 +34,17 @@ export function numberedImages(dir: string): string[] {
return [...byNum.keys()].sort((a, b) => Number(a) - Number(b)).map(n => join(dir, byNum.get(n)!)); return [...byNum.keys()].sort((a, b) => Number(a) - Number(b)).map(n => join(dir, byNum.get(n)!));
} }
export interface ImageChange { type: string; change: 'same' | 'replace'; remote: number; local: number }
/** One image type: what Play has (sha256 of each uploaded file, in order) against the local files' sha256. Play keeps
* the uploaded bytes' hash, so equal lists mean an upload would change nothing. Pure. */
export function imageChange(type: string, remote: { sha256?: string }[], local: string[]): ImageChange {
const same = remote.length === local.length && remote.every((r, i) => r.sha256 === local[i]);
return { type, change: same ? 'same' : 'replace', remote: remote.length, local: local.length };
}
const sha256 = (f: string): string => createHash('sha256').update(readFileSync(f)).digest('hex');
export async function run(ctx: Ctx): Promise<void> { export async function run(ctx: Ctx): Promise<void> {
const { config, root, resolve } = ctx.loaded; const { config, root, resolve } = ctx.loaded;
const pkg = need(config.play?.packageName, 'play.packageName'); const pkg = need(config.play?.packageName, 'play.packageName');
@ -57,6 +71,23 @@ export async function run(ctx: Ctx): Promise<void> {
log('play', `${codes.length} languages, ${[...sets.values()][0]?.reduce((n, [, f]) => n + f.length, 0) ?? 0} images each`); log('play', `${codes.length} languages, ${[...sets.values()][0]?.reduce((n, [, f]) => n + f.length, 0) ?? 0} images each`);
const play = await playClient(root, pkg); const play = await playClient(root, pkg);
if (ctx.args.has('dry')) {
let changed = 0;
await play.read(async id => {
for (const [i, code] of codes.entries()) {
const out: ImageChange[] = [];
for (const [type, files] of sets.get(code)!) {
const remote = (await play.call<{ images?: { sha256?: string }[] }>('GET', `${play.API}/edits/${id}/listings/${code}/${type}`)).images ?? [];
out.push(imageChange(type, remote, files.map(sha256)));
}
const rep = out.filter(c => c.change === 'replace');
changed += rep.length;
log('play', `${step(i + 1, codes.length, code)}: ${rep.length ? 'would replace ' + rep.map(c => `${c.type} (${c.remote} -> ${c.local})`).join(', ') : 'unchanged'}`);
}
});
done('play', `dry run: ${changed ? changed + ' image set(s) would be replaced' : 'every image set is already on Play'}; read-only, nothing uploaded`);
return;
}
for (const [i, code] of codes.entries()) { for (const [i, code] of codes.entries()) {
const set = sets.get(code)!; const set = sets.get(code)!;
await play.edit(async id => { await play.edit(async id => {
@ -70,7 +101,7 @@ export async function run(ctx: Ctx): Promise<void> {
} }
})); }));
log('play', `${step(i + 1, codes.length, code)}: ${n} images`); log('play', `${step(i + 1, codes.length, code)}: ${n} images`);
}, { dry: ctx.args.has('dry') }); });
} }
done('play', 'images done'); done('play', 'images done');
} }

View file

@ -1,5 +1,7 @@
// Play store listing text + app details in one edit. // Play store listing text + app details in one edit.
// storebound play listing [--dry] [--only en-US,de-DE] // storebound play listing [--dry] [--only en-US,de-DE]
// --dry is read-only: a throwaway edit is opened, the current details and listings are read and compared with the file,
// and the edit is deleted. Nothing is written, not even into that edit.
// App details: default language, contact email, website (must be the domain that serves app-ads.txt). // App details: default language, contact email, website (must be the domain that serves app-ads.txt).
// Listings: title, short and full description of every `<!-- play: ... -->` section of the listing file, plus the promo // Listings: title, short and full description of every `<!-- play: ... -->` section of the listing file, plus the promo
// video (play.video, a YouTube URL) on every language. Play has no video upload: make the video with // video (play.video, a YouTube URL) on every language. Play has no video upload: make the video with
@ -24,6 +26,27 @@ export function listingBody(c: { lang: string; title: string; short: string; ful
return { language: c.lang, title: c.title, shortDescription: c.short, fullDescription: c.full, ...(video ? { video } : {}) }; return { language: c.lang, title: c.title, shortDescription: c.short, fullDescription: c.full, ...(video ? { video } : {}) };
} }
export interface PlayListing { language: string; title?: string; shortDescription?: string; fullDescription?: string; video?: string }
export interface ListingChange { lang: string; change: 'new' | 'update' | 'same'; fields: string[] }
const LISTING_FIELDS = ['title', 'shortDescription', 'fullDescription', 'video'] as const;
/** What a listing run would change, from the listings Play has now (GET edits/{id}/listings). An empty and a missing
* value are the same; a promo video on Play that play.video leaves out is cleared by the PUT. Pure. */
export function listingChanges(current: PlayListing[], wanted: Record<string, string>[]): ListingChange[] {
const now = new Map(current.map(l => [l.language, l]));
return wanted.map(w => {
const cur = now.get(w.language!);
if (!cur) return { lang: w.language!, change: 'new', fields: [] };
const fields = LISTING_FIELDS.filter(f => (cur[f] ?? '') !== (w[f] ?? '')).map(f => (f === 'video' && !w.video ? 'video (cleared)' : f));
return { lang: w.language!, change: fields.length ? 'update' : 'same', fields };
});
}
/** App details fields a run would change (defaultLanguage, contactEmail, contactWebsite). Pure. */
export function detailsChanges(current: Record<string, string | undefined>, wanted: Record<string, string | undefined>): string[] {
return Object.keys(wanted).filter(k => (current[k] ?? '') !== (wanted[k] ?? ''));
}
export async function run(ctx: Ctx): Promise<void> { export async function run(ctx: Ctx): Promise<void> {
const { config, root } = ctx.loaded; const { config, root } = ctx.loaded;
const pkg = need(config.play?.packageName, 'play.packageName'); const pkg = need(config.play?.packageName, 'play.packageName');
@ -32,6 +55,21 @@ export async function run(ctx: Ctx): Promise<void> {
if (!copy.some(c => c.lang === details.defaultLanguage) && !ctx.args.has('only')) throw new Error(`listing has no section for the default language ${details.defaultLanguage}`); if (!copy.some(c => c.lang === details.defaultLanguage) && !ctx.args.has('only')) throw new Error(`listing has no section for the default language ${details.defaultLanguage}`);
log('play', `${copy.length} listings: ${copy.map(c => c.lang).join(', ')}${config.play?.video ? ` · promo video ${config.play.video}` : ''}`); 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 play = await playClient(root, pkg);
if (ctx.args.has('dry')) {
await play.read(async id => {
const cur = await play.call<Record<string, string>>('GET', `${play.API}/edits/${id}/details`);
const dc = detailsChanges(cur, details);
log('play', `details: ${dc.length ? 'would change ' + dc.join(', ') : 'unchanged'}`);
const now = (await play.call<{ listings?: PlayListing[] }>('GET', `${play.API}/edits/${id}/listings`)).listings ?? [];
const changes = listingChanges(now, copy.map(c => listingBody(c, config.play?.video)));
for (const c of changes) log('play', `${c.lang}: ${c.change === 'update' ? 'would update ' + c.fields.join(', ') : c.change === 'new' ? 'would add (new language)' : 'unchanged'}`);
const other = now.filter(l => !copy.some(c => c.lang === l.language)).map(l => l.language);
if (other.length && !ctx.args.has('only')) log('play', `${other.length} languages on Play not in the listing file (left as they are): ${other.join(', ')}`);
const n = changes.filter(c => c.change !== 'same').length + (dc.length ? 1 : 0);
done('play', `dry run: ${n ? n + ' change(s)' : 'nothing to change'}; read-only, nothing written`);
});
return;
}
await play.edit(async id => { await play.edit(async id => {
await play.call('PUT', `${play.API}/edits/${id}/details`, details); await play.call('PUT', `${play.API}/edits/${id}/details`, details);
log('play', `details: ${details.defaultLanguage} · ${details.contactEmail} · ${details.contactWebsite}`); log('play', `details: ${details.defaultLanguage} · ${details.contactEmail} · ${details.contactWebsite}`);
@ -39,6 +77,6 @@ export async function run(ctx: Ctx): Promise<void> {
await play.call('PUT', `${play.API}/edits/${id}/listings/${c.lang}`, listingBody(c, config.play?.video)); await play.call('PUT', `${play.API}/edits/${id}/listings/${c.lang}`, listingBody(c, config.play?.video));
log('play', step(i + 1, copy.length, c.lang)); log('play', step(i + 1, copy.length, c.lang));
} }
}, { dry: ctx.args.has('dry') }); });
done('play', 'listing done'); done('play', 'listing done');
} }

View file

@ -11,18 +11,17 @@ import { playClient } from './client';
export async function run(ctx: Ctx): Promise<void> { export async function run(ctx: Ctx): Promise<void> {
const { config, root } = ctx.loaded; const { config, root } = ctx.loaded;
const play = await playClient(root, need(config.play?.packageName, 'play.packageName')); const play = await playClient(root, need(config.play?.packageName, 'play.packageName'));
const e = await play.call<{ id: string }>('POST', `${play.API}/edits`, {}); await play.read(async id => {
try { const bundles = (await play.call<{ bundles?: { versionCode: number }[] }>('GET', `${play.API}/edits/${id}/bundles`)).bundles ?? [];
const bundles = (await play.call<{ bundles?: { versionCode: number }[] }>('GET', `${play.API}/edits/${e.id}/bundles`)).bundles ?? []; const apks = (await play.call<{ apks?: { versionCode: number }[] }>('GET', `${play.API}/edits/${id}/apks`).catch(() => ({ apks: [] }))).apks ?? [];
const apks = (await play.call<{ apks?: { versionCode: number }[] }>('GET', `${play.API}/edits/${e.id}/apks`).catch(() => ({ apks: [] }))).apks ?? []; const tracks = (await play.call<{ tracks?: { releases?: { versionCodes?: string[] }[] }[] }>('GET', `${play.API}/edits/${id}/tracks`)).tracks ?? [];
const tracks = (await play.call<{ tracks?: { releases?: { versionCodes?: string[] }[] }[] }>('GET', `${play.API}/edits/${e.id}/tracks`)).tracks ?? [];
const codes = [...bundles.map(b => b.versionCode), ...apks.map(a => a.versionCode), ...tracks.flatMap(t => (t.releases ?? []).flatMap(r => r.versionCodes ?? []))]; const codes = [...bundles.map(b => b.versionCode), ...apks.map(a => a.versionCode), ...tracks.flatMap(t => (t.releases ?? []).flatMap(r => r.versionCodes ?? []))];
if (ctx.args.has('json')) printJson({ next: nextVersionCode(codes), seen: codes.length }); // the same code is in the bundle list and on every track it was released to: count distinct codes
const distinct = new Set(codes.map(String)).size;
if (ctx.args.has('json')) printJson({ next: nextVersionCode(codes), seen: distinct });
else { else {
console.error(`[play] ${codes.length} version codes seen; next versionCode:`); console.error(`[play] ${distinct} distinct version code(s) in bundles, APKs and tracks; next versionCode:`);
console.log(nextVersionCode(codes)); console.log(nextVersionCode(codes));
} }
} finally { });
await play.call('DELETE', `${play.API}/edits/${e.id}`).catch(() => {});
}
} }

View file

@ -11,11 +11,10 @@ interface Track { track: string; releases?: { name?: string; versionCodes?: stri
export async function run(ctx: Ctx): Promise<void> { export async function run(ctx: Ctx): Promise<void> {
const { config, root } = ctx.loaded; const { config, root } = ctx.loaded;
const play = await playClient(root, need(config.play?.packageName, 'play.packageName')); const play = await playClient(root, need(config.play?.packageName, 'play.packageName'));
const e = await play.call<{ id: string }>('POST', `${play.API}/edits`, {}); await play.read(async id => {
try { const tracks = (await play.call<{ tracks?: Track[] }>('GET', `${play.API}/edits/${id}/tracks`)).tracks ?? [];
const tracks = (await play.call<{ tracks?: Track[] }>('GET', `${play.API}/edits/${e.id}/tracks`)).tracks ?? []; const listings = (await play.call<{ listings?: { language: string }[] }>('GET', `${play.API}/edits/${id}/listings`)).listings ?? [];
const listings = (await play.call<{ listings?: { language: string }[] }>('GET', `${play.API}/edits/${e.id}/listings`)).listings ?? []; const details = await play.call<Record<string, string>>('GET', `${play.API}/edits/${id}/details`);
const details = await play.call<Record<string, string>>('GET', `${play.API}/edits/${e.id}/details`);
const products = await listOneTimeProducts(play); const products = await listOneTimeProducts(play);
if (ctx.args.has('json')) { printJson({ details, tracks, listings, products }); return; } if (ctx.args.has('json')) { printJson({ details, tracks, listings, products }); return; }
console.log(`${play.packageName}\n details: ${details.defaultLanguage} · ${details.contactEmail ?? '-'} · ${details.contactWebsite ?? '-'}`); console.log(`${play.packageName}\n details: ${details.defaultLanguage} · ${details.contactEmail ?? '-'} · ${details.contactWebsite ?? '-'}`);
@ -26,7 +25,5 @@ export async function run(ctx: Ctx): Promise<void> {
} }
console.log(` products (${products.length}):`); console.log(` products (${products.length}):`);
for (const p of products) console.log(` ${p.productId.padEnd(40)} ${(p.purchaseOptions ?? []).map(o => `${o.purchaseOptionId}:${o.state ?? '?'}`).join(' ')}`); for (const p of products) console.log(` ${p.productId.padEnd(40)} ${(p.purchaseOptions ?? []).map(o => `${o.purchaseOptionId}:${o.state ?? '?'}`).join(' ')}`);
} finally { });
await play.call('DELETE', `${play.API}/edits/${e.id}`).catch(() => {});
}
} }

View file

@ -30,7 +30,7 @@ describe('reporting api requests', () => {
expect(latestDailyEnd({})).toBeNull(); expect(latestDailyEnd({})).toBeNull();
const p = new URLSearchParams(issuesQuery({ year: 2026, month: 10, day: 8 }, 7, { type: 'CRASH' })); const p = new URLSearchParams(issuesQuery({ year: 2026, month: 10, day: 8 }, 7, { type: 'CRASH' }));
expect(p.get('interval.startTime.day')).toBe('1'); expect(p.get('interval.startTime.day')).toBe('1');
expect(p.get('interval.endTime.timeZone.id')).toBe('America/Los_Angeles'); expect(p.get('interval.endTime.timeZone.id')).toBe('UTC'); // errorIssues:search takes hour-aligned UTC only (400 for LA)
expect(p.get('filter')).toBe('errorIssueType = CRASH'); expect(p.get('filter')).toBe('errorIssueType = CRASH');
expect(p.get('orderBy')).toBe('errorReportCount desc'); expect(p.get('orderBy')).toBe('errorReportCount desc');
}); });

View file

@ -0,0 +1,183 @@
// Play read paths that need POST / DELETE (a throwaway edit, Reporting API :query), against what the real APIs answered
// in a guarded read-only run (2026-10; only edit insert / delete, :query and the token exchange were let through).
// Recorded shapes are sanitized: package names, ids and hashes are placeholders.
// - errorIssues:search answers 400 "Unsupported timezone" for America/Los_Angeles (the metric sets want exactly that
// zone; the issues search wants hour-aligned UTC). The failure dropped the crash and ANR rates already read and
// health / validate said "vitals not readable" with the error cut off inside the long query string.
// - `play listing --dry` and `play images --dry` wrote into the edit (PUT details / listings, image DELETE + uploads,
// :validate) before discarding it. Now they read and compare only.
import { afterEach, describe, expect, test } from 'bun:test';
import { createHash, generateKeyPairSync } from 'node:crypto';
import { mkdirSync, mkdtempSync, 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 { detailsChanges, listingChanges, run as listingRun } from '../cli/play/listing';
const PKG = 'com.example.game';
const API = `https://androidpublisher.googleapis.com/androidpublisher/v3/applications/${PKG}`;
const REPORTING = `https://playdeveloperreporting.googleapis.com/v1beta1/apps/${PKG}`;
// GET apps/{pkg}/crashRateMetricSet, as answered (an app with no vitals data yet)
const METRIC_SET = { name: `apps/${PKG}/crashRateMetricSet`, freshnessInfo: { freshnesses: [
{ aggregationPeriod: 'HOURLY', latestEndTime: { year: 2026, month: 10, day: 10, hours: 18, timeZone: { id: 'UTC' } } },
{ aggregationPeriod: 'DAILY', latestEndTime: { year: 2026, month: 10, day: 8, timeZone: { id: 'America/Los_Angeles' } } },
] } };
// errorIssues:search with an America/Los_Angeles interval, as sent (pretty-printed)
const UNSUPPORTED_TZ = `{
"error": {
"code": 400,
"message": "Unsupported timezone: id: \\t \\"America/Los_Angeles\\"\\n",
"status": "INVALID_ARGUMENT"
}
}
`;
// GET edits/{id}/listings: no "video" key when none is set
const LISTINGS = { kind: 'androidpublisher#listingsListResponse', listings: [
{ language: 'en-US', title: 'Example Game', fullDescription: 'Full text.', shortDescription: 'Short text.' },
{ language: 'de-DE', title: 'Example Game', fullDescription: 'Volltext.', shortDescription: 'Kurz.' },
] };
const image = (sha256: string) => ({ id: '1000000000000000000', url: 'https://lh3.googleusercontent.com/placeholder', sha1: '0'.repeat(40), sha256, aiGeneratedState: 'AI_GENERATED_STATE_UNSPECIFIED' });
const realFetch = globalThis.fetch;
const saved = process.env.PLAY_SERVICE_ACCOUNT;
afterEach(() => { globalThis.fetch = realFetch; if (saved === undefined) delete process.env.PLAY_SERVICE_ACCOUNT; else process.env.PLAY_SERVICE_ACCOUNT = saved; });
const json = (o: unknown, status = 200) => new Response(JSON.stringify(o), { status, headers: { 'content-type': 'application/json' } });
function playKey(): void {
const { privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
process.env.PLAY_SERVICE_ACCOUNT = JSON.stringify({ client_email: 'sb@example.com', private_key: privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(), token_uri: 'https://oauth2.googleapis.com/token' });
}
interface Call { method: string; url: string }
/** fetch double: the token exchange, then `route` for every API call; records method + URL. */
function fake(route: (c: Call) => Response): Call[] {
const calls: Call[] = [];
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
const c = { method: (init?.method ?? 'GET').toUpperCase(), url: String(input) };
if (c.url === 'https://oauth2.googleapis.com/token') return json({ access_token: 'tok', expires_in: 3600 });
calls.push(c);
return route(c);
}) as typeof fetch;
return calls;
}
/** Only what the guarded run let through: GETs, edit insert / delete, Reporting :query. */
const readOnly = (c: Call) => c.method === 'GET' || (c.method === 'POST' && (c.url === `${API}/edits` || /:query$/.test(c.url))) || (c.method === 'DELETE' && /\/edits\/[^/]+$/.test(c.url));
describe('Play vitals: errorIssues:search time zone', () => {
test('issues interval is hour-aligned UTC; the metric timelines stay on America/Los_Angeles days', () => {
const q = new URLSearchParams(issuesQuery({ year: 2026, month: 10, day: 8 }, 7));
expect(q.get('interval.startTime.timeZone.id')).toBe('UTC');
expect(q.get('interval.endTime.timeZone.id')).toBe('UTC');
expect([q.get('interval.startTime.hours'), q.get('interval.endTime.hours')]).toEqual(['0', '0']);
expect([q.get('interval.startTime.day'), q.get('interval.endTime.day')]).toEqual(['1', '8']);
expect(issuesQuery({ year: 2026, month: 10, day: 8 }, 7)).not.toContain('Los_Angeles');
expect(JSON.stringify(metricQuery(['crashRate'], { year: 2026, month: 10, day: 8 }, 7))).toContain('America/Los_Angeles');
});
const reporting = (issues: (url: string) => Response) => fake(c => {
if (c.url === `${REPORTING}/crashRateMetricSet`) return json(METRIC_SET);
if (c.method === 'POST' && /MetricSet:query$/.test(c.url)) return json({});
if (c.url.startsWith(`${REPORTING}/errorIssues:search?`)) return issues(c.url);
return json({ error: { code: 404 } }, 404);
});
test('collect reads rates and issues with the zones each endpoint takes (the real API rejects LA for issues)', async () => {
playKey();
const calls = reporting(url => (url.includes('America%2FLos_Angeles') ? new Response(UNSUPPORTED_TZ, { status: 400 }) : json({})));
const h = await collectPlayHealth(mkdtempSync(join(tmpdir(), 'sb-post-')), PKG, 7);
expect(h.issuesError).toBeUndefined();
expect(h.window).toEqual({ start: '2026-10-01', end: '2026-10-08', days: 7 });
expect(calls.every(readOnly)).toBe(true);
expect(calls.filter(c => c.method === 'POST').length).toBe(4);
expect(healthFindings({ date: '2026-10-11', play: h })).toEqual([expect.objectContaining({ level: 'ok', msg: 'no vitals data yet (too few users or no release)' })]);
});
test('a failing issues search keeps the rates; the reason is readable, not cut off in the query string', async () => {
playKey();
reporting(() => new Response(UNSUPPORTED_TZ, { status: 400 }));
const h = await collectPlayHealth(mkdtempSync(join(tmpdir(), 'sb-post-')), PKG, 7);
expect(h.crash).toEqual([]);
expect(h.issuesError).toStartWith(`GET /v1beta1/apps/${PKG}/errorIssues:search -> 400 {"error":{"code":400,"message":"Unsupported timezone`);
const f = healthFindings({ date: '2026-10-11', play: h });
expect(f.map(x => x.code)).toEqual(['vitals', 'vitals-issues']);
expect(f[1]).toMatchObject({ level: 'warn', msg: expect.stringContaining('Unsupported timezone') });
expect(healthTable({ date: '2026-10-11', play: h })).toMatch(/top issues\s+not read/);
});
});
describe('play listing --dry: read and compare only', () => {
test('changes per language; empty equals missing; a Play video the config leaves out is cleared', () => {
const wanted = [
{ language: 'en-US', title: 'Example Game', shortDescription: 'Short text.', fullDescription: 'Full text.' },
{ language: 'de-DE', title: 'Example Game', shortDescription: 'Neu.', fullDescription: 'Volltext.' },
{ language: 'fr-FR', title: 'Example Game', shortDescription: 'Court.', fullDescription: 'Texte.' },
];
expect(listingChanges(LISTINGS.listings, wanted)).toEqual([
{ lang: 'en-US', change: 'same', fields: [] },
{ lang: 'de-DE', change: 'update', fields: ['shortDescription'] },
{ lang: 'fr-FR', change: 'new', fields: [] },
]);
const withVideo = [{ ...LISTINGS.listings[0]!, video: 'https://www.youtube.com/watch?v=placeholder' }];
expect(listingChanges(withVideo, [wanted[0]!])[0]!.fields).toEqual(['video (cleared)']);
expect(detailsChanges({ defaultLanguage: 'en-US', contactEmail: 'a@example.com', contactWebsite: 'https://example.com' },
{ defaultLanguage: 'en-US', contactEmail: 'b@example.com', contactWebsite: 'https://example.com' })).toEqual(['contactEmail']);
});
test('the run opens an edit, reads details and listings, deletes the edit; no PUT, no :validate, no commit', async () => {
playKey();
const root = mkdtempSync(join(tmpdir(), 'sb-post-'));
mkdirSync(join(root, 'store'));
writeFileSync(join(root, 'store/listing.md'), '## English\n<!-- play: en-US -->\n\n### Play short description\n```\nShort text.\n```\n\n### Play full description\n```\nFull text.\n```\n');
const config = { app: { name: 'Example Game', contactEmail: 'support@example.com', website: 'https://example.com' }, languages: [{ id: 'en', play: 'en-US' }], 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-1', expiryTimeSeconds: '1760000000' });
if (c.url === `${API}/edits/edit-1/details`) return json({ defaultLanguage: 'en-US', contactWebsite: 'https://example.com', contactEmail: 'support@example.com' });
if (c.url === `${API}/edits/edit-1/listings`) return json(LISTINGS);
if (c.method === 'DELETE') return new Response(null, { status: 204 });
return json({ error: { code: 400, message: 'unexpected' } }, 400);
});
await listingRun({ loaded, args: parseArgs(['--dry']) });
expect(calls.map(c => `${c.method} ${c.url.replace(API, '')}`)).toEqual(['POST /edits', 'GET /edits/edit-1/details', 'GET /edits/edit-1/listings', 'DELETE /edits/edit-1']);
});
});
describe('play images --dry: hashes compared, nothing uploaded', () => {
const sha = (s: string) => createHash('sha256').update(s).digest('hex');
test('same files in the same order are unchanged; any other difference replaces the set', () => {
expect(imageChange('icon', [image(sha('a'))], [sha('a')])).toEqual({ type: 'icon', change: 'same', remote: 1, local: 1 });
expect(imageChange('phoneScreenshots', [image(sha('a')), image(sha('b'))], [sha('b'), sha('a')]).change).toBe('replace');
expect(imageChange('phoneScreenshots', [], [sha('a'), sha('b')])).toMatchObject({ change: 'replace', remote: 0, local: 2 });
});
test('the run reads every image type of a language in one edit and deletes it; no image DELETE, upload or :validate', async () => {
playKey();
const root = mkdtempSync(join(tmpdir(), 'sb-post-'));
const files: Record<string, string> = { 'store/icons/play-512.png': 'icon', 'store/renders/en/feature/1024x500.png': 'feature' };
for (const sub of ['play', 'tab7', 'tab10']) 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); }
mkdirSync(join(root, 'store'), { recursive: true });
writeFileSync(join(root, 'store/listing.md'), '## English\n<!-- play: en-US -->\n\n### Play short description\n```\nShort.\n```\n\n### Play full description\n```\nFull.\n```\n');
const config = { app: { name: 'Example Game' }, languages: [{ id: 'en', play: 'en-US' }], play: { packageName: PKG } } as unknown as ShipConfig;
const loaded: Loaded = { config, root, path: join(root, 'storebound.config.ts'), resolve: p => join(root, p) };
const remote: Record<string, string[]> = { icon: ['icon'], featureGraphic: ['old'], phoneScreenshots: ['play01', 'play02'], sevenInchScreenshots: ['tab701', 'tab702'], tenInchScreenshots: [] };
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 === 'DELETE' && c.url === `${API}/edits/edit-2`) return new Response(null, { status: 204 });
return json({ error: { code: 400, message: 'unexpected' } }, 400);
});
const lines: string[] = [];
const log = console.log;
console.log = (...a: unknown[]) => { lines.push(a.join(' ')); };
try { await imagesRun({ loaded, args: parseArgs(['--dry']) }); } finally { console.log = log; }
expect(calls.every(readOnly)).toBe(true);
expect(calls.filter(c => c.method === 'POST').length).toBe(1);
expect(calls.at(-1)).toEqual({ method: 'DELETE', url: `${API}/edits/edit-2` });
expect(lines.join('\n')).toContain('en-US: would replace featureGraphic (1 -> 1), tenInchScreenshots (0 -> 2)');
});
});