Play edits under a machine-wide per-package lock (parallel reads of one app no longer delete each other's edits; a read whose edit was deleted is redone once, writes are not); a store that failed to read is 'Couldn't read Google Play' and keeps its last good releases with 'couldn't refresh'; stores on different versions: neutral 'Current · Play x · App Store y' header and each lane its own state, 'Not on this store' only without a record; Play status decided from the public store page's live version (Live / In review / In review (live: x) / managed publishing / Rolling out / Halted / Draft; only a network failure says 'Couldn't check the store page', with the last known answer and its time); tests/play-edit-lock.test.ts, recorded page fixture, boot check auto-read cases (pitfall 58)

This commit is contained in:
CRTkafa 2026-10-14 00:14:32 +08:00
parent e59f439101
commit 714352ec18
24 changed files with 570 additions and 103 deletions

7
tests/fixtures/play-page-version.html vendored Normal file
View file

@ -0,0 +1,7 @@
<!doctype html><html lang="en-US"><head><title>Example Game - Apps on Google Play</title></head><body>
<!-- Recorded 2026-10-13 from the store page of a live app (version 0.8.1),
trimmed: only the AF_initDataCallback blocks' shape around "About this app"; image URLs, other blobs and ids removed. -->
<script nonce="n">AF_initDataCallback({key: 'ds:0', hash: '1', data:[[["en-US"]],null,[["Varies"]]], sideChannel: {}});</script>
<script nonce="n">AF_initDataCallback({key: 'ds:3', hash: '7', data:[[[["1.0"]]],[null,[["Recent changes"]]]], sideChannel: {}});</script>
<script nonce="n">AF_initDataCallback({key: 'ds:5', hash: '12', data:[[[[]]],[null,null,[["Example Game"],null,null,null,[null,[null,"You can request that data be deleted"]],1],null,null,null,[[["0.8.1"]],[[[36]],[[[24,"7.0"]]]]],null,null,null,[null,[null,"Example Game 0.8.1 (Alpha): bug fixes and stability improvements."]],[["Sep 30, 2026",[1790776977,376000000]]],null,null,[0,4,0,0],null,null,null,null,null,[null,[[],null,11]]],{"12":[["com.example.demo"]]}], sideChannel: {}});</script>
</body></html>

View file

@ -13,6 +13,7 @@ process.env.STOREBOUND_LIST_SETTLE_MS ??= '0';
const machine = mkdtempSync(join(tmpdir(), 'storebound-machine-'));
process.env.STOREBOUND_CREDENTIALS_DIR = join(machine, 'credentials');
process.env.STOREBOUND_REGISTRY = join(machine, 'projects.json');
process.env.STOREBOUND_LOCK_DIR = join(machine, 'locks');
// Commands run in-process set process.exitCode on purpose (a check with problems, a declined confirmation:
// DECLINED_EXIT in cli/lib/ctx.ts). Tests assert it where it matters; it must not leak into bun test's own exit code.

View file

@ -0,0 +1,135 @@
// Google Play keeps one open edit per app: a second edits.insert for the same package deletes the first, and a read in
// the first edit then answers 400 "This Edit has been deleted." (seen live when the desktop's auto-read ran several Play
// reads of one app at once). The fake below does exactly that; parallel reads of one package must all succeed, other
// packages must still run in parallel, and a read whose edit was replaced is redone once with a new edit.
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import { generateKeyPairSync } from 'node:crypto';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { playClient } from '../cli/play/client';
import { withMachineLock } from '../cli/lib/lock';
import { laneGap, storesDiffer, withLastGood } from '../cli/lib/train';
import type { AppStatus, StoreRelease } from '../cli/lib/desktop-protocol';
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 { privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
const KEY = JSON.stringify({ client_email: 'sb@example.com', private_key: privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(), token_uri: 'https://oauth2.googleapis.com/token' });
beforeEach(() => { process.env.PLAY_SERVICE_ACCOUNT = KEY; });
const json = (o: unknown, status = 200) => new Response(JSON.stringify(o), { status, headers: { 'content-type': 'application/json' } });
const DELETED = { error: { code: 400, message: 'This Edit has been deleted.', status: 'INVALID_ARGUMENT' } };
const wait = (ms: number) => new Promise(r => setTimeout(r, ms));
/** Fake androidpublisher: one open edit per package; an insert replaces it. Counts the edits open at once per package. */
function fakePlay(o: { delay?: number; replaceOnce?: string } = {}) {
const open = new Map<string, string>();
let n = 0;
const peak = { total: 0, now: 0 };
let replaced = false;
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
const url = String(input);
const method = init?.method ?? 'GET';
if (url.includes('oauth2')) return json({ access_token: 't', expires_in: 3600 });
const m = /applications\/([^/]+)\/edits(?:\/([^/:]+))?(\/.*)?$/.exec(url);
if (!m) return json({}, 404);
const [, pkg, id, rest] = m as unknown as [string, string, string | undefined, string | undefined];
if (method === 'POST' && !id) {
const e = `e${++n}`; open.set(pkg, e);
peak.now++; peak.total = Math.max(peak.total, peak.now);
return json({ id: e });
}
if (method === 'DELETE') { peak.now--; if (open.get(pkg) === id) open.delete(pkg); return json({}); }
await wait(o.delay ?? 15);
if (o.replaceOnce === pkg && !replaced) { replaced = true; open.set(pkg, `e${++n}`); } // e.g. a Console save
if (open.get(pkg) !== id) return json(DELETED, 400);
return json({ ok: rest ?? '' });
}) as typeof fetch;
return peak;
}
describe('Play edits: one open edit per package', () => {
test('the fake is faithful: two edits of one package without the lock kill the first', async () => {
fakePlay();
const ids: string[] = [];
const call = async (pkg: string) => { const r = await realFetchless(pkg); ids.push(r); };
async function realFetchless(pkg: string): Promise<string> {
const e = await (await fetch(`https://x/applications/${pkg}/edits`, { method: 'POST' })).json() as { id: string };
await wait(5);
const r = await fetch(`https://x/applications/${pkg}/edits/${e.id}/details`);
return String(r.status);
}
await Promise.all([call('com.example.a'), call('com.example.a')]);
expect(ids.sort()).toEqual(['200', '400']);
});
test('parallel reads of one package all succeed (serialized)', async () => {
const peak = fakePlay();
const play = [await playClient('/nonexistent', 'com.example.demo'), await playClient('/nonexistent', 'com.example.demo'), await playClient('/nonexistent', 'com.example.demo'), await playClient('/nonexistent', 'com.example.demo')];
const out = await Promise.all(play.map(p => p.read(async id => {
const a = await p.call<{ ok: string }>('GET', `${p.API}/edits/${id}/details`);
const b = await p.call<{ ok: string }>('GET', `${p.API}/edits/${id}/tracks`);
return [a.ok, b.ok];
})));
expect(out).toEqual(Array(4).fill(['/details', '/tracks']));
expect(peak.total).toBe(1);
});
test('different packages still read in parallel', async () => {
const peak = fakePlay({ delay: 40 });
const pkgs = ['com.example.a', 'com.example.b', 'com.example.c'];
const started = Date.now();
const out = await Promise.all(pkgs.map(async pkg => { const p = await playClient('/nonexistent', pkg); return p.read(id => p.call<{ ok: string }>('GET', `${p.API}/edits/${id}/details`)); }));
expect(out.map(o => o.ok)).toEqual(['/details', '/details', '/details']);
expect(peak.total).toBe(3);
expect(Date.now() - started).toBeLessThan(40 * 3);
});
test('a read whose edit was deleted is redone once with a new edit', async () => {
fakePlay({ replaceOnce: 'com.example.demo' });
const p = await playClient('/nonexistent', 'com.example.demo');
const seen: string[] = [];
const r = await p.read(async id => { seen.push(id); return p.call<{ ok: string }>('GET', `${p.API}/edits/${id}/details`); });
expect(r.ok).toBe('/details');
expect(seen.length).toBe(2);
expect(seen[0]).not.toBe(seen[1]);
});
test('a lock held by another holder times out with who holds it', async () => {
const dir = mkdtempSync(join(tmpdir(), 'sb-lock-'));
const prev = process.env.STOREBOUND_LOCK_DIR;
process.env.STOREBOUND_LOCK_DIR = dir;
try {
const beats: string[] = [];
let release!: () => void;
const first = withMachineLock('play-edit-x', 'play read x', () => new Promise<void>(r => { release = r; }));
await wait(10);
await expect(withMachineLock('play-edit-x', 'play read x', async () => 1, { timeoutMs: 60, pollMs: 10, heartbeatMs: 20, onWait: m => beats.push(m) })).rejects.toThrow(/timed out .*play read x/);
expect(beats.length).toBeGreaterThan(0);
release(); await first;
expect(await withMachineLock('play-edit-x', 'play read x', async () => 2, { timeoutMs: 100 })).toBe(2);
} finally { if (prev === undefined) delete process.env.STOREBOUND_LOCK_DIR; else process.env.STOREBOUND_LOCK_DIR = prev; }
});
});
describe('a store that could not be read is unknown, never empty', () => {
const r = (store: 'ios' | 'android', version: string, phase: StoreRelease['phase']): StoreRelease => ({ store, version, build: '1', state: 's', phase, fraction: null, track: store === 'android' ? 'production' : null });
test("laneGap: unreadable, the store's own other version, missing, none", () => {
expect(laneGap('android', [r('ios', '0.8.1', 'live')], '400 This Edit has been deleted.').kind).toBe('unreadable');
expect(laneGap('ios', [r('ios', '4.1.2', 'live'), r('android', '4.1.9', 'live')])).toMatchObject({ kind: 'other', now: { version: '4.1.2' } });
expect(laneGap('ios', [r('ios', '1.0', 'review')]).kind).toBe('missing');
expect(laneGap('ios', [r('android', '1.0', 'live')]).kind).toBe('none');
expect(storesDiffer([r('ios', '4.1.2', 'live'), r('android', '4.1.9', 'live')])).toEqual({ ios: '4.1.2', android: '4.1.9' });
expect(storesDiffer([r('ios', '4.1', 'live'), r('android', '4.1.0', 'live')])).toBeNull();
});
test('withLastGood keeps the last good Play releases with their time after a failed refresh', () => {
const prev = { release: { source: 'live', current: null, next: null, all: [r('ios', '0.8.1', 'live'), r('android', '0.8.1', 'live')] }, storeErrors: {} } as unknown as AppStatus;
const next = { release: { source: 'live', current: null, next: null, all: [r('ios', '0.8.1', 'live')] }, storeErrors: { android: 'GET /edits/x/details -> 400 This Edit has been deleted.' } } as unknown as AppStatus;
const m = withLastGood(prev, next, '2026-10-13T10:00:00Z');
expect(m.staleStores).toEqual({ android: '2026-10-13T10:00:00Z' });
expect(m.release.current).toMatchObject({ version: '0.8.1', android: { phase: 'live' } });
expect(withLastGood(undefined, next, 'x')).toBe(next);
});
});

View file

@ -4,7 +4,9 @@
import { afterEach, describe, expect, test } from 'bun:test';
import { APPLE_STATUS_FIRST_REVIEW, PLAY_STATUS_FIRST_200, PLAY_STATUS_FIRST_404 } from '../apps/desktop/src/dev/submit-fixtures';
import { trainsOf } from '../cli/release';
import { clearPlayPublicCache, pageVersion, playPublicPage } from '../cli/lib/play-public';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { clearPlayPublicCache, pageVersion, pageVersionInfo, playPublicPage } from '../cli/lib/play-public';
import { appleReleases, playReleaseLabel, playReleases, releaseTrains, sameVersion, versionKey } from '../cli/lib/portfolio';
import { laneOf, laneText, releasePlan, storeNow, versionHistory } from '../cli/lib/train';
@ -31,10 +33,10 @@ describe('version matching across the stores', () => {
expect(h).toHaveLength(1);
expect(h[0]!.version).toBe('1.0.0');
expect(h[0]!.ios).toMatchObject({ version: '1.0', build: '3', phase: 'review' });
expect(h[0]!.android).toMatchObject({ version: '1.0.0', build: '3', phase: 'sent', track: 'production' });
expect(h[0]!.android).toMatchObject({ version: '1.0.0', build: '3', phase: 'review', track: 'production' });
const t = releaseTrains(ios, android);
expect(t.current).toBeNull(); // nothing reaches users yet
expect(t.next).toMatchObject({ version: '1.0.0', ios: { version: '1.0', phase: 'review' }, android: { version: '1.0.0', phase: 'sent' } });
expect(t.next).toMatchObject({ version: '1.0.0', ios: { version: '1.0', phase: 'review' }, android: { version: '1.0.0', phase: 'review' } });
// release status --versions: the one row is the next train
const s = { release: { source: 'live' as const, ...t, all }, storeErrors: {}, summary: null } as never;
expect(trainsOf(s, true).map(x => [x.which, x.train.version])).toEqual([['next', '1.0.0']]);
@ -43,15 +45,15 @@ describe('version matching across the stores', () => {
expect(p.ios?.release?.version).toBe('1.0');
expect(p.android?.release?.version).toBe('1.0.0');
expect(p.done).toBe(false);
expect(p.android!.steps.map(x => x.text).join(' ')).toContain('sent to Google');
expect(p.android!.steps.map(x => x.text).join(' ')).toContain('1.0: In review');
});
});
describe('Google Play "Live" needs the public store page', () => {
test('completed production + page 404 = sent to Google, never live', () => {
test('completed production + page 404 = in review, never live', () => {
const [prod] = playReleases(PLAY_STATUS_FIRST_404);
expect(prod).toMatchObject({ phase: 'sent', state: 'completed', publicPage: 'notPublic', fraction: 1 });
expect(playReleaseLabel(prod!)).toBe('Sent to Google (in review or not yet published)');
expect(prod).toMatchObject({ phase: 'review', state: 'completed', publicPage: 'notPublic', fraction: 1 });
expect(playReleaseLabel(prod!)).toBe('In review');
const lane = laneOf('android', prod!);
expect(lane.at).toBe(2); // the review stage, not live / rollout
expect(lane.share).toBeNull();
@ -65,22 +67,46 @@ describe('Google Play "Live" needs the public store page', () => {
const t = releaseTrains(appleReleases(APPLE_STATUS_FIRST_REVIEW), playReleases(PLAY_STATUS_FIRST_200));
expect(t.current).toMatchObject({ version: '1.0.0', android: { phase: 'live' }, ios: { phase: 'review' } });
});
test('an update after the first release: page 200 is not proof for the new versionCode unless the page shows it', () => {
const update = { publicPage: { state: 'public', versionName: null }, tracks: [
{ track: 'production', releases: [{ name: '1.1.0', versionCodes: ['5'], status: 'completed' }] },
{ track: 'internal', releases: [{ name: '1.0.0', versionCodes: ['3'], status: 'completed' }] },
] };
const [prod] = playReleases(update);
expect(prod!.phase).toBe('released');
expect(playReleaseLabel(prod!)).toBe('Released to production (Google may still be reviewing)');
expect(laneText(laneOf('android', prod!))).toContain('Google may still be reviewing');
expect(playReleases({ ...update, publicPage: { state: 'public', versionName: '1.1' } })[0]!.phase).toBe('live');
const prodRel = (page: Record<string, unknown> | undefined, name = '1.1.0', status = 'completed', extra: Record<string, unknown> = {}) => playReleases({ ...extra, publicPage: page, tracks: [
{ track: 'production', releases: [{ name, versionCodes: ['5'], status, userFraction: status === 'completed' ? undefined : 0.2 }] },
{ track: 'internal', releases: [{ name: '1.0.0', versionCodes: ['3'], status: 'completed' }] },
] })[0]!;
test('rule: page version = the release version: Live', () => {
const r = prodRel({ state: 'public', versionName: '1.1' });
expect(r.phase).toBe('live');
expect(playReleaseLabel(r)).toBe('Live');
});
test('page not read = unknown, shown as unknown', () => {
const [prod] = playReleases({ tracks: PLAY_STATUS_FIRST_404.tracks });
expect(prod).toMatchObject({ phase: 'released', publicPage: 'unknown' });
expect(playReleaseLabel(prod!)).toContain('unknown');
expect(laneOf('android', prod!).tone).toBe('neutral');
test('rule: page 404, managed publishing off: In review; on: In review / ready to publish', () => {
expect(playReleaseLabel(prodRel({ state: 'notPublic', versionName: null }))).toBe('In review');
expect(playReleaseLabel(prodRel({ state: 'notPublic', versionName: null }, '1.1.0', 'completed', { managedPublishing: true }))).toBe('In review / ready to publish (managed publishing)');
});
test('rule: page live with an older version: In review (live: <older>)', () => {
const r = prodRel({ state: 'public', versionName: '1.0.0' });
expect(r).toMatchObject({ phase: 'review', liveVersion: '1.0.0' });
expect(playReleaseLabel(r)).toBe('In review (live: 1.0.0)');
expect(laneText(laneOf('android', r))).toBe('in review (live: 1.0.0)');
expect(storeNow([r], 'android').now).toBeNull();
});
test('rule: rollout, halted, draft', () => {
expect(playReleaseLabel(prodRel({ state: 'public', versionName: '1.0.0' }, '1.1.0', 'inProgress'))).toBe('Rolling out 20%');
expect(playReleaseLabel(prodRel({ state: 'public', versionName: '1.0.0' }, '1.1.0', 'halted'))).toBe('Halted');
expect(playReleaseLabel(prodRel({ state: 'public', versionName: '1.0.0' }, '1.1.0', 'draft'))).toBe('Draft');
});
test('rule: "Varies with device" = live (nothing to compare)', () => {
expect(prodRel({ state: 'public', versionName: null, varies: true }).phase).toBe('live');
});
test('rule: only a network failure is "Couldn\'t check the store page"; with a last known read it keeps that state and its time', () => {
const r = prodRel(undefined);
expect(r).toMatchObject({ phase: 'released', publicPage: 'unknown' });
expect(playReleaseLabel(r)).toBe("Couldn't check the store page");
expect(laneOf('android', r).tone).toBe('neutral');
const s = prodRel({ state: 'public', versionName: '1.1.0', stale: true, checkedAt: '2026-10-12T09:30:00Z' });
expect(s).toMatchObject({ phase: 'live', pageStaleAt: '2026-10-12T09:30:00Z' });
expect(playReleaseLabel(s)).toBe("Live (couldn't check the store page now; as of 2026-10-12 09:30 UTC)");
});
test('no uncertain wording is left', () => {
for (const r of [prodRel(undefined), prodRel({ state: 'notPublic' }), prodRel({ state: 'public', versionName: '1.0' })])
expect(`${playReleaseLabel(r)} ${laneText(laneOf('android', r))}`).not.toMatch(/not yet published|may still be reviewing|Sent to Google/);
});
});
@ -89,10 +115,19 @@ describe('public store page read', () => {
test('404 = not public yet, 200 = public, the url has no auth and asks for English', async () => {
const urls = answer(404);
expect((await playPublicPage('com.example.first404', { noCache: true })).state).toBe('notPublic');
expect(urls[0]).toBe('https://play.google.com/store/apps/details?id=com.example.first404&hl=en');
expect(urls[0]).toBe('https://play.google.com/store/apps/details?id=com.example.first404&hl=en&gl=US');
answer(200, '<script type="application/ld+json">{"softwareVersion":"1.0.0"}</script>');
expect(await playPublicPage('com.example.first200', { noCache: true })).toMatchObject({ state: 'public', http: 200, versionName: '1.0.0' });
});
test('a network failure returns the last known answer with its time', async () => {
const pkg = `com.example.lastknown${Date.now()}`;
answer(200, readFileSync(join(import.meta.dir, 'fixtures/play-page-version.html'), 'utf8'));
const ok = await playPublicPage(pkg, { noCache: true });
expect(ok).toMatchObject({ state: 'public', versionName: '0.8.1' });
globalThis.fetch = (async () => { throw new Error('getaddrinfo ENOTFOUND play.google.com'); }) as unknown as typeof fetch;
const r = await playPublicPage(pkg, { noCache: true });
expect(r).toMatchObject({ state: 'public', versionName: '0.8.1', stale: true, checkedAt: ok.checkedAt });
});
test('offline or a server error = unknown; unknown is never cached', async () => {
const off = `com.example.offline${Date.now()}`;
globalThis.fetch = (async () => { throw new Error('getaddrinfo ENOTFOUND play.google.com'); }) as unknown as typeof fetch;
@ -112,9 +147,12 @@ describe('public store page read', () => {
expect((await playPublicPage(pkg)).state).toBe('notPublic');
expect(urls).toHaveLength(1);
});
test('the page version is read only from structured data', () => {
test('the page version is read from the recorded live page shape (About this app in the ds blobs)', () => {
const html = readFileSync(join(import.meta.dir, 'fixtures/play-page-version.html'), 'utf8');
expect(pageVersionInfo(html)).toEqual({ versionName: '0.8.1', varies: false });
expect(pageVersionInfo(html.replace('[[["0.8.1"]]', '[[["Varies with device"]]'))).toEqual({ versionName: null, varies: true });
expect(pageVersion('{"softwareVersion": "2.3.1"}')).toBe('2.3.1');
expect(pageVersion('{"softwareVersion":"Varies with device"}')).toBeNull();
expect(pageVersionInfo('{"softwareVersion":"Varies with device"}')).toEqual({ versionName: null, varies: true });
expect(pageVersion('<div>Version 1.0</div>')).toBeNull();
});
});

View file

@ -24,7 +24,7 @@ const play = {
{ track: 'internal', releases: [{ name: '2.4.0', versionCodes: ['240'], status: 'completed' }] },
],
products: [{ productId: 'coins' }, { productId: 'no_ads' }, { sku: 'android_only' }],
publicPage: { state: 'public' },
publicPage: { state: 'public', versionName: '2.2.1' },
};
describe('portfolio (apps, release trains, catalog parity)', () => {

View file

@ -22,7 +22,7 @@ const play = {
{ track: 'production', releases: [{ name: '2.3.0', versionCodes: ['230'], status: 'inProgress', userFraction: 0.1 }, { name: '2.2.0', versionCodes: ['220'], status: 'completed' }] },
{ track: 'internal', releases: [{ name: '2.4.0', versionCodes: ['240'], status: 'completed' }] },
],
publicPage: { state: 'public' },
publicPage: { state: 'public', versionName: '2.2.0' },
};
const all: StoreRelease[] = [...appleReleases(apple), ...playReleases(play)];

View file

@ -254,7 +254,7 @@ describe('serve --stdio: session', () => {
const { call, sent } = await session(argv => argv[0] === 'inspect' ? { data: summary }
: argv[0] === 'check' ? { data: { ok: true, problems: [], warnings: ['w'] } }
: argv[0] === 'apple' ? { code: 1, out: 'error: ASC_KEY_ID and ASC_ISSUER_ID are not set' }
: argv[0] === 'play' ? { data: { tracks: [{ track: 'production', releases: [{ name: '1.2.0 (12)', versionCodes: ['12'], status: 'completed' }] }, { track: 'beta', releases: [{ name: '1.3.0', versionCodes: ['13'], status: 'completed' }] }], products: [{ productId: 'coins' }], publicPage: { state: 'public' } } } : {});
: argv[0] === 'play' ? { data: { tracks: [{ track: 'production', releases: [{ name: '1.2.0 (12)', versionCodes: ['12'], status: 'completed' }] }, { track: 'beta', releases: [{ name: '1.3.0', versionCodes: ['13'], status: 'completed' }] }], products: [{ productId: 'coins' }], publicPage: { state: 'public', versionName: '1.2.0' } } } : {});
const r = resOf(await call('portfolio.status', { projects: [DEMO, '/nope/game'], live: true })) as { apps: J[] };
expect(r.apps[0]).toMatchObject({ project: DEMO, app: { id: DEMO, name: 'Demo', platforms: { ios: { bundleId: 'com.example.demo' }, android: { packageName: 'com.example.demo' } } },
check: { ok: true, warnings: ['w'] }, storeErrors: { ios: 'ASC_KEY_ID and ASC_ISSUER_ID are not set' }, ok: false });