// 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' }, }; 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 --internal completed --dry'); }); test('review, approved, rejected, halted', () => { const r = (phase: StoreRelease['phase'], extra: Partial = {}): 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); }); });