// A first release on both stores in the shapes they really answer: App Store "1.0" (build 3) waiting for review, Google // Play production "1.0.0" (versionCode 3) completed with userFraction 1 while the app is not public yet (the store page // answers 404). One row for 1.0 / 1.0.0, never "Live" on Play from the Developer API alone (pitfall 56). 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 { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { clearPlayPublicCache, pageVersion, pageVersionInfo, playPublicPage } from '../cli/lib/play-public'; import { appleReleases, PLAY_NOT_LIVE, playReleaseLabel, playReleases, releaseTrains, sameVersion, versionKey } from '../cli/lib/portfolio'; import { laneOf, laneText, releasePlan, storeNow, versionHistory } from '../cli/lib/train'; const realFetch = globalThis.fetch; afterEach(() => { globalThis.fetch = realFetch; delete process.env.STOREBOUND_PLAY_PUBLIC; clearPlayPublicCache(); }); describe('version matching across the stores', () => { test('trailing zero parts do not make a different version', () => { expect(versionKey('1.0')).toBe('1'); expect(versionKey('1.0.0')).toBe('1'); expect(versionKey('1')).toBe('1'); expect(versionKey('2.10.0')).toBe('2.10'); expect(versionKey('01.2.0')).toBe('1.2'); expect(versionKey('1.0-beta')).toBe('1.0-beta'); expect(sameVersion('1.0', '1.0.0')).toBe(true); expect(sameVersion('2.1', '2.10')).toBe(false); expect(sameVersion('1.0.1', '1.0')).toBe(false); expect(sameVersion(null, '1')).toBe(false); }); test('App Store 1.0 and Play 1.0.0 are one row and one train; each side keeps its own string', () => { const ios = appleReleases(APPLE_STATUS_FIRST_REVIEW), android = playReleases(PLAY_STATUS_FIRST_404); const all = [...ios, ...android]; const h = versionHistory(all); 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: '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: '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']]); // release plan 1.0 finds the Play 1.0.0 release too const p = releasePlan({ version: '1.0', platforms: { ios: true, android: true }, check: { problems: [], warnings: [] }, validate: [], releases: all }); 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('1.0: Not live yet'); }); }); describe('Google Play "Live" needs the public store page', () => { test('completed production + page 404 = in review, never live', () => { const [prod] = playReleases(PLAY_STATUS_FIRST_404); expect(prod).toMatchObject({ phase: 'review', state: 'completed', publicPage: 'notPublic', fraction: 1 }); expect(playReleaseLabel(prod!)).toBe(PLAY_NOT_LIVE); const lane = laneOf('android', prod!); expect(lane.at).toBe(2); // the review stage, not live / rollout expect(lane.share).toBeNull(); expect(laneText(lane)).not.toMatch(/^live/); expect(storeNow(playReleases(PLAY_STATUS_FIRST_404), 'android').now).toBeNull(); }); test('first release + page 200 = live', () => { const [prod] = playReleases(PLAY_STATUS_FIRST_200); expect(prod).toMatchObject({ phase: 'live', publicPage: 'public' }); expect(laneText(laneOf('android', prod!))).toBe('live'); 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' } }); }); const prodRel = (page: Record | undefined, name = '1.1.0', status = 'completed', extra: Record = {}) => 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('rule: page 404, managed publishing off: In review; on: In review / ready to publish', () => { expect(playReleaseLabel(prodRel({ state: 'notPublic', versionName: null }))).toBe(PLAY_NOT_LIVE); 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: )', () => { const r = prodRel({ state: 'public', versionName: '1.0.0' }); expect(r).toMatchObject({ phase: 'review', liveVersion: '1.0.0' }); expect(playReleaseLabel(r)).toBe(`${PLAY_NOT_LIVE} (live: 1.0.0)`); expect(laneText(laneOf('android', r))).toBe(`not live yet: in review or blocked before review (the API cannot tell; see Publishing overview) (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/); }); }); describe('public store page read', () => { const answer = (status: number, body = '') => { const urls: string[] = []; globalThis.fetch = (async (u: string | URL | Request) => { urls.push(String(u)); return new Response(body, { status }); }) as unknown as typeof fetch; return urls; }; 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&gl=US'); answer(200, ''); 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; const r = await playPublicPage(off, { noCache: true }); expect(r).toMatchObject({ state: 'unknown', http: null }); expect(r.error).toContain('ENOTFOUND'); answer(503); expect((await playPublicPage(off)).state).toBe('unknown'); answer(404); expect((await playPublicPage(off)).state).toBe('notPublic'); }); test('answers are cached for a few minutes', async () => { const urls = answer(404); const pkg = `com.example.cache${Date.now()}`; await playPublicPage(pkg); answer(200); expect((await playPublicPage(pkg)).state).toBe('notPublic'); expect(urls).toHaveLength(1); }); 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(pageVersionInfo('{"softwareVersion":"Varies with device"}')).toEqual({ versionName: null, varies: true }); expect(pageVersion('
Version 1.0
')).toBeNull(); }); });