storebound/tests/upload-pkg-hang.test.ts

114 lines
7.5 KiB
TypeScript

// `apple upload <app.pkg> --platform macos` once printed nothing for 10 minutes over SSH although the build number was
// VALID on App Store Connect. Causes: pkgutil --check-signature / --expand ran unbounded and silent, and the processing
// wait polled the IOS platform only, so a Mac build was "not listed yet" for the whole --timeout. These tests keep
// every slow step logged and bounded, and an already-VALID build number ends at once (no upload, no wait).
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, setDefaultTimeout, test } from 'bun:test';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { hooks, run as appleUpload } from '../cli/apple/upload';
import { PKG_CHECK_MS, PKG_EXPAND_MS, pkgInfo } from '../cli/lib/mas';
import { HEARTBEAT_MS } from '../cli/lib/progress';
import type { SyncResult } from '../cli/lib/rt';
import { ascEnv, BASE, project, restore } from './helpers/store-mock';
import { installStores, type Stores, uninstallStores, writes } from './helpers/fake-stores';
const SIDE = JSON.stringify({ bundleId: 'com.example.notes', version: '1.2.0', build: '7' });
const files = (): Record<string, string> => ({ 'build/macos/Example.pkg': 'pkg', 'build/macos/Example.pkg.json': SIDE });
const result = (o: Partial<SyncResult>): SyncResult => ({ exitCode: 0, signalCode: null, success: true, stdout: Buffer.alloc(0), stderr: Buffer.alloc(0), ...o });
const timedOut = result({ exitCode: null, signalCode: 'SIGTERM', success: false });
const SIGNED = 'Package "Bare.pkg":\n Status: signed by a certificate trusted by macOS\n Certificate Chain:\n 1. 3rd Party Mac Developer Installer: Example (ABCDE12345)\n Expires: 2030-01-01\n 2. Apple Worldwide Developer Relations Certification Authority\n 3. Apple Root CA\n';
type Seen = { argv: string[]; timeout?: number };
const fake = (seen: Seen[], answer: (argv: string[]) => SyncResult) => ((argv: string[], o: { timeout?: number }) => { seen.push({ argv, timeout: o.timeout }); return answer(argv); }) as never;
/** Runs fn with log output captured (log.ts writes through console). */
async function logged(fn: () => Promise<void>): Promise<{ text: string; error: Error | null }> {
const out: string[] = [];
const log = console.log, err = console.error, warn = console.warn;
console.log = console.error = console.warn = (...a: unknown[]) => { out.push(a.map(String).join(' ')); };
let error: Error | null = null;
try { await fn(); } catch (e) { error = e as Error; } finally { console.log = log; console.error = err; console.warn = warn; }
return { text: out.join('\n'), error };
}
describe('pkgInfo: bounded and never silent', () => {
test('the signature check has a timeout and a log line; when it times out the .json apple build wrote is used', () => {
const p = project(BASE, files());
const said: string[] = [], seen: Seen[] = [];
const info = pkgInfo(join(p.root, 'build/macos/Example.pkg'), 'com.example.notes', { say: m => said.push(m), spawnSync: fake(seen, () => timedOut) });
expect(info).toEqual({ bundleId: 'com.example.notes', version: '1.2.0', build: '7' });
expect(seen).toHaveLength(1);
expect(seen[0]!.timeout).toBe(PKG_CHECK_MS);
expect(said[0]).toContain('pkgutil --check-signature');
expect(said.at(-1)).toContain('gave no answer');
expect(said.at(-1)).toContain('Example.pkg.json');
});
test('the expand is bounded too; a timeout without the .json file is an error that names the cause', () => {
const p = project(BASE, { 'build/macos/Bare.pkg': 'pkg' });
const said: string[] = [], seen: Seen[] = [];
const spawnSync = fake(seen, argv => (argv[1] === '--check-signature' ? result({ stdout: Buffer.from(SIGNED) }) : timedOut));
let error: Error | null = null;
try { pkgInfo(join(p.root, 'build/macos/Bare.pkg'), undefined, { say: m => said.push(m), spawnSync, expandMs: 5000 }); } catch (e) { error = e as Error; }
expect(error?.message).toContain('pkgutil --expand gave no answer');
expect(error?.message).toContain('Bare.pkg.json');
expect(seen.map(x => x.argv[1])).toEqual(['--check-signature', '--expand']);
expect(seen[1]!.timeout).toBe(5000);
expect(said.some(m => m.includes('pkgutil --expand'))).toBe(true);
expect(PKG_EXPAND_MS).toBeGreaterThan(PKG_CHECK_MS);
});
});
describe('apple upload of a .pkg (fake stores)', () => {
setDefaultTimeout(30_000);
let s: Stores;
const path = process.env.PATH;
beforeAll(() => { ascEnv(); });
afterAll(restore);
beforeEach(() => { s = installStores(); process.env.PATH = mkdtempSync(join(tmpdir(), 'sb-nopkgutil-')); }); // no pkgutil: the .json is read
afterEach(() => { uninstallStores(); process.env.PATH = path; hooks.heartbeat = undefined; });
test('an already VALID build number ends at once: clear message, no upload, no wait, every slow step logged first', async () => {
s.asc.put({ type: 'builds', id: 'b-7', attributes: { version: '7', processingState: 'VALID' }, rels: { app: '6400000101' } });
const p = project(BASE, files());
const started = Date.now();
const r = await logged(() => appleUpload(p.ctx(['build/macos/Example.pkg', '--platform', 'macos', '--yes'])));
expect(Date.now() - started).toBeLessThan(10_000);
expect(r.error?.message).toContain('already on App Store Connect (VALID)');
expect(writes(s.calls)).toEqual([]);
expect(s.calls.some(c => decodeURIComponent(c.path).includes('preReleaseVersion.platform'))).toBe(false); // no processing poll
expect(s.asc.parts).toEqual([]);
expect(r.text).toContain('reading the package');
expect(r.text).toContain('asking App Store Connect');
expect(r.text.indexOf('reading the package')).toBeLessThan(r.text.indexOf('asking App Store Connect'));
});
test('--allow-existing on a Mac build: the wait polls MAC_OS and prints a heartbeat while a poll is in flight', async () => {
s.asc.put({ type: 'builds', id: 'b-7', attributes: { version: '7', processingState: 'VALID' }, rels: { app: '6400000101' } });
const p = project(BASE, files());
let beat: (() => void) | null = null;
hooks.heartbeat = { everyMs: 1000, ticker: f => { beat = f; return () => { beat = null; }; } };
const inner = globalThis.fetch, polls: string[] = [];
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
if (decodeURIComponent(String(input)).includes('preReleaseVersion.platform')) {
(beat as (() => void) | null)?.(); // the poll is in flight: time passes
polls.push(decodeURIComponent(String(input)));
// the fake does not implement include=: answer the way App Store Connect does
return new Response(JSON.stringify({ data: [{ type: 'builds', id: 'b-7', attributes: { version: '7', processingState: 'VALID' }, relationships: { preReleaseVersion: { data: { type: 'preReleaseVersions', id: 'pv-1' } } } }], included: [{ type: 'preReleaseVersions', id: 'pv-1', attributes: { version: '1.2.0' } }] }), { status: 200 });
}
return inner(input as never, init);
}) as typeof fetch;
const r = await logged(() => appleUpload(p.ctx(['build/macos/Example.pkg', '--platform', 'macos', '--yes', '--allow-existing'])));
expect(r.error).toBeNull();
expect(r.text).toMatch(/still waiting for build 7: first check/);
expect(r.text).toContain('build 7 is VALID');
expect(polls).toHaveLength(1);
expect(polls[0]).toContain('filter[preReleaseVersion.platform]=MAC_OS');
expect(writes(s.calls)).toEqual([]);
});
test('the default heartbeat is at least once a minute', () => {
expect(HEARTBEAT_MS).toBeLessThanOrEqual(60_000);
});
});