storebound/tests/flow-dry.test.ts

100 lines
6 KiB
TypeScript

// 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<string, string>) {
const f = load(name);
const pending: DryPending = { built: new Set(), uploaded: new Set() };
const out: Record<string, { how: string; argv?: string[]; owner?: string }> = {};
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<string, string>) => 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=<value>');
});
});