// storebound flow, dry runs: a step whose input (the IPA / AAB, the uploaded build) only a real earlier step makes is // planned, not failed; a real run is untouched. The beta template: required VERSION, GROUP empty = the config's internal group. import { afterAll, describe, expect, test } from 'bun:test'; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { type DryPending, evalIf, expand, flowsOfFile, missingParams, noteDry, planStep, plannedForDryInput, substitute, validateFlows } from '../cli/lib/flow'; import { parseYaml } from '../cli/lib/yaml'; import { readFileSync } from 'node:fs'; const ROOT = join(import.meta.dir, '..'); const CLI = join(ROOT, 'cli', 'index.ts'); const tmp = mkdtempSync(join(tmpdir(), 'storebound-flow-dry-')); afterAll(() => rmSync(tmp, { recursive: true, force: true })); const sb = (cwd: string, ...args: string[]) => Bun.spawnSync([process.execPath, CLI, ...args], { cwd, stdout: 'pipe', stderr: 'pipe', stdin: 'ignore' }); const C = { apple: true, play: true, exists: () => false }; const load = (name: string) => flowsOfFile(`templates/workflows/${name}.yml`, parseYaml(readFileSync(join(ROOT, 'templates', 'workflows', `${name}.yml`), 'utf8')), name).flows[0]!; /** What the runner decides per step in a mode: 'ran' (storebound is called), 'planned' (dry input missing) or 'skipped'. */ function simulate(name: string, mode: 'dry' | 'apply', vars: Record) { const f = load(name); const pending: DryPending = { built: new Set(), uploaded: new Set() }; const out: Record = {}; for (const s of expand(f, new Map([[f.name, f]]), 'steps').steps) { const p = planStep(s, 'steps', mode, vars, C); if (p.skip) { out[s.id] = { how: 'skipped', argv: p.argv, owner: p.owner }; continue; } const why = plannedForDryInput(p, mode, pending); if (why) { out[s.id] = { how: 'planned', argv: p.argv }; continue; } noteDry(p, pending); out[s.id] = { how: 'ran', argv: p.argv }; } return out; } describe('dry flows plan what a dry earlier step cannot produce', () => { test('beta: dry build then upload (and wait, distribute) are planned, reads and checks still run', () => { const r = simulate('beta', 'dry', { VERSION: '1.2.0', GROUP: '', BUILD: '7', 'config.testflight.internal': 'Team' }); expect(r['build']).toMatchObject({ how: 'ran', argv: ['apple', 'build', '--version', '1.2.0', '--build-number', '7', '--dry'] }); expect(r['upload']!.how).toBe('planned'); expect(r['wait']!.how).toBe('planned'); expect(r['distribute-internal']!.how).toBe('planned'); expect(r['play-build']!.how).toBe('ran'); expect(r['play-upload']!.how).toBe('planned'); expect(r['check']!.how).toBe('ran'); }); test('a real run is untouched: upload with no IPA still runs (and fails inside the command)', () => { const r = simulate('beta', 'apply', { VERSION: '1.2.0', GROUP: '', BUILD: '7', 'config.testflight.internal': 'Team' }); expect(r['upload']).toMatchObject({ how: 'ran', argv: ['apple', 'upload', '--no-wait', '--build', '7', '--allow-existing'] }); expect(r['play-upload']!.how).toBe('ran'); }); test('an upload of a named file, or without a dry build before it, is not planned away', () => { const pending: DryPending = { built: new Set(['apple']), uploaded: new Set() }; const up = planStep({ id: 'u', run: 'apple upload build/App.ipa' }, 'steps', 'dry', {}, C); expect(plannedForDryInput(up, 'dry', pending)).toBeNull(); const fresh: DryPending = { built: new Set(), uploaded: new Set() }; expect(plannedForDryInput(planStep({ id: 'u', run: 'apple upload --no-wait' }, 'steps', 'dry', {}, C), 'dry', fresh)).toBeNull(); }); test('release: the macOS pkg is tracked apart from the iOS IPA', () => { const r = simulate('release', 'dry', { VERSION: '2.0.0', MAC_TAURI: 'src-tauri', PLAY_TRACK: 'beta', BUILD: '9' }); expect(r['build']!.how).toBe('ran'); expect(r['upload']!.how).toBe('planned'); expect(r['mac-build']!.argv).toContain('--dry'); expect(r['mac-upload']!.how).toBe('planned'); expect(r['play-upload']!.argv).toEqual(['play', 'upload', '--track', 'beta', '--dry']); const off = simulate('release', 'dry', { VERSION: '2.0.0', MAC_TAURI: '', PLAY_TRACK: 'internal', BUILD: '9' }); expect(off['mac-build']!.how).toBe('skipped'); expect(off['mac-upload']!.how).toBe('skipped'); expect(off['promote']!.owner).toContain('internal'); }); }); describe('beta template defaults', () => { test('VERSION is required, GROUP defaults to empty and falls to the internal group of the config', () => { const f = load('beta'); expect(f.params['VERSION']).toBe('?'); expect(f.params['GROUP']).toBe(''); expect(missingParams({ ...f.params })).toEqual(['VERSION']); expect(missingParams({ ...f.params, VERSION: '1.0.0' })).toEqual([]); const steps = expand(f, new Map([[f.name, f]]), 'steps').steps; const by = (v: Record) => steps.filter(s => s.id.startsWith('distribute') || s.id === 'no-group') .filter(s => evalIf(substitute(s.if ?? '', v), C)).map(s => s.id); expect(by({ GROUP: '', 'config.testflight.internal': 'Team' })).toEqual(['distribute-internal']); expect(by({ GROUP: 'QA', 'config.testflight.internal': 'Team' })).toEqual(['distribute']); expect(by({ GROUP: '', 'config.testflight.internal': '' })).toEqual(['no-group']); expect(validateFlows([f, load('release')])).toEqual([]); }); test('flow run beta without VERSION fails early with a clear message', () => { const dir = join(tmp, 'req'); mkdirSync(join(dir, 'store', 'workflows'), { recursive: true }); writeFileSync(join(dir, 'store', 'workflows', 'beta.yml'), readFileSync(join(ROOT, 'templates', 'workflows', 'beta.yml'), 'utf8')); const p = sb(dir, 'flow', 'run', 'beta'); expect(p.exitCode).not.toBe(0); expect(p.stderr.toString() + p.stdout.toString()).toContain('beta needs VERSION='); }); });