95 lines
4.7 KiB
TypeScript
95 lines
4.7 KiB
TypeScript
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<Response> => {
|
|
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<Response> => {
|
|
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<Response> => 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<Response> => {
|
|
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');
|
|
});
|
|
});
|