345 lines
23 KiB
TypeScript
345 lines
23 KiB
TypeScript
// The ship-step contract (cli/lib/ship-step.ts, docs/ship-step.md): exit codes, the one final JSON line, the result
|
|
// file (also on failure), --progress json lines, idempotent re-uploads against the fake stores, --env-file with its
|
|
// permission check and redaction, --version-from shapes and the status read.
|
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, setDefaultTimeout, test } from 'bun:test';
|
|
import { chmodSync, existsSync, mkdtempSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { parseArgs } from '../cli/lib/args';
|
|
import { log, printJson } from '../cli/lib/log';
|
|
import { declined } from '../cli/lib/ctx';
|
|
import { classifyError, classifyExit, envFileProblem, parseEnvFile, parseVersionFile, redact, runShipStep, SHIP_EXIT, shipNoop, shipPhase, shipReport, ShipStepError, usageResult, versionArgv } from '../cli/lib/ship-step';
|
|
import { setConsoleIds } from '../cli/lib/console-links';
|
|
import { platformStatus } from '../cli/status';
|
|
import type { AppStatus } from '../cli/lib/desktop-protocol';
|
|
import { run as playUpload } from '../cli/play/upload';
|
|
import { run as appleUpload } from '../cli/apple/upload';
|
|
import { run as playRelease } from '../cli/play/release';
|
|
import { makeAab, makeIpa } from './build-fixtures';
|
|
import { ascEnv, BASE, playEnv, project, restore } from './helpers/store-mock';
|
|
import { installStores, type Stores, uninstallStores, writes } from './helpers/fake-stores';
|
|
|
|
const tmp = (): string => mkdtempSync(join(tmpdir(), 'sb-ship-'));
|
|
|
|
/** 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 => {
|
|
// the dispatcher's --json: console.log goes to stderr (log.ts withJsonOutput)
|
|
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; }
|
|
}
|
|
|
|
const KEYS = ['ok', 'step', 'store', 'track', 'version', 'build', 'state', 'url', 'warnings'];
|
|
|
|
describe('exit codes and error classification', () => {
|
|
afterEach(() => setConsoleIds({}));
|
|
test('the uniform codes', () => {
|
|
expect(SHIP_EXIT).toEqual({ ok: 0, failure: 1, usage: 2, owner: 3, refused: 4, noop: 7 });
|
|
});
|
|
test('owner steps carry a code and the console deep link', () => {
|
|
setConsoleIds({ appleAppId: '1000000001', playDeveloperId: '5550000000000000001', playAppId: '4970000000000000002' });
|
|
const agree = classifyError(new Error('GET /v1/apps -> 403 FORBIDDEN.REQUIRED_AGREEMENTS_MISSING_OR_EXPIRED'), 'apple');
|
|
expect(agree.exit).toBe(3);
|
|
expect(agree.error.code).toBe('AGREEMENT_PENDING');
|
|
expect(agree.error.consoleUrl).toBe('https://appstoreconnect.apple.com/business');
|
|
const sign = classifyError(new Error('GET /v1/apps -> 401 NOT_AUTHORIZED'), 'apple');
|
|
expect([sign.exit, sign.error.code, sign.error.consoleUrl]).toEqual([3, 'SIGN_IN_REQUIRED', 'https://appstoreconnect.apple.com/access/integrations/api']);
|
|
expect(classifyError(new Error('PLAY_SERVICE_ACCOUNT is not set (env, ...)'), 'play').error.code).toBe('SIGN_IN_REQUIRED');
|
|
const draft = classifyError(new Error('POST edits:commit -> 400 Only releases with status draft may be created on draft app.\n\nThis app has never been published'), 'play');
|
|
expect([draft.exit, draft.error.code]).toEqual([3, 'CONSOLE_STEP']);
|
|
expect(draft.error.consoleUrl).toContain('/app/4970000000000000002/publishing');
|
|
const ds = classifyError(new Error('the Data safety form is not answered'), 'play');
|
|
expect([ds.exit, ds.error.code]).toEqual([3, 'COMPLIANCE_ANSWER_MISSING']);
|
|
expect(ds.error.consoleUrl).toContain('data-privacy-security');
|
|
expect(classifyError(new Error('build 7 has no export compliance answer yet'), 'apple').error.consoleUrl).toContain('/distribution/info');
|
|
});
|
|
test('store refusals, usage, failures', () => {
|
|
expect(classifyError(new Error('PATCH /v1/reviewSubmissions/1 -> 409 STATE_ERROR'), 'apple')).toMatchObject({ exit: 4, error: { code: 'STORE_REFUSED' } });
|
|
expect(classifyError(new Error('upload rejected: ITMS-90725'), 'apple').exit).toBe(4);
|
|
expect(classifyError(new Error('--track: internal | alpha'), 'play')).toMatchObject({ exit: 2, error: { code: 'USAGE' } });
|
|
expect(classifyError(new Error('usage: storebound play upload <app.aab>'), 'play').exit).toBe(2);
|
|
expect(classifyError(new Error('build failed (exit 1)'), 'play')).toMatchObject({ exit: 1, error: { code: 'FAILED' } });
|
|
expect(classifyError(new ShipStepError('x', 4, 'BUILD_INVALID', 'https://example.com'), 'apple')).toEqual({ exit: 4, error: { code: 'BUILD_INVALID', message: 'x', consoleUrl: 'https://example.com' } });
|
|
});
|
|
test('legacy exit codes: a declined confirmation is exit 3, blockers are classified', () => {
|
|
expect(classifyExit(3, { declined: 'not a terminal and no --yes', store: 'play' })).toEqual({ exit: 3, error: { code: 'CONFIRMATION_REQUIRED', message: 'not a terminal and no --yes' } });
|
|
expect(classifyExit(1, { store: 'apple', detail: { blockers: ['PATCH x -> 409 ENTITY_ERROR'] } }).exit).toBe(4);
|
|
expect(classifyExit(1, { store: 'apple', detail: { blockers: ['screenshots missing'] } }).error?.code).toBe('BLOCKERS');
|
|
expect(classifyExit(7, { store: 'apple' }).exit).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('the final JSON line, the result file and progress', () => {
|
|
test('--json: exactly one JSON line on stdout, logs on stderr', async () => {
|
|
const r = await ship('play build', ['--json'], async () => {
|
|
log('build', 'a log line');
|
|
printJson({ aab: '/x/app-42.aab', packageName: 'com.example.notes', versionCode: 42, versionName: '1.2.0' });
|
|
});
|
|
expect(r.code).toBe(0);
|
|
const lines = r.out.trim().split('\n');
|
|
expect(lines).toHaveLength(1);
|
|
const j = JSON.parse(lines[0]!);
|
|
for (const k of KEYS) expect(j).toHaveProperty(k);
|
|
expect(j).toMatchObject({ ok: true, step: 'build', store: 'play', version: '1.2.0', build: '42', state: 'built', artifact: '/x/app-42.aab', warnings: [] });
|
|
expect(j.error).toBeUndefined();
|
|
expect(r.err).toContain('a log line');
|
|
});
|
|
test('--result-file: written atomically on failure too, with the error', async () => {
|
|
const d = tmp();
|
|
const file = join(d, 'result.json');
|
|
const r = await ship('apple submit', ['--result-file', file], async () => {
|
|
shipReport({ version: '1.2.0', build: '7' });
|
|
throw new Error('PATCH /v1/reviewSubmissions/x -> 409 ENTITY_ERROR.ATTRIBUTE.INVALID');
|
|
});
|
|
expect(r.code).toBe(4);
|
|
expect(r.out).toBe(''); // no --json: nothing on stdout but the logs
|
|
const j = JSON.parse(readFileSync(file, 'utf8'));
|
|
expect(j).toMatchObject({ ok: false, step: 'submit', store: 'apple', version: '1.2.0', build: '7', state: 'failed', error: { code: 'STORE_REFUSED' } });
|
|
expect(j.error.message).toContain('409');
|
|
expect(readdirSync(d)).toEqual(['result.json']); // no temp file left
|
|
});
|
|
test('a declined confirmation: exit 3 CONFIRMATION_REQUIRED', async () => {
|
|
const r = await ship('play upload', ['--json'], async () => { declined('not a terminal and no --yes (re-run with --yes to confirm)'); });
|
|
expect(r.code).toBe(3);
|
|
expect(JSON.parse(r.out)).toMatchObject({ ok: false, error: { code: 'CONFIRMATION_REQUIRED' } });
|
|
});
|
|
test('process.exit inside a command still gives the line and the file', async () => {
|
|
const file = join(tmp(), 'r.json');
|
|
const r = await ship('play release', ['--json', '--result-file', file], async () => { process.exit(2); });
|
|
expect(r.code).toBe(2);
|
|
expect(JSON.parse(r.out).error.code).toBe('USAGE');
|
|
expect(JSON.parse(readFileSync(file, 'utf8')).error.code).toBe('USAGE');
|
|
});
|
|
test('nothing to do: exit 7, ok true', async () => {
|
|
const r = await ship('play upload', ['--json'], async () => { shipNoop({ track: 'production', build: '42', state: 'completed' }); });
|
|
expect(r.code).toBe(7);
|
|
expect(JSON.parse(r.out)).toMatchObject({ ok: true, track: 'production', build: '42', state: 'completed' });
|
|
});
|
|
test('--progress json: one JSON line per phase on stderr', async () => {
|
|
const r = await ship('apple upload', ['--progress', 'json'], async () => { shipPhase('upload', 'parts', 40); shipPhase('processing', 'waiting'); });
|
|
const lines = r.err.split('\n').filter(l => l.startsWith('{')).map(l => JSON.parse(l) as { phase: string; pct?: number; message: string });
|
|
expect(lines.map(l => l.phase)).toEqual(['start', 'upload', 'processing', 'done']);
|
|
expect(lines[1]).toEqual({ phase: 'upload', pct: 40, message: 'parts' });
|
|
});
|
|
test('--progress with another value: usage', async () => {
|
|
const r = await ship('apple upload', ['--progress', 'xml', '--json'], async () => {});
|
|
expect(r.code).toBe(2);
|
|
});
|
|
test('an unknown flag (stopped by the dispatcher): the usage line and file', () => {
|
|
const file = join(tmp(), 'u.json');
|
|
const out: string[] = [];
|
|
const o = process.stdout.write;
|
|
process.stdout.write = ((s: string) => { out.push(s); return true; }) as typeof process.stdout.write;
|
|
try { usageResult('play upload', ['--json', '--result-file', file, '--trak', 'x'], 'unknown option for storebound play upload: --trak'); } finally { process.stdout.write = o; }
|
|
expect(JSON.parse(out.join(''))).toMatchObject({ ok: false, step: 'upload', error: { code: 'USAGE' } });
|
|
expect(existsSync(file)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('--env-file and redaction', () => {
|
|
test('KEY=VALUE parsing', () => {
|
|
expect(parseEnvFile('# c\nexport A=1\nB="x y\\nz"\nC=\'q\'\nD=plain # note\n')).toEqual({ A: '1', B: 'x y\nz', C: 'q', D: 'plain' });
|
|
expect(() => parseEnvFile('not a pair')).toThrow('KEY=VALUE');
|
|
});
|
|
test.skipIf(process.platform === 'win32')('POSIX: group / world readable files are refused', () => {
|
|
const f = join(tmp(), '.env');
|
|
writeFileSync(f, 'A=1\n');
|
|
chmodSync(f, 0o644);
|
|
expect(envFileProblem(f)).toContain('chmod 600');
|
|
chmodSync(f, 0o600);
|
|
expect(envFileProblem(f)).toBeNull();
|
|
});
|
|
test('Windows: no mode check; a missing file is refused', () => {
|
|
expect(envFileProblem(join(tmp(), 'none'), 'win32')).toContain('no such file');
|
|
const f = join(tmp(), '.env');
|
|
writeFileSync(f, 'A=1\n');
|
|
expect(envFileProblem(f, 'win32')).toBeNull();
|
|
});
|
|
test('values are loaded before the command and scrubbed from stdout, stderr and the result file', async () => {
|
|
const d = tmp();
|
|
const env = join(d, 'ship.env');
|
|
writeFileSync(env, 'SB_TEST_UPLOAD_TOKEN=s3cr3t-value-123456\n', { mode: 0o600 });
|
|
const file = join(d, 'r.json');
|
|
try {
|
|
const r = await ship('play upload', ['--json', '--env-file', env, '--result-file', file], async () => {
|
|
expect(process.env.SB_TEST_UPLOAD_TOKEN).toBe('s3cr3t-value-123456');
|
|
console.error(`token is ${process.env.SB_TEST_UPLOAD_TOKEN}`);
|
|
throw new Error(`refused with s3cr3t-value-123456`);
|
|
});
|
|
expect(r.err).not.toContain('s3cr3t-value-123456');
|
|
expect(r.err).toContain('[redacted]');
|
|
expect(r.out).not.toContain('s3cr3t-value-123456');
|
|
expect(readFileSync(file, 'utf8')).not.toContain('s3cr3t-value-123456');
|
|
expect(JSON.parse(r.out).error.message).toContain('[redacted]');
|
|
} finally { delete process.env.SB_TEST_UPLOAD_TOKEN; }
|
|
});
|
|
test('redact: multi-line keys line by line, JSON-escaped forms', () => {
|
|
const pem = '-----BEGIN PRIVATE KEY-----\nMIGHAgEAMBMGByqGSM49AgEGCCqGSM49\n-----END PRIVATE KEY-----';
|
|
const s = [pem, JSON.stringify(pem).slice(1, -1)];
|
|
expect(redact(`a ${pem} b`, s)).toBe('a [redacted] b');
|
|
expect(redact(JSON.stringify({ k: pem }), s)).not.toContain('MIGHAgEA');
|
|
});
|
|
});
|
|
|
|
describe('--version-from', () => {
|
|
test('the three shapes', () => {
|
|
expect(parseVersionFile('{"version":"1.2.0","build":42}')).toMatchObject({ version: '1.2.0', build: '42' });
|
|
expect(parseVersionFile('{"versionName":"1.2.0","versionCode":42}')).toMatchObject({ version: '1.2.0', build: '42' });
|
|
expect(parseVersionFile('{"marketing":"1.2","build":"1.2.42"}')).toMatchObject({ version: '1.2', build: '1.2.42' });
|
|
expect(() => parseVersionFile('{"name":"x"}')).toThrow('no version');
|
|
expect(() => parseVersionFile('{"version":"one"}')).toThrow('not x.y.z');
|
|
expect(() => parseVersionFile('nope')).toThrow('not JSON');
|
|
});
|
|
test('the flags each command gets; a hand flag must agree', () => {
|
|
const v = { version: '1.2.0', build: '42', shape: '' };
|
|
expect(versionArgv('play build', ['--json'], parseArgs(['--json']), v)).toEqual(['--json', '--version-name', '1.2.0', '--version-code', '42']);
|
|
expect(versionArgv('apple build', [], parseArgs([]), v)).toEqual(['--version', '1.2.0', '--build-number', '42']);
|
|
expect(versionArgv('apple upload', [], parseArgs([]), v)).toEqual(['--build', '42']);
|
|
expect(versionArgv('apple submit', ['--yes'], parseArgs(['--yes']), v)).toEqual(['--yes', '--version', '1.2.0', '--build', '42']);
|
|
expect(versionArgv('play upload', [], parseArgs([]), v)).toEqual(['--version-code', '42']);
|
|
expect(versionArgv('play release', ['a.aab'], parseArgs(['a.aab']), v)).toEqual(['a.aab']);
|
|
expect(versionArgv('apple upload', ['--build', '42'], parseArgs(['--build', '42']), v)).toEqual(['--build', '42']);
|
|
expect(() => versionArgv('apple upload', ['--build', '41'], parseArgs(['--build', '41']), v)).toThrow('disagrees');
|
|
expect(() => versionArgv('play build', [], parseArgs([]), { ...v, build: '1.2.42' })).toThrow('versionCode');
|
|
});
|
|
test('a run reads the file and the result carries the version', async () => {
|
|
const f = join(tmp(), 'version.json');
|
|
writeFileSync(f, '{"versionName":"2.0.0","versionCode":77}');
|
|
let seen = '';
|
|
const r = await ship('play upload', ['--json', '--version-from', f], async a => { seen = a.str('version-code', ''); });
|
|
expect(seen).toBe('77');
|
|
expect(JSON.parse(r.out)).toMatchObject({ version: '2.0.0', build: '77' });
|
|
});
|
|
});
|
|
|
|
describe('idempotent re-upload (fake stores)', () => {
|
|
setDefaultTimeout(30_000);
|
|
let s: Stores;
|
|
beforeAll(() => { ascEnv(); playEnv(); });
|
|
afterAll(restore);
|
|
beforeEach(() => { s = installStores(); });
|
|
afterEach(() => { uninstallStores(); });
|
|
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';
|
|
|
|
test('play upload: first run uploads (exit 0), the re-run is exit 7 with the existing release, no second upload', async () => {
|
|
s.play.live.bundles = [41];
|
|
s.play.live.tracks = { production: { releases: [{ status: 'completed', versionCodes: ['41'] }] } }; // published: an update
|
|
const p = project(BASE, { 'store/listing.md': LISTING, 'build/android/app.aab': makeAab('com.example.notes', 42, '1.2.0') });
|
|
const first = await ship('play upload', ['--json', '--yes', '--track', 'production'], a => playUpload({ loaded: p.loaded, args: a }));
|
|
expect(first.code).toBe(0);
|
|
expect(JSON.parse(first.out)).toMatchObject({ ok: true, step: 'upload', store: 'play', track: 'production', version: '1.2.0', build: '42', state: 'completed' });
|
|
const n = s.calls.length;
|
|
const again = await ship('play upload', ['--json', '--yes', '--track', 'production'], a => playUpload({ loaded: p.loaded, args: a }));
|
|
expect(again.code).toBe(7);
|
|
expect(JSON.parse(again.out)).toMatchObject({ ok: true, track: 'production', build: '42', state: 'completed' });
|
|
expect(writes(s.calls.slice(n))).toEqual([]);
|
|
});
|
|
|
|
test('play upload: the versionCode is in the bundle list but on another track: exit 7 with a warning, nothing sent', async () => {
|
|
s.play.live.bundles = [42];
|
|
s.play.live.tracks = { internal: { releases: [{ status: 'completed', versionCodes: ['42'] }] } } as never;
|
|
const p = project(BASE, { 'store/listing.md': LISTING, 'build/android/app.aab': makeAab('com.example.notes', 42) });
|
|
const r = await ship('play upload', ['--json', '--yes', '--track', 'production'], a => playUpload({ loaded: p.loaded, args: a }));
|
|
expect(r.code).toBe(7);
|
|
const j = JSON.parse(r.out);
|
|
expect(j).toMatchObject({ track: 'internal', state: 'completed', build: '42' });
|
|
expect(j.warnings[0]).toContain('not on production');
|
|
expect(writes(s.calls)).toEqual([]);
|
|
});
|
|
|
|
test('play release: the same release on the track again is exit 7, no commit', async () => {
|
|
s.play.live.bundles = [42];
|
|
const p = project(BASE, { 'store/listing.md': LISTING });
|
|
const argv = ['--json', '--yes', '--version-code', '42', '--internal', 'completed'];
|
|
const first = await ship('play release', argv, a => playRelease({ loaded: p.loaded, args: a }));
|
|
expect(first.code).toBe(0);
|
|
expect(JSON.parse(first.out)).toMatchObject({ ok: true, step: 'release', track: 'internal', build: '42', state: 'completed', versionCode: 42 });
|
|
const n = s.play.committed.length;
|
|
const again = await ship('play release', argv, a => playRelease({ loaded: p.loaded, args: a }));
|
|
expect(again.code).toBe(7);
|
|
expect(s.play.committed.length).toBe(n);
|
|
});
|
|
|
|
test('play upload without the contract keeps its old exit 1 for a used versionCode', async () => {
|
|
s.play.live.bundles = [42];
|
|
const p = project(BASE, { 'store/listing.md': LISTING, 'build/android/app.aab': makeAab('com.example.notes', 42) });
|
|
await expect(playUpload(p.ctx(['--yes']))).rejects.toThrow('already on Google Play');
|
|
});
|
|
|
|
test('apple upload: a build number App Store Connect has is exit 7 with its state, no build upload', async () => {
|
|
s.asc.put({ type: 'builds', id: 'b-7', attributes: { version: '7', processingState: 'VALID' }, rels: { app: '6400000101' } });
|
|
const plist = '<?xml version="1.0" encoding="UTF-8"?><plist version="1.0"><dict><key>CFBundleIdentifier</key><string>com.example.notes</string><key>CFBundleShortVersionString</key><string>1.2.0</string><key>CFBundleVersion</key><string>7</string></dict></plist>';
|
|
const p = project(BASE, { 'build/ios/export/Example.ipa': makeIpa(plist) });
|
|
const r = await ship('apple upload', ['--json', '--yes'], a => appleUpload({ loaded: p.loaded, args: a }));
|
|
expect(r.err).not.toContain('error:');
|
|
expect(r.code).toBe(7);
|
|
expect(JSON.parse(r.out)).toMatchObject({ ok: true, step: 'upload', store: 'apple', version: '1.2.0', build: '7', state: 'VALID' });
|
|
expect(writes(s.calls).filter(c => /buildUploads/.test(c.path))).toEqual([]);
|
|
});
|
|
test('play upload --pre-check: a passing command writes the evidence and joins the result; a failing one refuses with PRE_CHECK_FAILED before any write', async () => {
|
|
const d = tmp();
|
|
const p = project(BASE, { 'store/listing.md': LISTING, 'build/android/app.aab': makeAab('com.example.notes', 42, '1.2.0') });
|
|
const ev = join(d, 'ev', 'evidence.json');
|
|
const bad = await ship('play upload', ['--json', '--yes', '--pre-check', 'echo scanning {file}; exit 3', '--evidence', ev], a => playUpload({ loaded: p.loaded, args: a }));
|
|
expect(bad.code).toBe(1);
|
|
const jb = JSON.parse(bad.out);
|
|
expect(jb).toMatchObject({ ok: false, error: { code: 'PRE_CHECK_FAILED' }, preCheck: { exitCode: 3, size: expect.any(Number) } });
|
|
expect(JSON.parse(readFileSync(ev, 'utf8')).exitCode).toBe(3);
|
|
expect(writes(s.calls)).toEqual([]);
|
|
const ok = await ship('play upload', ['--json', '--yes', '--pre-check', 'echo scanning {file}', '--evidence', ev], a => playUpload({ loaded: p.loaded, args: a }));
|
|
expect(ok.code).toBe(0);
|
|
const e = JSON.parse(readFileSync(ev, 'utf8'));
|
|
expect(e).toMatchObject({ file: 'build/android/app.aab', exitCode: 0, storeboundVersion: expect.any(String) });
|
|
expect(e.sha256).toMatch(/^[0-9a-f]{64}$/);
|
|
expect(e.stdoutTail.join(' ')).toContain('scanning');
|
|
expect(JSON.parse(ok.out).preCheck.sha256).toBe(e.sha256);
|
|
});
|
|
|
|
test('apple upload --pre-check: a failing command stops before any App Store Connect call', async () => {
|
|
const plist = '<?xml version="1.0" encoding="UTF-8"?><plist version="1.0"><dict><key>CFBundleIdentifier</key><string>com.example.notes</string><key>CFBundleShortVersionString</key><string>1.2.0</string><key>CFBundleVersion</key><string>9</string></dict></plist>';
|
|
const p = project(BASE, { 'build/ios/export/Example.ipa': makeIpa(plist) });
|
|
const n = s.calls.length;
|
|
const r = await ship('apple upload', ['--json', '--yes', '--pre-check', 'exit 1'], a => appleUpload({ loaded: p.loaded, args: a }));
|
|
expect(r.code).toBe(1);
|
|
expect(JSON.parse(r.out)).toMatchObject({ ok: false, step: 'upload', store: 'apple', error: { code: 'PRE_CHECK_FAILED' }, preCheck: { exitCode: 1 } });
|
|
expect(s.calls.length).toBe(n);
|
|
});
|
|
});
|
|
|
|
describe('status', () => {
|
|
test('per platform: live and next with build, track, review state, rollout %', () => {
|
|
const s: AppStatus = {
|
|
project: '/x', ok: true, app: null, check: null, catalog: null, storeErrors: {}, errors: [],
|
|
summary: { apple: { bundleId: 'com.example.notes', appId: '1' }, play: { packageName: 'com.example.notes' } } as never,
|
|
release: { source: 'live', current: null, next: null, all: [
|
|
{ store: 'ios', version: '1.1.0', build: '6', state: 'READY_FOR_SALE', phase: 'live', fraction: null, track: null },
|
|
{ store: 'ios', version: '1.2.0', build: '7', state: 'WAITING_FOR_REVIEW', phase: 'sent', fraction: null, track: null },
|
|
{ store: 'android', version: '1.2.0', build: '42', state: 'inProgress', phase: 'rollout', fraction: 0.2, track: 'production' },
|
|
] },
|
|
};
|
|
const p = platformStatus(s);
|
|
expect(p).toEqual([
|
|
{ platform: 'ios', store: 'apple', live: { version: '1.1.0', build: '6', track: 'app-store', state: 'READY_FOR_SALE', phase: 'live', reviewState: null, rolloutPct: null },
|
|
next: { version: '1.2.0', build: '7', track: 'app-store', state: 'WAITING_FOR_REVIEW', phase: 'sent', reviewState: 'WAITING_FOR_REVIEW', rolloutPct: null } },
|
|
{ platform: 'android', store: 'play', live: { version: '1.2.0', build: '42', track: 'production', state: 'inProgress', phase: 'rollout', reviewState: null, rolloutPct: 20 }, next: null },
|
|
]);
|
|
});
|
|
test('an unreadable store is listed with its error', () => {
|
|
const s = { project: '/x', ok: false, app: null, check: null, catalog: null, errors: [], storeErrors: { android: 'GET -> 401' },
|
|
summary: { apple: null, play: { packageName: 'com.example.notes' } }, release: { source: 'live', current: null, next: null, all: [] } } as unknown as AppStatus;
|
|
expect(platformStatus(s)).toEqual([{ platform: 'android', store: 'play', live: null, next: null, error: 'GET -> 401' }]);
|
|
});
|
|
});
|