storebound/tests/upload-progress.test.ts

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');
});
});