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:
parent
5d485e01b5
commit
b4d924b132
11 changed files with 314 additions and 29 deletions
14
CHANGELOG.md
14
CHANGELOG.md
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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> {
|
||||||
|
|
|
||||||
|
|
@ -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 ?? '']);
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(() => {});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(() => {});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
});
|
});
|
||||||
|
|
|
||||||
183
tests/play-post-reads.test.ts
Normal file
183
tests/play-post-reads.test.ts
Normal 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)');
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue