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:
parent
e59f439101
commit
714352ec18
24 changed files with 570 additions and 103 deletions
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue