storebound/tests/play-public-version-match.test.ts

120 lines
7.4 KiB
TypeScript

// 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 { clearPlayPublicCache, pageVersion, 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';
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: 'sent', 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' } });
// 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('sent to Google');
});
});
describe('Google Play "Live" needs the public store page', () => {
test('completed production + page 404 = sent to Google, 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)');
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' } });
});
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');
});
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');
});
});
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');
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('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 only from structured data', () => {
expect(pageVersion('{"softwareVersion": "2.3.1"}')).toBe('2.3.1');
expect(pageVersion('{"softwareVersion":"Varies with device"}')).toBeNull();
expect(pageVersion('<div>Version 1.0</div>')).toBeNull();
});
});