// storebound flow: the YAML subset, step classification (owner steps never run, writes dry unless --apply), checks, // the plan, and real runs in a temp project (progress, capture, failure and --resume, the init templates). import { afterAll, describe, expect, test } from 'bun:test'; import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { classify, evalIf, expand, type Flow, flowsOfFile, parseParams, planStep, resumePoint, splitArgs, startIndex, substitute, toFlow, validateFlows } from '../cli/lib/flow'; import { parseYaml } from '../cli/lib/yaml'; import { FORBIDDEN_ARGS, TOOLS, toArgv, validateInput } from '../cli/mcp-tools'; const ROOT = join(import.meta.dir, '..'); const CLI = join(ROOT, 'cli', 'index.ts'); const tmp = mkdtempSync(join(tmpdir(), 'storebound-flow-')); 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: false, exists: (p: string) => p === 'store/whats-new' }; const flow = (name: string, steps: unknown[], extra: Record = {}): Flow => toFlow(name, 'test.yml', { steps, ...extra }).flow!; describe('yaml subset', () => { test('maps, lists of maps, lists at the key indentation, scalars, quotes, comments, inline lists, block scalars', () => { const v = parseYaml(`# a flow description: Ship it # trailing comment params: VERSION: "1.0.0" N: 3 ON: true steps: - id: a run: apple next-build capture: BUILD - id: b run: 'say "hi" # not a comment' continueOnError: false tags: [one, "two", 3] note: | line one line two fold: > a b `); expect(v).toEqual({ description: 'Ship it', params: { VERSION: '1.0.0', N: 3, ON: true }, steps: [{ id: 'a', run: 'apple next-build', capture: 'BUILD' }, { id: 'b', run: 'say "hi" # not a comment', continueOnError: false }], tags: ['one', 'two', 3], note: 'line one\nline two\n', fold: 'a b\n' }); expect(parseYaml('steps:\n - id: x\n run: check\n - plain\n')).toEqual({ steps: [{ id: 'x', run: 'check' }, 'plain'] }); }); test('errors name the line', () => { expect(() => parseYaml('a: 1\na: 2')).toThrow('line 2: "a" twice'); expect(() => parseYaml('a:\n\tb: 1')).toThrow('tabs'); expect(() => parseYaml('a: {b: 1}')).toThrow('inline maps'); expect(() => parseYaml('a: &x 1')).toThrow('anchors'); expect(() => parseYaml('a: "open')).toThrow('line 1'); }); }); describe('steps', () => { test('classification: reads, writes, waits, owner steps', () => { expect(classify(['validate'])).toBe('read'); expect(classify(['release', 'plan', '1.2.0'])).toBe('read'); expect(classify(['apple', 'next-build'])).toBe('read'); expect(classify(['apple', 'upload', '--no-wait'])).toBe('write'); expect(classify(['whats-new', 'apply'])).toBe('write'); expect(classify(['apple', 'testflight', 'wait', '--build', '7'])).toBe('wait'); for (const owner of [['apple', 'release', '--phased', 'on'], ['play', 'rollout', '--complete'], ['reviews', 'send'], ['prices', 'import'], ['apple', 'testflight', 'distribute', '--build', '7', '--group', 'x', '--submit-review'], ['apple', 'iap-submit'], ['apple', 'ppo', 'start'], ['play', 'release', '--production', 'completed']]) expect(classify(owner)).toBe('owner'); expect(classify(['prices', 'import', '--dry'])).toBe('read'); expect(splitArgs(`apple testflight what-to-test --text "New levels, fewer bugs" --build 7`)).toEqual(['apple', 'testflight', 'what-to-test', '--text', 'New levels, fewer bugs', '--build', '7']); }); test('plan per mode: writes get --dry unless --apply, owner steps never run, shell only with --apply, if conditions', () => { const vars = { VERSION: '1.2.0', BUILD: '42' }; const up = { id: 'upload', run: 'apple upload --no-wait' }; expect(planStep(up, 'steps', 'dry', vars, C)).toMatchObject({ kind: 'write', argv: ['apple', 'upload', '--no-wait', '--dry'], dryAdded: true }); expect(planStep(up, 'steps', 'apply', vars, C)).toMatchObject({ argv: ['apple', 'upload', '--no-wait'], dryAdded: false }); expect(planStep(up, 'steps', 'plan', vars, C).skip).toContain('plan only'); expect(planStep({ id: 'w', run: 'apple testflight wait --build ${BUILD}' }, 'steps', 'dry', vars, C).argv).toEqual(['apple', 'testflight', 'wait', '--build', '42', '--dry']); expect(planStep({ id: 'v', run: 'validate' }, 'steps', 'dry', vars, C)).toMatchObject({ argv: ['validate'], dryAdded: false }); for (const mode of ['plan', 'dry', 'apply'] as const) { expect(planStep({ id: 'r', run: 'apple release --phased on' }, 'steps', mode, vars, C)).toMatchObject({ kind: 'owner', skip: expect.stringContaining('your click') }); expect(planStep({ id: 's', owner: 'Submit ${VERSION}' }, 'steps', mode, vars, C)).toMatchObject({ kind: 'owner', owner: 'Submit 1.2.0' }); } expect(planStep({ id: 'sh', sh: 'npm run build' }, 'steps', 'dry', vars, C).skip).toContain('--apply'); expect(planStep({ id: 'sh', sh: 'npm run build' }, 'steps', 'apply', vars, C).skip).toBeUndefined(); expect(planStep({ id: 'p', run: 'play build', if: 'play' }, 'steps', 'apply', vars, C).skip).toBe('if play: false'); expect(planStep({ id: 'n', run: 'whats-new apply', if: 'exists store/whats-new' }, 'steps', 'apply', vars, C).skip).toBeUndefined(); }); test('conditions, variables, params', () => { expect(evalIf('apple && !play', C)).toBe(true); expect(evalIf('1.2 == 1.2', C)).toBe(true); expect(evalIf('a != a', C)).toBe(false); expect(evalIf('${UNSET}', C)).toBe(false); expect(evalIf('false', C)).toBe(false); expect(substitute('v${VERSION} ${env.HOME_X} ${NOPE}', { VERSION: '2' }, { HOME_X: 'h' })).toBe('v2 h ${NOPE}'); expect(parseParams(['VERSION=1.2.0', 'GROUP=a=b'])).toEqual({ VERSION: '1.2.0', GROUP: 'a=b' }); expect(() => parseParams(['version=1'])).toThrow('NAME=value'); }); test('validation: --yes refused, unknown variables, duplicate ids, cycles, unknown commands, storebound prefix', () => { const f = flow('bad', [{ id: 'a', run: 'apple upload --yes' }, { id: 'a', run: 'check ${NOPE}' }, { id: 'c', run: 'storebound check' }, { id: 'd', run: 'frobnicate now' }, { id: 'e', flow: 'loop' }]); const loop = flow('loop', [{ id: 'x', flow: 'bad' }]); const e = validateFlows([f, loop], { commands: new Set(['check', 'apple upload']) }).join('\n'); expect(e).toContain('--yes is not allowed'); expect(e).toContain('${NOPE} is not a param'); expect(e).toContain('id used twice'); expect(e).toContain('without "storebound"'); expect(e).toContain('"frobnicate now" is not a storebound command'); expect(e).toContain('cycle'); expect(toFlow('x', 'f.yml', { steps: [{ id: 'a', run: 'check', sh: 'ls' }], extra: 1 }).errors.join()).toContain('exactly one of run, sh, owner, flow'); expect(flowsOfFile('w.yml', { flows: { a: { steps: [{ id: 's', run: 'check' }] } } }, null).flows.map(x => x.name)).toEqual(['a']); }); test('sub-flows expand with prefixed ids; --from and --resume points', () => { const beta = flow('beta', [{ id: 'build', run: 'apple build' }, { id: 'upload', run: 'apple upload' }], { private: true, params: { GROUP: 'qa' } }); const rel = flow('release', [{ id: 'check', run: 'check' }, { id: 'beta', flow: 'beta', if: 'apple' }, { id: 'plan', run: 'release plan 1.0' }]); const all = new Map([['beta', beta], ['release', rel]]); const x = expand(rel, all); expect(x.steps.map(s => s.id)).toEqual(['check', 'beta.build', 'beta.upload', 'plan']); expect(x.steps[1]!.if).toBe('apple'); expect(x.params).toEqual({ GROUP: 'qa' }); expect(startIndex(x.steps, 'beta')).toBe(1); expect(() => startIndex(x.steps, 'nope')).toThrow('no such step'); expect(resumePoint(x.steps, { flow: 'release', mode: 'dry', startedAt: '', vars: {}, ok: false, results: [{ id: 'check', part: 'steps', kind: 'read', status: 'ok' }, { id: 'beta.build', part: 'steps', kind: 'write', status: 'failed' }] })).toBe('beta.build'); }); }); describe('flow command', () => { const app = join(tmp, 'app'); mkdirSync(join(app, 'store', 'workflows'), { recursive: true }); test('init writes release and beta flows per profile; they validate and plan', () => { for (const profile of ['app', 'game']) { const dir = join(tmp, `init-${profile}`); mkdirSync(dir); expect(sb(dir, 'init', '--profile', profile).exitCode).toBe(0); expect(existsSync(join(dir, 'store/workflows/release.yml'))).toBe(true); expect(existsSync(join(dir, 'store/workflows/beta.yml'))).toBe(true); const v = sb(dir, 'flow', 'validate'); expect(v.stderr.toString() + v.stdout.toString()).toContain('2 flow(s) in 2 file(s): ok'); const l = JSON.parse(sb(dir, 'flow', 'list', '--json').stdout.toString()); const steps = l.flows.find((f: { name: string }) => f.name === 'release').steps.map((s: { id: string }) => s.id); expect(steps.slice(0, 4)).toEqual(profile === 'app' ? ['next-build', 'build', 'upload', 'wait'] : ['shots', 'next-build', 'build', 'upload']); expect(steps.slice(-4)).toEqual(['validate', 'scan', 'plan', 'submit']); const p = sb(dir, 'flow', 'run', 'release', 'VERSION=2.0.0', '--dry', '--json'); expect(p.exitCode).toBe(0); const r = JSON.parse(p.stdout.toString()); expect(r.mode).toBe('plan'); expect(r.results.find((x: { id: string }) => x.id === 'submit').status).toBe('owner'); expect(r.results.find((x: { id: string }) => x.id === 'upload').command).toBe('storebound apple upload --no-wait --dry'); expect(r.results.find((x: { id: string }) => x.id === 'build').command).toContain('--version 2.0.0'); } }); test('a real run: progress, capture, dry writes, owner click, failure, --resume', () => { writeFileSync(join(app, 'store', 'workflows', 'ship.yml'), `description: test flow params: NAME: demo steps: - id: version run: version capture: V - id: marker sh: test -f ok.txt - id: after sh: echo "got \${V} \${NAME}" > out.txt - id: submit owner: "Submit \${NAME} \${V}" `); const dry = sb(app, 'flow', 'run', 'ship'); const out = dry.stdout.toString(); expect(dry.exitCode).toBe(0); expect(out).toContain('1/4 version: storebound version --dry (dry run)'); expect(out).toContain('V='); expect(out).toContain('shell step: runs with --apply'); expect(out).toContain('YOUR CLICK Submit demo'); expect(out).toContain('dry run: nothing changed'); expect(existsSync(join(app, 'out.txt'))).toBe(false); const fail = sb(app, 'flow', 'run', 'ship', '--apply', 'NAME=x'); expect(fail.exitCode).toBe(1); expect(fail.stdout.toString()).toContain('stopped at marker'); const state = JSON.parse(readFileSync(join(app, '.storebound/flows/ship.json'), 'utf8')); expect(state.results.map((r: { id: string; status: string }) => `${r.id}:${r.status}`)).toEqual(['version:ok', 'marker:failed']); writeFileSync(join(app, 'ok.txt'), ''); const res = sb(app, 'flow', 'run', 'ship', '--resume', '--apply', 'NAME=x'); expect(res.exitCode).toBe(0); expect(res.stdout.toString()).toContain('starting at marker (--resume)'); expect(res.stdout.toString()).not.toContain('version: storebound version'); const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version; expect(readFileSync(join(app, 'out.txt'), 'utf8').trim()).toBe(`got ${pkg} x`); expect(sb(app, 'flow', 'run', 'ship', '--resume').stderr.toString()).toContain('nothing to resume'); expect(sb(app, 'flow', 'run', 'ship', '--from', 'after', '--apply').exitCode).toBe(0); expect(sb(app, 'flow', 'run', 'nope').stderr.toString()).toContain('no flow "nope"'); }); test('MCP: flow_list and a dry flow_run that never applies', () => { const list = TOOLS.find(t => t.name === 'flow_list')!; const run = TOOLS.find(t => t.name === 'flow_run')!; expect(list.write).toBe(false); expect(run.write).toBe(false); expect(validateInput(run, { name: 'release', params: { VERSION: '1.2.0' }, from: 'build' })).toEqual([]); expect(validateInput(run, { name: '--apply' }).join()).toContain('name'); const argv = toArgv(run, { name: 'release', params: { VERSION: '1.2.0', bad: 'x' }, plan_only: true }).argv; expect(argv).toEqual(['flow', 'run', 'release', 'VERSION=1.2.0', '--dry', '--json']); expect(argv.some(a => FORBIDDEN_ARGS.includes(a) || a === '--apply')).toBe(false); }); });