235 lines
15 KiB
TypeScript
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);
|
|
});
|
|
});
|