import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; import { sha256 } from '@noble/hashes/sha2.js'; import { NotFoundError, downloadDirectory, createMemoryDirectory, walk, type DirectoryHandleLike, type FileEntry, } from '../../src/index.js'; import { setupFileRig, type FileTestRig } from './helpers/rig.js'; interface StoredFile { bytes: Uint8Array; contentType?: string; sha256: string; } function bytesToHex(b: Uint8Array): string { return Array.from(b, (x) => x.toString(16).padStart(2, '0')).join(''); } async function streamToBytes(s: ReadableStream): Promise { const reader = s.getReader(); const parts: Uint8Array[] = []; let total = 0; while (true) { const { value, done } = await reader.read(); if (done) break; if (value === undefined) continue; parts.push(value); total += value.byteLength; } reader.releaseLock(); const out = new Uint8Array(total); let offset = 0; for (const p of parts) { out.set(p, offset); offset += p.byteLength; } return out; } function streamFromBytes(bytes: Uint8Array): ReadableStream { return new ReadableStream({ start(controller) { controller.enqueue(bytes); controller.close(); }, }); } describe('downloadDirectory — bulk download from remote', () => { let rig: FileTestRig; const blobs = new Map(); const dirs = new Set(); beforeAll(async () => { blobs.clear(); dirs.clear(); // Build remote tree: // /src/ // ├── small.txt ('hello world\n', 12 bytes) // ├── img.bin (50 KiB random) // └── nested/ // ├── big.bin (400 KiB random) // └── tiny.bin (3 bytes) dirs.add('/'); dirs.add('/src'); dirs.add('/src/nested'); const small = new TextEncoder().encode('hello world\n'); const mid = new Uint8Array(50 * 1024); crypto.getRandomValues(mid); const big = new Uint8Array(400 * 1024); crypto.getRandomValues(big); const tiny = new Uint8Array([1, 2, 3]); blobs.set('/src/small.txt', { bytes: small, sha256: bytesToHex(sha256(small)), contentType: 'text/plain' }); blobs.set('/src/img.bin', { bytes: mid, sha256: bytesToHex(sha256(mid)) }); blobs.set('/src/nested/big.bin', { bytes: big, sha256: bytesToHex(sha256(big)) }); blobs.set('/src/nested/tiny.bin', { bytes: tiny, sha256: bytesToHex(sha256(tiny)) }); rig = await setupFileRig({ list: async (ctx) => { if (!dirs.has(ctx.path)) throw new NotFoundError(ctx.path); const entries: FileEntry[] = []; const dirPrefix = ctx.path === '/' ? '/' : ctx.path + '/'; // Subdirs for (const d of dirs) { if (d === ctx.path) continue; if (!d.startsWith(dirPrefix)) continue; const rest = d.slice(dirPrefix.length); if (rest.includes('/')) continue; entries.push({ name: rest, kind: 'dir', size: 0, mtime: 0, metadata: {} }); } // Files for (const [path, blob] of blobs) { if (!path.startsWith(dirPrefix)) continue; const rest = path.slice(dirPrefix.length); if (rest.includes('/')) continue; entries.push({ name: rest, kind: 'file', size: blob.bytes.byteLength, mtime: 0, ...(blob.contentType !== undefined ? { contentType: blob.contentType } : {}), metadata: {}, }); } return { entries, hasMore: false }; }, read: async (ctx) => { const blob = blobs.get(ctx.path); if (blob === undefined) throw new NotFoundError(ctx.path); if (blob.bytes.byteLength > 256 * 1024) { return blob.contentType !== undefined ? { kind: 'streams', stream: streamFromBytes(blob.bytes), size: blob.bytes.byteLength, sha256: blob.sha256, contentType: blob.contentType, } : { kind: 'streams', stream: streamFromBytes(blob.bytes), size: blob.bytes.byteLength, sha256: blob.sha256, }; } return blob.contentType !== undefined ? { kind: 'inline', bytes: blob.bytes, sha256: blob.sha256, contentType: blob.contentType } : { kind: 'inline', bytes: blob.bytes, sha256: blob.sha256 }; }, }); }); afterAll(async () => { await rig.teardown(); }); test('downloads entire tree, sha256 matches per file', async () => { const local = createMemoryDirectory('local'); const handle = downloadDirectory(rig.fs, '/src', local); const result = await handle.done(); expect(result.filesDone).toBe(4); expect(result.bytesDone).toBe(12 + 50 * 1024 + 400 * 1024 + 3); // Verify local tree contents const downloadedFiles = new Map(); async function dump(dir: DirectoryHandleLike, prefix: string): Promise { for await (const [name, child] of dir.entries()) { const path = prefix === '' ? name : `${prefix}/${name}`; if (child.kind === 'directory') { await dump(child as DirectoryHandleLike, path); } else { const file = await (child as { getFile: () => Promise<{ arrayBuffer: () => Promise }> }).getFile(); downloadedFiles.set(path, new Uint8Array(await file.arrayBuffer())); } } } await dump(local, ''); expect(downloadedFiles.size).toBe(4); expect(downloadedFiles.has('small.txt')).toBe(true); expect(downloadedFiles.has('img.bin')).toBe(true); expect(downloadedFiles.has('nested/big.bin')).toBe(true); expect(downloadedFiles.has('nested/tiny.bin')).toBe(true); expect(bytesToHex(sha256(downloadedFiles.get('nested/big.bin')!))).toBe( blobs.get('/src/nested/big.bin')!.sha256, ); expect(bytesToHex(sha256(downloadedFiles.get('img.bin')!))).toBe( blobs.get('/src/img.bin')!.sha256, ); }); test('aggregated progress events fire monotonically', async () => { const local = createMemoryDirectory('local'); const handle = downloadDirectory(rig.fs, '/src', local); const progresses: { filesDone: number; bytesDone: number }[] = []; (async () => { for await (const ev of handle.events) { if (ev.type === 'progress') { progresses.push({ filesDone: ev.filesDone, bytesDone: ev.bytesDone }); } } })().catch(() => undefined); await handle.done(); await new Promise((r) => setTimeout(r, 30)); for (let i = 1; i < progresses.length; i++) { expect(progresses[i]!.filesDone).toBeGreaterThanOrEqual(progresses[i - 1]!.filesDone); expect(progresses[i]!.bytesDone).toBeGreaterThanOrEqual(progresses[i - 1]!.bytesDone); } }); test('aborts via handle.abort()', async () => { const local = createMemoryDirectory('local'); const handle = downloadDirectory(rig.fs, '/src', local); setTimeout(() => void handle.abort('test-cancel'), 5); await expect(handle.done()).rejects.toThrow(); }); test('walk + downloadDirectory are consistent', async () => { const local = createMemoryDirectory('local'); const remoteFiles: string[] = []; for await (const item of walk(rig.fs, '/src')) { if (item.entry.kind === 'file') remoteFiles.push(item.relativePath); } const handle = downloadDirectory(rig.fs, '/src', local); const result = await handle.done(); expect(result.filesDone).toBe(remoteFiles.length); }); });