storebound/tests/update-gate.test.ts

259 lines
16 KiB
TypeScript

// Updates are agent steps, the first release is the owner's (cli/lib/release-kind.ts, docs/bypass.md "Updates are agent
// steps"): the kind (update / first) is read from the store; an update of a live app ships with --yes in a pipeline
// (no owner gate), a first-ever release with --yes is exit 3 OWNER_STEP unless approved (--first-release, bypass mode),
// and a release that is on the store already is exit 7. Against the stateful App Store Connect and Google Play doubles.
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, setDefaultTimeout, test } from 'bun:test';
import { mkdtempSync, readFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { parseArgs } from '../cli/lib/args';
import { appleReleaseKind, firstReleaseAllowed, playReleaseKind } from '../cli/lib/release-kind';
import { setBypass } from '../cli/lib/bypass';
import { runShipStep } from '../cli/lib/ship-step';
import { run as playUpload } from '../cli/play/upload';
import { run as playRelease } from '../cli/play/release';
import { run as appleSubmit } from '../cli/apple/submit';
import { run as appleRelease } from '../cli/apple/release';
import { makeAab } from './build-fixtures';
import { ascEnv, BASE, playEnv, project, restore } from './helpers/store-mock';
import { installStores, type Stores, uninstallStores, writes } from './helpers/fake-stores';
const APP = '6400000101';
/** Runs a ship step in-process with stdout / stderr captured. */
async function ship(key: string, argv: string[], body: (a: ReturnType<typeof parseArgs>) => Promise<void>): Promise<{ code: number; out: string; err: string }> {
const out: string[] = [], err: string[] = [];
const o = process.stdout.write, e = process.stderr.write, cl = console.log, ce = console.error, cw = console.warn, ci = console.info;
process.stdout.write = ((s: string | Uint8Array) => { out.push(typeof s === 'string' ? s : new TextDecoder().decode(s)); return true; }) as typeof process.stdout.write;
process.stderr.write = ((s: string | Uint8Array) => { err.push(typeof s === 'string' ? s : new TextDecoder().decode(s)); return true; }) as typeof process.stderr.write;
console.log = (...a: unknown[]) => { out.push(a.map(String).join(' ') + '\n'); };
console.info = console.log;
console.error = (...a: unknown[]) => { err.push(a.map(String).join(' ') + '\n'); };
console.warn = console.error;
try {
const code = await runShipStep(key, argv, parseArgs, async a => {
const keep = console.log;
if (a.has('json')) console.log = (...x: unknown[]) => console.error(...x);
try { await body(a); } finally { console.log = keep; }
});
return { code, out: out.join(''), err: err.join('') };
} finally { process.stdout.write = o; process.stderr.write = e; console.log = cl; console.error = ce; console.warn = cw; console.info = ci; }
}
describe('the kind comes from the store (pure)', () => {
test('Play: a completed / inProgress / halted production release means published; a draft or nothing means first', () => {
expect(playReleaseKind([{ status: 'completed', versionCodes: ['41'] }]).kind).toBe('update');
expect(playReleaseKind([{ status: 'inProgress', versionCodes: ['42'] }, { status: 'completed', versionCodes: ['41'] }]).kind).toBe('update');
expect(playReleaseKind([{ status: 'halted', versionCodes: ['42'] }]).kind).toBe('update');
expect(playReleaseKind([{ status: 'draft', versionCodes: ['1'] }])).toMatchObject({ kind: 'first', why: expect.stringContaining('only draft') });
expect(playReleaseKind([]).kind).toBe('first');
expect(playReleaseKind(undefined).kind).toBe('first');
});
test('App Store: a version that is or was on sale means update; Prepare for Submission / in review / rejected means first', () => {
expect(appleReleaseKind([{ versionString: '1.0.0', state: 'READY_FOR_SALE' }, { versionString: '1.1.0', state: 'PREPARE_FOR_SUBMISSION' }]).kind).toBe('update');
expect(appleReleaseKind([{ state: 'REPLACED_WITH_NEW_VERSION' }]).kind).toBe('update');
expect(appleReleaseKind([{ state: 'READY_FOR_DISTRIBUTION' }]).kind).toBe('update');
expect(appleReleaseKind([{ state: 'PREPARE_FOR_SUBMISSION' }, { state: 'WAITING_FOR_REVIEW' }, { state: 'REJECTED' }]).kind).toBe('first');
expect(appleReleaseKind([]).kind).toBe('first');
});
test('the policy: updates always; a first release only with a prompt, the owner flag or bypass mode', () => {
const g = { yes: true, bypass: false, approved: false };
expect(firstReleaseAllowed({ ...g, kind: 'update' })).toBe(true);
expect(firstReleaseAllowed({ ...g, kind: 'first' })).toBe(false);
expect(firstReleaseAllowed({ ...g, kind: 'first', yes: false })).toBe(true); // the prompt is the owner's answer
expect(firstReleaseAllowed({ ...g, kind: 'first', approved: true })).toBe(true);
expect(firstReleaseAllowed({ ...g, kind: 'first', bypass: true })).toBe(true);
});
});
describe('Google Play production (fake store)', () => {
setDefaultTimeout(30_000);
let s: Stores;
beforeAll(() => { playEnv(); });
afterAll(restore);
beforeEach(() => { s = installStores(); });
afterEach(() => { uninstallStores(); delete process.env.STOREBOUND_HOME; });
const LISTING = '# Play\n\n## English\n<!-- play: en-US -->\n\n### Play short description\n```\nNotes.\n```\n\n### Play full description\n```\nNotes for everyone.\n```\n\n### What\'s new\n```\nFixes.\n```\n';
const published = (): void => { s.play.live.bundles = [41]; s.play.live.tracks = { production: { releases: [{ status: 'completed', versionCodes: ['41'] }] } }; };
const files = () => ({ 'store/listing.md': LISTING, 'build/android/app.aab': makeAab('com.example.notes', 42, '1.2.0') });
const upload = ['--json', '--yes', '--track', 'production'];
test('play upload --track production --yes on a published app: no owner gate, exit 0, exit 7 on the re-run', async () => {
published();
const p = project(BASE, files());
const first = await ship('play upload', upload, a => playUpload({ loaded: p.loaded, args: a }));
expect(first.err).not.toContain('error:');
expect(first.code).toBe(0);
expect(JSON.parse(first.out)).toMatchObject({ ok: true, step: 'upload', track: 'production', build: '42', state: 'completed' });
expect(s.play.committed.length).toBe(1);
const n = s.calls.length;
const again = await ship('play upload', upload, a => playUpload({ loaded: p.loaded, args: a }));
expect(again.code).toBe(7);
expect(writes(s.calls.slice(n))).toEqual([]);
});
test('play upload --track production --yes on a never-published app: exit 3 OWNER_STEP, nothing written', async () => {
const p = project(BASE, files());
const r = await ship('play upload', upload, a => playUpload({ loaded: p.loaded, args: a }));
expect(r.code).toBe(3);
const j = JSON.parse(r.out);
expect(j).toMatchObject({ ok: false, error: { code: 'OWNER_STEP' } });
expect(j.error.message).toContain('first release of this app');
expect(j.error.message).not.toContain('the owner sends it');
expect(j.error.consoleUrl).toContain('play.google.com/console');
expect(writes(s.calls)).toEqual([]);
expect(s.play.committed).toEqual([]);
});
test('the owner approved the first release (--first-release) or turned bypass mode on: the gate lets it through', async () => {
const p = project(BASE, files());
const ok = await ship('play upload', [...upload, '--first-release'], a => playUpload({ loaded: p.loaded, args: a }));
// Play takes only drafts on a never-published app: the gate passed, the store's own answer is the console step
expect(ok.err).toContain('first release (');
});
test('bypass mode (the owner opted in) lets a first release through the gate', async () => {
process.env.STOREBOUND_HOME = mkdtempSync(join(tmpdir(), 'sb-bypass-'));
setBypass(true);
const p2 = project(BASE, files());
const viaBypass = await ship('play upload', upload, a => playUpload({ loaded: p2.loaded, args: a }));
expect(viaBypass.code).not.toBe(3);
expect(viaBypass.err).toContain('bypass mode');
});
test('a plain run (no contract) of a first release is the usual declined exit 3', async () => {
const p = project(BASE, files());
const before = process.exitCode;
const err: string[] = [];
const ce = console.error;
console.error = (...a: unknown[]) => { err.push(a.map(String).join(' ')); };
try { await playUpload(p.ctx(['--yes', '--track', 'production'])); } finally { console.error = ce; }
expect(process.exitCode).toBe(3);
expect(err.join('\n')).toContain('declined: first release of this app');
process.exitCode = before;
expect(writes(s.calls)).toEqual([]);
});
test('play release <versionCode> --production completed --yes: an update goes through, then exit 7; a staged rollout is the same policy', async () => {
published();
s.play.live.bundles = [41, 42];
const p = project(BASE, { 'store/listing.md': LISTING });
const argv = ['--json', '--yes', '--version-code', '42', '--production', 'completed'];
const first = await ship('play release', argv, a => playRelease({ loaded: p.loaded, args: a }));
expect(first.err).not.toContain('error:');
expect(first.code).toBe(0);
expect(JSON.parse(first.out)).toMatchObject({ ok: true, step: 'release', track: 'production', build: '42', state: 'completed', releaseKind: 'update' });
const again = await ship('play release', argv, a => playRelease({ loaded: p.loaded, args: a }));
expect(again.code).toBe(7);
const staged = await ship('play release', ['--json', '--yes', '--version-code', '42', '--production', 'inProgress', '--fraction', '0.1'], a => playRelease({ loaded: p.loaded, args: a }));
expect(staged.code).toBe(0);
expect(JSON.parse(staged.out).tracks[0]).toMatchObject({ status: 'inProgress', userFraction: 0.1 });
});
test('play release --production completed --yes on a never-published app: exit 3 OWNER_STEP; --production draft is not gated', async () => {
s.play.live.bundles = [42];
const p = project(BASE, { 'store/listing.md': LISTING });
const refused = await ship('play release', ['--json', '--yes', '--version-code', '42', '--production', 'completed'], a => playRelease({ loaded: p.loaded, args: a }));
expect(refused.code).toBe(3);
expect(JSON.parse(refused.out)).toMatchObject({ ok: false, error: { code: 'OWNER_STEP' } });
expect(s.play.committed).toEqual([]);
const draft = await ship('play release', ['--json', '--yes', '--version-code', '42', '--production', 'draft'], a => playRelease({ loaded: p.loaded, args: a }));
expect(draft.code).toBe(0);
});
});
describe('App Store (fake store)', () => {
setDefaultTimeout(30_000);
let s: Stores;
beforeAll(() => { ascEnv(); });
afterAll(restore);
beforeEach(() => { s = installStores(); });
afterEach(() => { uninstallStores(); });
const version = (id: string, v: string, state: string): void => { s.asc.put({ type: 'appStoreVersions', id, attributes: { versionString: v, appVersionState: state, platform: 'IOS' }, rels: { app: APP } }); };
const build = (): void => { s.asc.put({ type: 'builds', id: 'b-8', attributes: { version: '8', processingState: 'VALID', usesNonExemptEncryption: false }, rels: { app: APP } }); };
test('apple submit --yes for an update of an app on sale: no owner gate, exit 0; the re-run is exit 7', async () => {
version('ver-0', '1.0.0', 'READY_FOR_SALE');
version('ver-1', '1.1.0', 'PREPARE_FOR_SUBMISSION');
build();
const p = project(BASE);
const argv = ['--json', '--yes', '--build', '8'];
const r = await ship('apple submit', argv, a => appleSubmit({ loaded: p.loaded, args: a }));
expect(r.err).not.toContain('error:');
expect(r.code).toBe(0);
expect(JSON.parse(r.out)).toMatchObject({ ok: true, step: 'submit', store: 'apple', build: '8', releaseKind: 'update' });
expect(s.asc.all('reviewSubmissions').map(x => x.attributes.state)).toEqual(['WAITING_FOR_REVIEW']);
const n = s.calls.length;
const again = await ship('apple submit', argv, a => appleSubmit({ loaded: p.loaded, args: a }));
expect(again.code).toBe(7);
expect(writes(s.calls.slice(n))).toEqual([]);
});
test('apple submit --yes for the first version of an app: exit 3 OWNER_STEP, nothing written; --first-release sends it', async () => {
version('ver-1', '1.0.0', 'PREPARE_FOR_SUBMISSION');
build();
const p = project(BASE);
const r = await ship('apple submit', ['--json', '--yes', '--build', '8'], a => appleSubmit({ loaded: p.loaded, args: a }));
expect(r.code).toBe(3);
const j = JSON.parse(r.out);
expect(j).toMatchObject({ ok: false, error: { code: 'OWNER_STEP' } });
expect(j.error.message).toContain('first release of this app');
expect(writes(s.calls)).toEqual([]);
expect(s.asc.all('reviewSubmissions')).toEqual([]);
const ok = await ship('apple submit', ['--json', '--yes', '--build', '8', '--first-release'], a => appleSubmit({ loaded: p.loaded, args: a }));
expect(ok.code).toBe(0);
expect(s.asc.all('reviewSubmissions').map(x => x.attributes.state)).toEqual(['WAITING_FOR_REVIEW']);
});
test('apple submit without --yes is a dry run that names the kind (no gate, exit 0)', async () => {
version('ver-0', '1.0.0', 'READY_FOR_SALE');
version('ver-1', '1.1.0', 'PREPARE_FOR_SUBMISSION');
build();
const p = project(BASE);
const r = await ship('apple submit', ['--json', '--build', '8'], a => appleSubmit({ loaded: p.loaded, args: a }));
expect(r.code).toBe(0);
expect(JSON.parse(r.out)).toMatchObject({ state: 'planned', releaseKind: 'update' });
expect(writes(s.calls)).toEqual([]);
});
test('apple release --manual-release --yes: an approved update is released (exit 0), released already is exit 7', async () => {
version('ver-0', '1.0.0', 'REPLACED_WITH_NEW_VERSION');
version('ver-1', '1.1.0', 'PENDING_DEVELOPER_RELEASE');
const p = project(BASE);
const argv = ['--json', '--yes', '--manual-release', '--version', '1.1.0'];
const r = await ship('apple release', argv, a => appleRelease({ loaded: p.loaded, args: a }));
expect(r.err).not.toContain('error:');
expect(r.code).toBe(0);
expect(JSON.parse(r.out)).toMatchObject({ ok: true, step: 'release', store: 'apple', version: '1.1.0', releaseKind: 'update' });
expect(writes(s.calls).map(c => `${c.method} ${c.path}`)).toEqual(['POST /v1/appStoreVersionReleaseRequests']);
// the store moved it on: the same command again has nothing to release
s.asc.all('appStoreVersions').find(v => v.id === 'ver-1')!.attributes.appVersionState = 'READY_FOR_SALE';
const n = s.calls.length;
const again = await ship('apple release', argv, a => appleRelease({ loaded: p.loaded, args: a }));
expect(again.code).toBe(7);
expect(JSON.parse(again.out)).toMatchObject({ ok: true, state: 'READY_FOR_SALE' });
expect(writes(s.calls.slice(n))).toEqual([]);
});
test('apple release --manual-release --yes for the first version ever: exit 3 OWNER_STEP, no release request', async () => {
version('ver-1', '1.0.0', 'PENDING_DEVELOPER_RELEASE');
const p = project(BASE);
const r = await ship('apple release', ['--json', '--yes', '--manual-release'], a => appleRelease({ loaded: p.loaded, args: a }));
expect(r.code).toBe(3);
expect(JSON.parse(r.out)).toMatchObject({ ok: false, error: { code: 'OWNER_STEP' } });
expect(writes(s.calls)).toEqual([]);
});
});
describe('the policy is written down', () => {
const read = (f: string): string => readFileSync(join(import.meta.dir, '..', f), 'utf8');
test('docs, skills and the agent rules say updates are agent steps and the first release is the owner\'s', () => {
for (const f of ['docs/bypass.md', 'docs/ship-step.md', 'skills/release-management/SKILL.md', 'skills/play-first-release/SKILL.md', 'docs/agents-core.md']) {
const t = read(f);
expect(t, f).toContain('--first-release');
expect(t, f).toContain('OWNER_STEP');
}
expect(read('skills/store-pitfalls/SKILL.md')).toContain('Updates are agent steps');
expect(read('CHANGELOG.md')).toContain('OWNER_STEP');
});
});