storebound/tests/release-train.test.ts

108 lines
6.8 KiB
TypeScript

// Release trains in the CLI (`storebound release status | plan`): the same lanes as the desktop's Releases tab
// (cli/lib/train.ts is the one implementation; apps/desktop/src/lib/model.ts re-exports it). Sanitized store shapes.
import { describe, expect, test } from 'bun:test';
import type { StoreRelease } from '../cli/lib/desktop-protocol';
import { appleReleases, playReleases, releaseTrains } from '../cli/lib/portfolio';
import { laneOf, laneText, releasePlan, trackText, trainLanes, versionHistory } from '../cli/lib/train';
import type { Finding } from '../cli/lib/validate-rules';
import * as desktop from '../apps/desktop/src/lib/model';
import { formatPlan, trainsOf } from '../cli/release';
const apple = {
versions: [
{ id: 'v3', attributes: { versionString: '2.4.0', appVersionState: 'PREPARE_FOR_SUBMISSION', platform: 'IOS' } },
{ id: 'v2', attributes: { versionString: '2.3.0', appStoreState: 'READY_FOR_SALE', platform: 'IOS' }, relationships: { build: { data: { id: 'b2' } } } },
{ id: 'v1', attributes: { versionString: '2.2.0', appStoreState: 'READY_FOR_SALE', platform: 'IOS' } },
],
builds: [{ id: 'b2', attributes: { version: '45' } }],
phased: { v2: { attributes: { phasedReleaseState: 'ACTIVE', currentDayNumber: 5 } } },
};
const play = {
tracks: [
{ 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', versionName: '2.2.0' },
};
const all: StoreRelease[] = [...appleReleases(apple), ...playReleases(play)];
describe('lanes (shared with the desktop)', () => {
test('the desktop re-exports the CLI implementation', () => {
expect(desktop.laneOf).toBe(laneOf);
expect(desktop.versionHistory).toBe(versionHistory);
expect(desktop.trainLanes).toBe(trainLanes);
});
test('stage words for the terminal', () => {
const [cur] = versionHistory(all).filter(t => t.version === '2.3.0');
expect(laneText(laneOf('ios', cur!.ios))).toBe('rollout 20%');
expect(laneText(laneOf('android', cur!.android))).toBe('rollout 10%');
const next = versionHistory(all).find(t => t.version === '2.4.0')!;
expect(laneText(laneOf('ios', next.ios))).toBe('build');
expect(laneText(laneOf('android', next.android))).toBe('testing');
expect(trackText(laneOf('android', next.android))).toBe('build > [testing] > review > live > rollout');
expect(laneText(laneOf('ios', null))).toBe('-');
expect(laneText(laneOf('ios', { ...next.ios!, phase: 'approved' }))).toBe('review (approved)');
expect(laneText(laneOf('ios', { ...next.ios!, phase: 'live' }))).toBe('live');
});
test('status rows: current + next, or every version', () => {
const s = { release: { source: 'live' as const, ...releaseTrains(appleReleases(apple), playReleases(play)), all } } as never;
expect(trainsOf(s, false).map(x => [x.which, x.train.version])).toEqual([['current', '2.3.0'], ['next', '2.4.0']]);
expect(trainsOf(s, true).map(x => [x.which, x.train.version])).toEqual([['next', '2.4.0'], ['current', '2.3.0'], ['other', '2.2.0']]);
});
});
describe('release plan', () => {
const findings: Finding[] = [
{ store: 'apple', level: 'blocker', code: 'build', msg: 'no build attached to 2.4.0', fix: 'storebound apple listing --version 2.4.0 --build 46' },
{ store: 'apple', level: 'warn', code: 'keywords', msg: 'keywords use 60 of 100 characters' },
{ store: 'play', level: 'blocker', code: 'images', msg: 'tr-TR: 1 phone screenshot (2 needed)' },
];
const base = { version: '2.4.0', platforms: { ios: true, android: true }, check: { problems: ['store/appstore.md: subtitle over 30 characters'], warnings: [] }, validate: findings, releases: all };
test('fix order: config, store blockers, the owner move, warnings last', () => {
const p = releasePlan(base);
expect(p.shared.map(s => s.kind)).toEqual(['config']);
expect(p.ios!.steps.map(s => [s.kind, s.code ?? ''])).toEqual([['blocker', 'build'], ['owner', ''], ['warning', 'keywords']]);
expect(p.ios!.steps[1]!.text).toContain('submit 2.4.0 for App Review (owner)');
expect(p.android!.steps.map(s => s.kind)).toEqual(['blocker', 'owner']);
expect(p.android!.steps[1]!.text).toContain('promote 2.4.0 from internal to production');
expect(p.done).toBe(false);
expect(p.unknown).toEqual([]);
const text = formatPlan('Puzzle', p);
expect(text.indexOf('CONFIG')).toBeLessThan(text.indexOf('BLOCKER'));
expect(text).toContain('App Store: build [[build] > testing > review > live > rollout]');
});
test('a version in rollout: no blockers, the owner raises or completes', () => {
const p = releasePlan({ ...base, version: '2.3.0', check: { problems: [], warnings: [] } });
expect(p.ios!.steps.map(s => s.kind)).toEqual(['owner']);
expect(p.ios!.steps[0]!.text).toContain('20%');
expect(p.android!.steps[0]!.fix).toContain('storebound play rollout');
});
test('live everywhere is done; a missing version says how to start it', () => {
expect(releasePlan({ ...base, version: '2.2.0', check: { problems: [], warnings: [] } }).done).toBe(true);
const p = releasePlan({ ...base, version: '3.0.0', validate: [] });
expect(p.ios!.steps[0]).toMatchObject({ kind: 'store' });
expect(p.ios!.steps[0]!.text).toContain('not on App Store yet');
expect(p.android!.steps[0]!.fix).toContain('play release <aab> --internal completed --dry');
});
test('review, approved, rejected, halted', () => {
const r = (phase: StoreRelease['phase'], extra: Partial<StoreRelease> = {}): StoreRelease[] => [{ store: 'ios', version: '5.0.0', build: '9', state: 'X', phase, fraction: null, track: null, ...extra }];
const one = (rs: StoreRelease[]) => releasePlan({ version: '5.0.0', platforms: { ios: true, android: false }, check: { problems: [], warnings: [] }, validate: findings, releases: rs }).ios!.steps;
expect(one(r('review')).map(s => s.kind)).toEqual(['wait']);
expect(one(r('approved'))[0]!.fix).toContain('--manual-release');
expect(one(r('rejected')).map(s => s.kind)).toEqual(['blocker', 'blocker', 'owner', 'warning']);
expect(one(r('halted', { fraction: 0.05 }))[0]!.text).toContain('halted at 5%');
});
test('unknown, never false: an unreadable store is not "not there"', () => {
const p = releasePlan({ ...base, releases: all, storeErrors: { android: 'PLAY_SERVICE_ACCOUNT is not set' }, validate: null, check: null });
expect(p.unknown).toEqual(['config check did not run', 'store readiness (validate) did not run', 'Google Play state not read: PLAY_SERVICE_ACCOUNT is not set']);
expect(p.android!.steps[0]!.text).toContain('could not be read');
expect(p.done).toBe(false);
});
});