storebound/tests/flow.test.ts

235 lines
15 KiB
TypeScript

// 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<string, unknown> = {}): 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');
// bare `testflight groups` only lists; `groups sync` creates (a listing-sounding command once created groups)
expect(classify(['apple', 'testflight', 'groups'])).toBe('read');
expect(classify(['apple', 'testflight', 'groups', 'sync'])).toBe('write');
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();
// flow run --apply --yes: the owner's approval reaches write steps only (never reads, waits or owner steps)
expect(planStep(up, 'steps', 'apply', vars, C, {}, true)).toMatchObject({ argv: ['apple', 'upload', '--no-wait', '--yes'], yesAdded: true });
expect(planStep(up, 'steps', 'dry', vars, C, {}, true).argv).toEqual(['apple', 'upload', '--no-wait', '--dry']);
expect(planStep({ id: 'v', run: 'validate' }, 'steps', 'apply', vars, C, {}, true).argv).toEqual(['validate']);
expect(planStep({ id: 'w', run: 'apple testflight wait --build 1' }, 'steps', 'apply', vars, C, {}, true).argv).not.toContain('--yes');
expect(planStep({ id: 'r', run: 'apple release' }, 'steps', 'apply', vars, C, {}, true)).toMatchObject({ kind: 'owner', skip: expect.stringContaining('your click') });
});
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 --build ${BUILD} --allow-existing --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('a declined confirmation (not a terminal, no --yes) stops the flow at that step, never "ok"', () => {
const d = join(tmp, 'declined');
mkdirSync(join(d, 'store', 'workflows'), { recursive: true });
writeFileSync(join(d, 'storebound.config.ts'), `export default { app: { name: 'Example', privacyPolicyUrl: 'https://example.com/p', supportUrl: 'https://example.com', website: 'https://example.com', contactEmail: 'hi@example.com' },
languages: [{ id: 'en', play: 'en-US' }], play: { packageName: 'com.example.game' } };\n`);
writeFileSync(join(d, 'app.aab'), 'not really a bundle');
writeFileSync(join(d, 'store', 'workflows', 'share.yml'), 'steps:\n - id: share\n run: play share --aab app.aab\n - id: after\n sh: touch after.txt\n');
const direct = sb(d, 'play', 'share', '--aab', 'app.aab');
expect(direct.exitCode).not.toBe(0);
expect(direct.stderr.toString()).toContain('declined: not a terminal and no --yes');
const r = sb(d, 'flow', 'run', 'share', '--apply');
const out = r.stdout.toString() + r.stderr.toString();
expect(r.exitCode).toBe(1);
expect(out).toContain('step 1 declined: not a terminal and no --yes');
expect(out).not.toContain('share: ok');
expect(existsSync(join(d, 'after.txt'))).toBe(false);
const state = JSON.parse(readFileSync(join(d, '.storebound/flows/share.json'), 'utf8'));
expect(state.results.map((x: { id: string; status: string }) => `${x.id}:${x.status}`)).toEqual(['share:declined']);
expect(sb(d, 'flow', 'run', 'share', '--yes').stderr.toString()).toContain('--yes only with --apply');
});
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);
});
});