diff --git a/src/files/download.ts b/src/files/download.ts index b8202c8..246b933 100644 --- a/src/files/download.ts +++ b/src/files/download.ts @@ -1,120 +1,478 @@ import { createWriteStream, renameSync, unlinkSync } from 'fs'; +import type { WriteStream } from 'fs'; import { createProgressBar } from '../output/progress'; import { CLIError } from '../errors/base'; import { ExitCode } from '../errors/codes'; +const DEFAULT_OVERALL_TIMEOUT_MS = 30 * 60 * 1000; +const DEFAULT_IDLE_TIMEOUT_MS = 60 * 1000; +const DEFAULT_MAX_BYTES = 5 * 1024 * 1024 * 1024; +const CLEANUP_TIMEOUT_MS = 1000; + export interface DownloadOpts { quiet?: boolean; retries?: number; retryDelayMs?: number; + overallTimeoutMs?: number; + idleTimeoutMs?: number; + maxBytes?: number; + signal?: AbortSignal; } -export async function downloadFile( - url: string, +class RetryableDownloadError extends CLIError { + readonly retryAfterMs?: number; + + constructor(message: string, exitCode: ExitCode = ExitCode.NETWORK, retryAfterMs?: number) { + super(message, exitCode); + this.retryAfterMs = retryAfterMs; + } +} + +class DownloadHttpError extends CLIError { + readonly retryable: boolean; + readonly retryAfterMs?: number; + + constructor(status: number, retryAfterMs?: number) { + super(`Download failed: HTTP ${status}`, ExitCode.GENERAL); + this.retryable = status === 408 || status === 429 || status >= 500; + this.retryAfterMs = retryAfterMs; + } +} + +function positiveNumber(value: number, name: string): number { + if (!Number.isFinite(value) || value <= 0) { + throw new CLIError(`${name} must be a positive number.`, ExitCode.USAGE); + } + return value; +} + +function nonNegativeInteger(value: number, name: string): number { + if (!Number.isInteger(value) || value < 0) { + throw new CLIError(`${name} must be a non-negative integer.`, ExitCode.USAGE); + } + return value; +} + +function nonNegativeNumber(value: number, name: string): number { + if (!Number.isFinite(value) || value < 0) { + throw new CLIError(`${name} must be a non-negative number.`, ExitCode.USAGE); + } + return value; +} + +function abortReason(signal: AbortSignal, fallback: string): Error { + return signal.reason instanceof Error + ? signal.reason + : new CLIError(fallback, ExitCode.GENERAL); +} + +function parseRetryAfter(value: string | null): number | undefined { + if (!value) return undefined; + + const seconds = Number(value); + if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1000; + + const timestamp = Date.parse(value); + if (!Number.isFinite(timestamp)) return undefined; + return Math.max(0, timestamp - Date.now()); +} + +function parseContentLength(value: string | null): number | undefined { + if (!value || !/^\d+$/.test(value)) return undefined; + const length = BigInt(value); + return length <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(length) : Number.POSITIVE_INFINITY; +} + +async function waitForDelay(delayMs: number, signal: AbortSignal): Promise { + if (signal.aborted) throw abortReason(signal, 'Download aborted.'); + if (delayMs <= 0) return; + + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + signal.removeEventListener('abort', onAbort); + resolve(); + }, delayMs); + const onAbort = () => { + clearTimeout(timer); + reject(abortReason(signal, 'Download aborted.')); + }; + signal.addEventListener('abort', onAbort, { once: true }); + }); +} + +async function withIdleTimeout( + operation: Promise, + controller: AbortController, + idleTimeoutMs: number, + phase: string, + retryable = true, +): Promise { + if (controller.signal.aborted) { + throw abortReason(controller.signal, 'Download aborted.'); + } + + let timer: ReturnType | undefined; + let onAbort: (() => void) | undefined; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => { + const error = retryable + ? new RetryableDownloadError( + `Download idle timeout while ${phase} after ${idleTimeoutMs}ms.`, + ExitCode.TIMEOUT, + ) + : new CLIError( + `Download idle timeout while ${phase} after ${idleTimeoutMs}ms.`, + ExitCode.TIMEOUT, + ); + controller.abort(error); + reject(error); + }, idleTimeoutMs); + }); + const aborted = new Promise((_, reject) => { + onAbort = () => reject(abortReason(controller.signal, 'Download aborted.')); + controller.signal.addEventListener('abort', onAbort, { once: true }); + }); + + try { + return await Promise.race([operation, timeout, aborted]); + } catch (error) { + if (controller.signal.aborted) { + throw abortReason(controller.signal, 'Download aborted.'); + } + throw error; + } finally { + if (timer) clearTimeout(timer); + if (onAbort) controller.signal.removeEventListener('abort', onAbort); + } +} + +async function waitForDrain( + writer: WriteStream, + controller: AbortController, + idleTimeoutMs: number, +): Promise { + const drain = new Promise((resolve, reject) => { + const onDrain = () => finish(resolve); + const onError = (error: Error) => finish(() => reject(error)); + const onClose = () => finish(() => reject(new Error('File writer closed before draining.'))); + const finish = (done: () => void) => { + writer.off('drain', onDrain); + writer.off('error', onError); + writer.off('close', onClose); + done(); + }; + + writer.once('drain', onDrain); + writer.once('error', onError); + writer.once('close', onClose); + }); + + try { + await withIdleTimeout(drain, controller, idleTimeoutMs, 'waiting for disk writes', false); + } catch (error) { + if (error instanceof CLIError) throw error; + throw new CLIError( + `Could not write download: ${error instanceof Error ? error.message : String(error)}`, + ExitCode.GENERAL, + ); + } +} + +async function finishWriter( + writer: WriteStream, + controller: AbortController, + idleTimeoutMs: number, +): Promise { + const finished = new Promise((resolve, reject) => { + const onFinish = () => finish(resolve); + const onError = (error: Error) => finish(() => reject(error)); + const onClose = () => { + if (!writer.writableFinished) finish(() => reject(new Error('File writer closed early.'))); + }; + const finish = (done: () => void) => { + writer.off('finish', onFinish); + writer.off('error', onError); + writer.off('close', onClose); + done(); + }; + + writer.once('finish', onFinish); + writer.once('error', onError); + writer.once('close', onClose); + writer.end(); + }); + + try { + await withIdleTimeout(finished, controller, idleTimeoutMs, 'finishing disk writes', false); + } catch (error) { + if (error instanceof CLIError) throw error; + throw new CLIError( + `Could not write download: ${error instanceof Error ? error.message : String(error)}`, + ExitCode.GENERAL, + ); + } +} + +async function destroyWriter(writer: WriteStream): Promise { + if (writer.closed) return; + await new Promise(resolve => { + const timer = setTimeout(() => { + writer.off('close', onClose); + resolve(); + }, CLEANUP_TIMEOUT_MS); + const onClose = () => { + clearTimeout(timer); + resolve(); + }; + writer.once('close', onClose); + writer.destroy(); + }); +} + +async function settleCleanup(operation: Promise): Promise { + let timer: ReturnType | undefined; + await Promise.race([ + operation.catch(() => undefined), + new Promise(resolve => { + timer = setTimeout(resolve, CLEANUP_TIMEOUT_MS); + }), + ]); + if (timer) clearTimeout(timer); +} + +async function attemptDownload( + downloadUrl: string, destPath: string, - opts?: DownloadOpts, + attempt: number, + opts: Required>, + overallSignal: AbortSignal, ): Promise<{ size: number }> { - // Fix: Alibaba Cloud OSS US East blocks HTTP from certain regions. - // Force HTTPS to ensure reliable downloads. - const downloadUrl = url.startsWith('http://') ? url.replace('http://', 'https://') : url; - const maxRetries = opts?.retries ?? 3; - const baseDelay = opts?.retryDelayMs ?? 1000; - let lastError: Error | undefined; + const controller = new AbortController(); + const abortAttempt = () => controller.abort(abortReason(overallSignal, 'Download aborted.')); + if (overallSignal.aborted) abortAttempt(); + else overallSignal.addEventListener('abort', abortAttempt, { once: true }); - for (let attempt = 0; attempt <= maxRetries; attempt++) { + let response: Response | undefined; + let reader: ReadableStreamDefaultReader | undefined; + let writer: WriteStream | undefined; + let tmpPath: string | undefined; + let progress: ReturnType | null = null; + let succeeded = false; + + try { try { - if (attempt > 0) { - const delay = baseDelay * Math.pow(2, attempt - 1); - if (!opts?.quiet) { - process.stderr.write(`\n Retry ${attempt}/${maxRetries} in ${delay}ms...\n`); + response = await withIdleTimeout( + fetch(downloadUrl, { signal: controller.signal }), + controller, + opts.idleTimeoutMs, + 'waiting for response headers', + ); + } catch (error) { + if (error instanceof CLIError) throw error; + throw new RetryableDownloadError( + `Download request failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + + if (!response.ok) { + throw new DownloadHttpError( + response.status, + parseRetryAfter(response.headers.get('retry-after')), + ); + } + + const declaredLength = parseContentLength(response.headers.get('content-length')); + if (declaredLength !== undefined && declaredLength > opts.maxBytes) { + throw new CLIError( + `Download exceeds maximum size of ${opts.maxBytes} bytes (Content-Length: ${declaredLength}).`, + ExitCode.GENERAL, + ); + } + + reader = response.body?.getReader(); + if (!reader) throw new CLIError('Download response has no body.', ExitCode.GENERAL); + + const expectedLength = response.headers.get('content-encoding') + ? undefined + : declaredLength; + tmpPath = `${destPath}.tmp-${process.pid}-${Date.now()}-${attempt}-${Math.random().toString(36).slice(2)}`; + writer = createWriteStream(tmpPath); + progress = expectedLength && !opts.quiet + ? createProgressBar(expectedLength, 'Downloading') + : null; + + let writerError: Error | undefined; + const writerFailed = new Promise((_, reject) => { + writer!.once('error', error => { + writerError = error; + reject(error); + }); + }); + void writerFailed.catch(() => undefined); + + let received = 0; + while (true) { + let result: Awaited['read']>>; + try { + result = await withIdleTimeout( + Promise.race([reader.read(), writerFailed]), + controller, + opts.idleTimeoutMs, + 'waiting for response data', + ); + } catch (error) { + if (writerError) { + throw new CLIError(`Could not write download: ${writerError.message}`, ExitCode.GENERAL); } - await new Promise(r => setTimeout(r, delay)); + if (error instanceof CLIError) throw error; + throw new RetryableDownloadError( + `Download stream failed: ${error instanceof Error ? error.message : String(error)}`, + ); } - const res = await fetch(downloadUrl); + if (result.done) break; + const nextSize = received + result.value.byteLength; + if (nextSize > opts.maxBytes) { + throw new CLIError( + `Download exceeds maximum size of ${opts.maxBytes} bytes.`, + ExitCode.GENERAL, + ); + } - if (!res.ok) { - throw new CLIError(`Download failed: HTTP ${res.status}`, ExitCode.GENERAL); + let accepted: boolean; + try { + accepted = writer.write(result.value); + } catch (error) { + throw new CLIError( + `Could not write download: ${error instanceof Error ? error.message : String(error)}`, + ExitCode.GENERAL, + ); } + if (!accepted) await waitForDrain(writer, controller, opts.idleTimeoutMs); - // fetch transparently decodes compressed responses, so Content-Length - // only describes the readable body when no Content-Encoding is present. - const contentLength = res.headers.get('content-encoding') - ? 0 - : Number(res.headers.get('content-length') || 0); - const reader = res.body?.getReader(); - if (!reader) throw new CLIError('No response body', ExitCode.GENERAL); + received = nextSize; + progress?.update(received); + } - const tmpPath = `${destPath}.tmp-${process.pid}-${Date.now()}-${attempt}-${Math.random().toString(36).slice(2)}`; - const writer = createWriteStream(tmpPath); - const progress = contentLength > 0 && !opts?.quiet - ? createProgressBar(contentLength, 'Downloading') - : null; + if (expectedLength !== undefined && received !== expectedLength) { + throw new RetryableDownloadError( + `Download truncated: expected ${expectedLength} bytes, received ${received}.`, + ); + } - let received = 0; - let readComplete = false; - let completed = false; + await finishWriter(writer, controller, opts.idleTimeoutMs); + writer = undefined; + reader.releaseLock(); + reader = undefined; + renameSync(tmpPath, destPath); + tmpPath = undefined; + succeeded = true; + return { size: received }; + } finally { + overallSignal.removeEventListener('abort', abortAttempt); + progress?.finish(); - try { - const writeError = new Promise((_, reject) => { - writer.on('error', reject); - }); - - while (true) { - const { done, value } = await Promise.race([ - reader.read(), - writeError, - ]) as ReadableStreamReadResult; - if (done) break; - - const ok = writer.write(value); - if (!ok) await new Promise(r => writer.once('drain', r)); - - received += value.byteLength; - progress?.update(received); - } + if (!succeeded) { + if (reader) { + await settleCleanup(reader.cancel(controller.signal.reason)); + try { reader.releaseLock(); } catch { /* best effort */ } + } else if (response?.body) { + await settleCleanup(response.body.cancel(controller.signal.reason)); + } + if (writer) await destroyWriter(writer); + if (tmpPath) { + try { unlinkSync(tmpPath); } catch { /* best effort */ } + } + } + } +} - if (contentLength > 0 && received !== contentLength) { - throw new CLIError( - `Download truncated: expected ${contentLength} bytes, received ${received}.`, - ExitCode.NETWORK, - ); - } - readComplete = true; - } finally { - reader.releaseLock(); - progress?.finish(); - - try { - if (!writer.destroyed) { - await new Promise((resolve, reject) => { - writer.on('finish', resolve); - writer.on('error', reject); - writer.end(); - }); - completed = readComplete; - } - } finally { - if (!completed) { - try { unlinkSync(tmpPath); } catch { /* best effort */ } - } +export async function downloadFile( + url: string, + destPath: string, + opts?: DownloadOpts, +): Promise<{ size: number }> { + // Alibaba Cloud OSS US East blocks HTTP from certain regions. + const downloadUrl = url.startsWith('http://') ? url.replace('http://', 'https://') : url; + const maxRetries = nonNegativeInteger(opts?.retries ?? 3, 'retries'); + const baseDelay = nonNegativeNumber(opts?.retryDelayMs ?? 1000, 'retryDelayMs'); + const overallTimeoutMs = positiveNumber( + opts?.overallTimeoutMs ?? DEFAULT_OVERALL_TIMEOUT_MS, + 'overallTimeoutMs', + ); + const idleTimeoutMs = positiveNumber( + opts?.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS, + 'idleTimeoutMs', + ); + const maxBytes = positiveNumber(opts?.maxBytes ?? DEFAULT_MAX_BYTES, 'maxBytes'); + const quiet = opts?.quiet ?? false; + + const overallController = new AbortController(); + const abortFromCaller = () => overallController.abort( + abortReason(opts!.signal!, 'Download aborted by caller.'), + ); + if (opts?.signal?.aborted) abortFromCaller(); + else opts?.signal?.addEventListener('abort', abortFromCaller, { once: true }); + + const overallTimer = setTimeout(() => { + overallController.abort(new CLIError( + `Download timed out after ${overallTimeoutMs}ms.`, + ExitCode.TIMEOUT, + )); + }, overallTimeoutMs); + + let lastError: Error | undefined; + let retryAfterMs: number | undefined; + + try { + for (let attempt = 0; attempt <= maxRetries; attempt++) { + if (attempt > 0) { + const exponentialDelay = baseDelay * Math.pow(2, attempt - 1); + const delay = Math.max(exponentialDelay, retryAfterMs ?? 0); + if (!quiet) { + process.stderr.write(`\n Retry ${attempt}/${maxRetries} in ${delay}ms...\n`); } + await waitForDelay(delay, overallController.signal); } - renameSync(tmpPath, destPath); - return { size: received }; - } catch (err) { - lastError = err instanceof Error ? err : new Error(String(err)); - if (!opts?.quiet) { - process.stderr.write(`\n Download attempt ${attempt + 1} failed: ${lastError.message}\n`); + try { + return await attemptDownload( + downloadUrl, + destPath, + attempt, + { quiet, idleTimeoutMs, maxBytes }, + overallController.signal, + ); + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + + if (overallController.signal.aborted) { + throw abortReason(overallController.signal, 'Download aborted.'); + } + + const retryable = error instanceof RetryableDownloadError + || (error instanceof DownloadHttpError && error.retryable); + if (!retryable) throw error; + + retryAfterMs = error instanceof DownloadHttpError || error instanceof RetryableDownloadError + ? error.retryAfterMs + : undefined; + if (!quiet) { + process.stderr.write(`\n Download attempt ${attempt + 1} failed: ${lastError.message}\n`); + } + if (attempt === maxRetries) break; } } + } finally { + clearTimeout(overallTimer); + opts?.signal?.removeEventListener('abort', abortFromCaller); } + const exitCode = lastError instanceof CLIError ? lastError.exitCode : ExitCode.NETWORK; throw new CLIError( `Download failed after ${maxRetries + 1} attempts: ${lastError?.message}`, - ExitCode.NETWORK, - 'Check your network connection and proxy settings.', + exitCode, + exitCode === ExitCode.NETWORK ? 'Check your network connection and proxy settings.' : undefined, ); } diff --git a/test/files/download.test.ts b/test/files/download.test.ts index a4f1029..17037d1 100644 --- a/test/files/download.test.ts +++ b/test/files/download.test.ts @@ -1,5 +1,14 @@ import { afterEach, describe, expect, it } from 'bun:test'; -import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'fs'; +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + writeFileSync, +} from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; import { downloadFile } from '../../src/files/download'; @@ -108,4 +117,233 @@ describe('downloadFile', () => { expect(readFileSync(destPath, 'utf-8')).toBe('new'); }); + + it('aborts while response headers are stalled and does not retry an external cancellation', async () => { + const dir = makeTempDir(); + const destPath = join(dir, 'video.mp4'); + writeFileSync(destPath, 'original'); + const controller = new AbortController(); + let calls = 0; + let fetchSignal: AbortSignal | undefined; + + globalThis.fetch = (async (_url: string | URL | Request, init?: RequestInit) => { + calls++; + fetchSignal = init?.signal ?? undefined; + return await new Promise(() => {}); + }) as unknown as typeof fetch; + + const pending = downloadFile('https://example.com/video.mp4', destPath, { + quiet: true, + retries: 3, + idleTimeoutMs: 1000, + overallTimeoutMs: 1000, + signal: controller.signal, + }); + controller.abort(new Error('cancelled by caller')); + + await expect(pending).rejects.toThrow('cancelled by caller'); + expect(calls).toBe(1); + expect(fetchSignal?.aborted).toBe(true); + expect(readFileSync(destPath, 'utf-8')).toBe('original'); + expect(readdirSync(dir)).toEqual(['video.mp4']); + }); + + it('times out while waiting for response headers', async () => { + const dir = makeTempDir(); + const destPath = join(dir, 'video.mp4'); + + globalThis.fetch = (async () => await new Promise(() => {})) as unknown as typeof fetch; + + await expect(downloadFile('https://example.com/video.mp4', destPath, { + quiet: true, + retries: 0, + idleTimeoutMs: 10, + overallTimeoutMs: 1000, + })).rejects.toThrow('idle timeout'); + + expect(readdirSync(dir)).toEqual([]); + }); + + it('enforces the overall timeout even when the idle timeout is longer', async () => { + const dir = makeTempDir(); + const destPath = join(dir, 'video.mp4'); + let fetchSignal: AbortSignal | undefined; + + globalThis.fetch = (async (_url: string | URL | Request, init?: RequestInit) => { + fetchSignal = init?.signal ?? undefined; + return await new Promise(() => {}); + }) as unknown as typeof fetch; + + await expect(downloadFile('https://example.com/video.mp4', destPath, { + quiet: true, + retries: 3, + idleTimeoutMs: 1000, + overallTimeoutMs: 10, + })).rejects.toThrow('Download timed out after 10ms'); + + expect(fetchSignal?.aborted).toBe(true); + expect(readdirSync(dir)).toEqual([]); + }); + + it('times out and cancels a body that stops producing data', async () => { + const dir = makeTempDir(); + const destPath = join(dir, 'video.mp4'); + let cancelled = false; + const stream = new ReadableStream({ + pull() { + // Keep the body open without producing data. + }, + cancel() { + cancelled = true; + }, + }); + + globalThis.fetch = (async () => new Response(stream, { status: 200 })) as unknown as typeof fetch; + + await expect(downloadFile('https://example.com/video.mp4', destPath, { + quiet: true, + retries: 0, + idleTimeoutMs: 10, + overallTimeoutMs: 1000, + })).rejects.toThrow('idle timeout'); + + expect(cancelled).toBe(true); + expect(readdirSync(dir)).toEqual([]); + }); + + it('rejects an oversized Content-Length before reading the body', async () => { + const dir = makeTempDir(); + const destPath = join(dir, 'video.mp4'); + let cancelled = false; + const stream = new ReadableStream({ + cancel() { + cancelled = true; + }, + }); + + globalThis.fetch = (async () => new Response(stream, { + status: 200, + headers: { 'content-length': '11' }, + })) as unknown as typeof fetch; + + await expect(downloadFile('https://example.com/video.mp4', destPath, { + quiet: true, + retries: 0, + maxBytes: 10, + })).rejects.toThrow('exceeds maximum size'); + + expect(cancelled).toBe(true); + expect(readdirSync(dir)).toEqual([]); + }); + + it('enforces the byte limit while streaming when Content-Length is absent', async () => { + const dir = makeTempDir(); + const destPath = join(dir, 'video.mp4'); + writeFileSync(destPath, 'original'); + + globalThis.fetch = (async () => new Response(new Uint8Array(11), { + status: 200, + })) as unknown as typeof fetch; + + await expect(downloadFile('https://example.com/video.mp4', destPath, { + quiet: true, + retries: 0, + maxBytes: 10, + })).rejects.toThrow('exceeds maximum size'); + + expect(readFileSync(destPath, 'utf-8')).toBe('original'); + expect(readdirSync(dir)).toEqual(['video.mp4']); + }); + + it('does not retry a permanent HTTP error or wrap its status', async () => { + const dir = makeTempDir(); + const destPath = join(dir, 'video.mp4'); + let calls = 0; + + globalThis.fetch = (async () => { + calls++; + return new Response(null, { status: 404 }); + }) as unknown as typeof fetch; + + await expect(downloadFile('https://example.com/missing', destPath, { + quiet: true, + retries: 3, + retryDelayMs: 0, + })).rejects.toThrow('Download failed: HTTP 404'); + + expect(calls).toBe(1); + expect(readdirSync(dir)).toEqual([]); + }); + + it('retries HTTP 429 and respects a zero Retry-After value', async () => { + const dir = makeTempDir(); + const destPath = join(dir, 'video.mp4'); + let calls = 0; + + globalThis.fetch = (async () => { + calls++; + if (calls === 1) { + return new Response(null, { status: 429, headers: { 'retry-after': '0' } }); + } + return new Response(new TextEncoder().encode('ok'), { + status: 200, + headers: { 'content-length': '2' }, + }); + }) as unknown as typeof fetch; + + await expect(downloadFile('https://example.com/video.mp4', destPath, { + quiet: true, + retries: 1, + retryDelayMs: 0, + })).resolves.toEqual({ size: 2 }); + + expect(calls).toBe(2); + expect(readFileSync(destPath, 'utf-8')).toBe('ok'); + }); + + it('retries a 5xx response and succeeds on the next attempt', async () => { + const dir = makeTempDir(); + const destPath = join(dir, 'video.mp4'); + let calls = 0; + + globalThis.fetch = (async () => { + calls++; + if (calls === 1) return new Response(null, { status: 503 }); + return new Response(new TextEncoder().encode('ok'), { status: 200 }); + }) as unknown as typeof fetch; + + await expect(downloadFile('https://example.com/video.mp4', destPath, { + quiet: true, + retries: 1, + retryDelayMs: 0, + })).resolves.toEqual({ size: 2 }); + + expect(calls).toBe(2); + }); + + it('preserves the destination and leaves no temp file after a writer error', async () => { + const dir = makeTempDir(); + const outputDir = join(dir, 'readonly'); + mkdirSync(outputDir); + const destPath = join(outputDir, 'video.mp4'); + writeFileSync(destPath, 'original'); + + globalThis.fetch = (async () => new Response(new TextEncoder().encode('new'), { + status: 200, + headers: { 'content-length': '3' }, + })) as unknown as typeof fetch; + + chmodSync(outputDir, 0o555); + try { + await expect(downloadFile('https://example.com/video.mp4', destPath, { + quiet: true, + retries: 3, + })).rejects.toThrow('Could not write download'); + } finally { + chmodSync(outputDir, 0o755); + } + + expect(readFileSync(destPath, 'utf-8')).toBe('original'); + expect(readdirSync(outputDir)).toEqual(['video.mp4']); + }); });