108 lines
6.8 KiB
TypeScript
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);
|
|
});
|
|
});
|