import { CID, cborEncodeDagCbor, cidToString, createBlobCid, createCid, } from '@pdsjs/core/repo'; import { describe, expect, it } from 'vitest'; import { createDriveBrowser } from '../src/browser.js'; const DID = 'did:plc:drivetest'; /** * @typedef {Object} FolderSpec * @property {string} rkey * @property {string} name * @property {string} [parent] */ /** * @typedef {Object} FileSpec * @property {string} rkey * @property {string} name * @property {string} [folder] * @property {string} [mimeType] * @property {Uint8Array[]} parts */ /** * @param {{folders?: FolderSpec[], files?: FileSpec[]}} tree */ async function harnessWith({ folders = [], files = [] }) { /** @type {Array<{uri: string, cid: string, value: Uint8Array}>} */ const rows = []; /** @type {Map} */ const blobData = new Map(); for (const folder of folders) { const value = cborEncodeDagCbor({ $type: 'dev.pdsjs.drive.folder', name: folder.name, ...(folder.parent ? { parent: folder.parent } : {}), createdAt: '2026-08-14T00:00:00.000Z', }); rows.push({ uri: `at://${DID}/dev.pdsjs.drive.folder/${folder.rkey}`, cid: cidToString(await createCid(value)), value, }); } for (const file of files) { const parts = []; for (const data of file.parts) { const cid = cidToString(await createBlobCid(data)); blobData.set(cid, data); parts.push({ $type: 'blob', ref: new CID(await createBlobCid(data)), mimeType: 'application/octet-stream', size: data.length, }); } const value = cborEncodeDagCbor({ $type: 'dev.pdsjs.drive.file', name: file.name, ...(file.folder ? { folder: file.folder } : {}), parts, mimeType: file.mimeType ?? 'text/plain', createdAt: '2026-08-14T00:00:00.000Z', }); rows.push({ uri: `at://${DID}/dev.pdsjs.drive.file/${file.rkey}`, cid: cidToString(await createCid(value)), value, }); } const actorStorage = /** @type {any} */ ({ getDid: async () => DID, listRecords: async ( /** @type {string} */ collection, /** @type {string|null} */ cursor, /** @type {number} */ limit, ) => { const matching = rows.filter((row) => row.uri.includes(`/${collection}/`), ); const start = cursor ? Number(cursor) : 0; const page = matching.slice(start, start + limit); const next = start + page.length; return { records: page, cursor: next < matching.length ? String(next) : null, }; }, getRecord: async (/** @type {string} */ uri) => rows.find((row) => row.uri === uri) ?? null, }); const blobs = /** @type {any} */ ({ get: async (/** @type {string} */ _did, /** @type {string} */ cid) => { const data = blobData.get(cid); return data ? { data, mimeType: 'application/octet-stream' } : null; }, }); return { browser: createDriveBrowser({ actorStorage, blobs, getDid: async () => DID, }), blobData, }; } const bytesOf = (/** @type {string} */ text) => new TextEncoder().encode(text); describe('createDriveBrowser', () => { it('throws on missing ctx entries', () => { expect(() => createDriveBrowser(/** @type {any} */ ({}))).toThrow( /actorStorage/, ); expect(() => createDriveBrowser(/** @type {any} */ ({ actorStorage: {} })), ).toThrow(/blobs/); expect(() => createDriveBrowser(/** @type {any} */ ({ actorStorage: {}, blobs: {} })), ).toThrow(/getDid/); }); }); describe('listFolder', () => { const tree = { folders: [ { rkey: 'fphotos', name: 'photos' }, { rkey: 'f2026', name: '2026', parent: 'fphotos' }, ], files: [ { rkey: 'a', name: 'top.txt', parts: [bytesOf('hi')] }, { rkey: 'b', name: 'shot.jpg', folder: 'fphotos', parts: [bytesOf('12345')], }, { rkey: 'c', name: 'deep.txt', folder: 'f2026', parts: [bytesOf('abc')] }, ], }; it('separates the files in a folder from the folders under it', async () => { const { browser } = await harnessWith(tree); const root = await browser.listFolder(''); expect(root?.folder).toBe(''); expect(root?.path).toBe(''); expect(root?.files.map((file) => file.name)).toEqual(['top.txt']); // A folder's tally counts everything beneath it, at any depth. expect(root?.folders).toEqual([ { rkey: 'fphotos', name: 'photos', files: 2, size: 8, createdAt: '2026-08-14T00:00:00.000Z', }, ]); expect(root?.totalFiles).toBe(3); expect(root?.totalSize).toBe(10); const photos = await browser.listFolder('fphotos'); expect(photos?.files.map((file) => file.name)).toEqual(['shot.jpg']); expect(photos?.folders.map((folder) => folder.name)).toEqual(['2026']); }); it('reports the path and ancestry a breadcrumb needs', async () => { const { browser } = await harnessWith(tree); const listing = await browser.listFolder('f2026'); expect(listing?.path).toBe('photos/2026'); expect(listing?.ancestry).toEqual([ { rkey: 'fphotos', name: 'photos' }, { rkey: 'f2026', name: '2026' }, ]); }); it('reports a folder that is not there as no listing', async () => { const { browser } = await harnessWith(tree); expect(await browser.listFolder('nosuchfolder')).toBe(null); }); it('keeps an empty folder, which is the point of it being a record', async () => { const { browser } = await harnessWith({ folders: [{ rkey: 'fempty', name: 'empty' }], }); const root = await browser.listFolder(''); expect(root?.folders).toEqual([ { rkey: 'fempty', name: 'empty', files: 0, size: 0, createdAt: '2026-08-14T00:00:00.000Z', }, ]); expect((await browser.listFolder('fempty'))?.files).toEqual([]); }); it('sums the part sizes without reading a blob', async () => { const { browser, blobData } = await harnessWith({ files: [ { rkey: 'a', name: 'big.bin', parts: [new Uint8Array(400), new Uint8Array(600)], }, ], }); // A read would have to go through this map, so emptying it proves the // listing never touches blob storage. blobData.clear(); const listing = await browser.listFolder(''); expect(listing?.files[0].size).toBe(1000); expect(listing?.files[0].partCount).toBe(2); }); it('lists a file whose folder is gone at the top level', async () => { const { browser } = await harnessWith({ files: [ { rkey: 'a', name: 'orphan.txt', folder: 'vanished', parts: [bytesOf('x')], }, ], }); const listing = await browser.listFolder(''); expect(listing?.files.map((file) => file.name)).toEqual(['orphan.txt']); }); it('orders by name', async () => { const { browser } = await harnessWith({ files: [ { rkey: 'a', name: 'zebra.txt', parts: [bytesOf('x')] }, { rkey: 'b', name: 'apple.txt', parts: [bytesOf('x')] }, ], }); const listing = await browser.listFolder(''); expect(listing?.files.map((file) => file.name)).toEqual([ 'apple.txt', 'zebra.txt', ]); }); }); describe('readFile', () => { it('joins the parts in order', async () => { const { browser } = await harnessWith({ files: [ { rkey: 'a', name: 'split.txt', mimeType: 'text/plain', parts: [bytesOf('hello '), bytesOf('world')], }, ], }); const file = await browser.readFile('a'); expect(new TextDecoder().decode(file?.bytes)).toBe('hello world'); expect(file?.size).toBe(11); expect(file?.name).toBe('split.txt'); expect(file?.mimeType).toBe('text/plain'); }); it('reads nothing for an unknown record', async () => { const { browser } = await harnessWith({}); expect(await browser.readFile('missing')).toBe(null); }); it('refuses the whole file when one part is gone', async () => { const { browser, blobData } = await harnessWith({ files: [ { rkey: 'a', name: 'split.txt', parts: [bytesOf('first'), bytesOf('second')], }, ], }); const [firstCid] = [...blobData.keys()]; blobData.delete(firstCid); expect(await browser.readFile('a')).toBe(null); }); }); describe('statFile', () => { it('reports the length without reading a part', async () => { const { browser, blobData } = await harnessWith({ files: [ { rkey: 'a', name: 'big.bin', mimeType: 'application/zip', parts: [new Uint8Array(100), new Uint8Array(50)], }, ], }); blobData.clear(); expect(await browser.statFile('a')).toEqual({ name: 'big.bin', mimeType: 'application/zip', size: 150, }); }); it('reports nothing for an unknown record', async () => { const { browser } = await harnessWith({}); expect(await browser.statFile('missing')).toBe(null); }); }); describe('readRange', () => { /** Three parts spelling out a known string, so offsets are checkable. */ const split = { files: [ { rkey: 'a', name: 'split.txt', mimeType: 'text/plain', parts: [bytesOf('AAAA'), bytesOf('BBBB'), bytesOf('CCCC')], }, ], }; it('returns a range that spans several parts', async () => { const { browser } = await harnessWith(split); const part = await browser.readRange('a', 2, 9); expect(new TextDecoder().decode(part?.bytes)).toBe('AABBBBCC'); expect(part?.start).toBe(2); expect(part?.end).toBe(9); expect(part?.total).toBe(12); }); it('reads only the parts a range touches', async () => { const { browser, blobData } = await harnessWith(split); // Drop the first and last parts: a range inside the middle one must not // need them, which is what keeps a large file off the heap. const cids = [...blobData.keys()]; blobData.delete(cids[0]); blobData.delete(cids[2]); const part = await browser.readRange('a', 4, 7); expect(new TextDecoder().decode(part?.bytes)).toBe('BBBB'); }); it('clamps a range that runs past the end', async () => { const { browser } = await harnessWith(split); const part = await browser.readRange('a', 8, 999); expect(new TextDecoder().decode(part?.bytes)).toBe('CCCC'); expect(part?.end).toBe(11); }); it('refuses a range that starts past the end', async () => { const { browser } = await harnessWith(split); expect(await browser.readRange('a', 99, 200)).toBe(null); }); });