import { describe, expect, test } from 'bun:test'; import { mkdtempSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { sendParts, type UploadOp } from '../cli/apple/upload'; import { elapsed, heartbeat, mb, share, type Ticker } from '../cli/lib/progress'; /** A ticker driven by hand: beat() runs the heartbeat once, no timers. */ function manualTicker(): { ticker: Ticker; beat: () => void; stopped: () => boolean } { let fn: (() => void) | null = null, stopped = false; return { ticker: f => { fn = f; return () => { stopped = true; fn = null; }; }, beat: () => fn?.(), stopped: () => stopped }; } function ipa(bytes: number): string { const f = join(mkdtempSync(join(tmpdir(), 'sb-upl-')), 'app.ipa'); writeFileSync(f, Buffer.alloc(bytes, 7)); return f; } const MB = 1048576; const parts = (size: number, n: number): UploadOp[] => Array.from({ length: n }, (_, i) => { const len = Math.ceil(size / n), offset = i * len; return { method: 'PUT', url: `https://upload.example.com/p${i}`, offset, length: Math.min(len, size - offset) }; }); describe('progress formats', () => { test('sizes, elapsed time, share', () => { expect(mb(4.2 * MB)).toBe('4.2 MB'); expect(mb(890 * MB)).toBe('890 MB'); expect(elapsed(45_000)).toBe('45s'); expect(elapsed(270_000)).toBe('4m30s'); expect(elapsed(3_720_000)).toBe('1h02m'); expect(share(312 * MB, 890 * MB)).toBe('312/890 MB (35%)'); }); test('heartbeat says the elapsed time and stops', () => { const t = manualTicker(); let now = 1000; const said: number[] = []; const h = heartbeat(ms => said.push(ms), { ticker: t.ticker, now: () => now }); now += 30_000; t.beat(); h.stop(); t.beat(); expect(said).toEqual([30_000]); expect(t.stopped()).toBe(true); }); }); describe('apple upload: parts are never silent', () => { test('size at the start, a heartbeat while a part is in flight, a line per part', async () => { const size = 3 * MB, file = ipa(size), t = manualTicker(), said: string[] = []; let now = 0; const fetch = async (url: string, init: RequestInit): Promise => { expect(init.signal).toBeDefined(); if (url.endsWith('p1')) { now += 270_000; t.beat(); } return new Response(null, { status: 200 }); }; await sendParts({ file, size, ops: parts(size, 3), deadline: 3_600_000, fetch, ticker: t.ticker, now: () => now, say: m => said.push(m) }); expect(said[0]).toBe('uploading 3.0 MB in 3 part(s)'); expect(said).toContain('still uploading, 4m30s, 1.0/3.0 MB (33%), part 2/3'); expect(said.filter(l => /^\d\/3 parts, /.test(l))).toHaveLength(3); expect(said.at(-1)).toStartWith('3/3 parts, 3.0/3.0 MB (100%)'); expect(t.stopped()).toBe(true); }); test('5xx and a part timeout are retried; 4xx stops at once', async () => { const size = MB, file = ipa(size), said: string[] = []; let calls = 0; const flaky = async (): Promise => { calls++; if (calls === 1) return new Response('busy', { status: 503 }); if (calls === 2) throw new DOMException('timed out', 'TimeoutError'); return new Response(null, { status: 200 }); }; await sendParts({ file, size, ops: parts(size, 1), deadline: Date.now() + 3_600_000, partTimeoutMs: 60_000, fetch: flaky, sleep: async () => {}, ticker: manualTicker().ticker, say: m => said.push(m) }); expect(calls).toBe(3); expect(said.filter(l => l.includes('retry'))).toEqual(['part 1/1: HTTP 503, retry 1/3', 'part 1/1: no answer in 1m00s, retry 2/3']); const denied = async (): Promise => new Response('no', { status: 403 }); await expect(sendParts({ file, size, ops: parts(size, 1), deadline: Date.now() + 3_600_000, fetch: denied, sleep: async () => {}, ticker: manualTicker().ticker, say: () => {} })) .rejects.toThrow('part 1/1 -> 403'); }); test('the overall timeout stops the upload with exit 7 and the hint to resume', async () => { const size = 2 * MB, file = ipa(size); let now = 0; const slow = async (url: string): Promise => { if (url.endsWith('p0')) { now += 50_000; return new Response(null, { status: 200 }); } now += 10_000; throw new DOMException('timed out', 'TimeoutError'); // cut by the deadline }; const err = await sendParts({ file, size, ops: parts(size, 2), deadline: 60_000, partTimeoutMs: 15 * 60_000, fetch: slow, now: () => now, ticker: manualTicker().ticker, say: () => {} }) .then(() => null, (e: Error & { exit?: number }) => e); expect(err?.exit).toBe(7); expect(err?.message).toContain('1.0/2.0 MB (50%) sent, 1/2 parts'); expect(err?.message).toContain('--timeout'); expect(err?.message).toContain('resumes this build upload'); }); });