import { CID, cborEncodeDagCbor, cidToBytes, cidToString, createBlobCid, createCid, } from '@pdsjs/core/repo'; import { beforeEach, describe, expect, it } from 'vitest'; import { clearManifestCache, loadSite } from '../src/manifest.js'; const DID = 'did:plc:sitetest'; /** * Build a blob ref the way records carry one, from arbitrary content bytes. * @param {string} content * @param {string} mimeType */ async function blobRef(content, mimeType) { const bytes = new TextEncoder().encode(content); const cid = await createBlobCid(bytes); return { $type: 'blob', ref: new CID(cid), mimeType, size: bytes.length, cidString: cidToString(cid), }; } /** @param {{$type: string, ref: CID, mimeType: string, size: number, cidString: string}} ref */ function stripCidString({ cidString, ...blob }) { return blob; } /** * In-memory actorStorage covering just getRecord, keyed by AT-URI. * @param {Record} records - uri → record value (pre-encode) */ async function mockStorage(records) { /** @type {Map} */ const encoded = new Map(); for (const [uri, value] of Object.entries(records)) { const bytes = cborEncodeDagCbor(value); encoded.set(uri, { cid: cidToString(await createCid(bytes)), value: bytes, }); } return { storage: /** @type {any} */ ({ getRecord: async (/** @type {string} */ uri) => encoded.get(uri) || null, }), encoded, }; } /** @param {string} name */ const uri = (name) => `at://${DID}/dev.pdsjs.site.deploy/${name}`; describe('loadSite', () => { beforeEach(() => clearManifestCache()); it('returns null for a missing site', async () => { const { storage } = await mockStorage({}); expect(await loadSite(storage, DID, 'nope')).toBeNull(); }); it('builds a path map with CID strings and metadata', async () => { const index = await blobRef('hi', 'text/html'); const css = await blobRef('body{}', 'text/css'); const { storage } = await mockStorage({ [uri('home')]: { $type: 'dev.pdsjs.site.deploy', files: [ { path: 'index.html', blob: stripCidString(index) }, { path: '/assets/site.css', blob: stripCidString(css), contentType: 'text/css; charset=utf-8', cacheControl: 'public, max-age=31536000, immutable', }, ], createdAt: '2026-08-06T00:00:00.000Z', }, }); const site = await loadSite(storage, DID, 'home'); expect(site).not.toBeNull(); expect(site?.createdAt).toBe('2026-08-06T00:00:00.000Z'); expect(site?.files.get('index.html')).toEqual({ cid: index.cidString, mimeType: 'text/html', size: index.size, }); // Leading slashes are normalized away; overrides carried through expect(site?.files.get('assets/site.css')).toMatchObject({ cid: css.cidString, contentType: 'text/css; charset=utf-8', cacheControl: 'public, max-age=31536000, immutable', }); expect(site?.fallback).toBeNull(); }); it('keeps fallback only when it resolves to a file', async () => { const index = await blobRef('', 'text/html'); const { storage } = await mockStorage({ [uri('app')]: { $type: 'dev.pdsjs.site.deploy', files: [{ path: 'index.html', blob: stripCidString(index) }], fallback: '/index.html', createdAt: '2026-08-06T00:00:00.000Z', }, [uri('broken')]: { $type: 'dev.pdsjs.site.deploy', files: [{ path: 'index.html', blob: stripCidString(index) }], fallback: 'missing.html', createdAt: '2026-08-06T00:00:00.000Z', }, }); expect((await loadSite(storage, DID, 'app'))?.fallback).toBe('index.html'); expect((await loadSite(storage, DID, 'broken'))?.fallback).toBeNull(); }); it('skips malformed file entries rather than failing the site', async () => { const good = await blobRef('ok', 'text/plain'); const { storage } = await mockStorage({ [uri('home')]: { $type: 'dev.pdsjs.site.deploy', files: [ { path: 'good.txt', blob: stripCidString(good) }, { path: '', blob: stripCidString(good) }, { path: 'no-blob.txt' }, { blob: stripCidString(good) }, ], createdAt: '2026-08-06T00:00:00.000Z', }, }); const site = await loadSite(storage, DID, 'home'); expect(site?.files.size).toBe(1); expect(site?.files.has('good.txt')).toBe(true); }); it('reads refs in their XRPC shape ({$link}), the write path default', async () => { // putRecord over JSON stores blob refs as `{$link}` maps, not tag-42 // CIDs — the same duality findBlobRefs handles. A CLI-deployed site is // entirely this shape. const data = new TextEncoder().encode('

hi

'); const cid = cidToString(await createBlobCid(data)); const { storage } = await mockStorage({ [uri('home')]: { $type: 'dev.pdsjs.site.deploy', files: [ { path: 'index.html', blob: { $type: 'blob', ref: { $link: cid }, mimeType: 'text/html', size: data.length, }, }, ], createdAt: '2026-08-06T00:00:00.000Z', }, }); const site = await loadSite(storage, DID, 'home'); expect(site?.files.get('index.html')).toMatchObject({ cid, mimeType: 'text/html', size: data.length, }); }); it('merges a base record beneath the site record, one hop', async () => { const shared = await blobRef('shared', 'text/css'); const oldIndex = await blobRef('old', 'text/html'); const newIndex = await blobRef('new', 'text/html'); const { storage, encoded } = await mockStorage({ [uri('assets')]: { $type: 'dev.pdsjs.site.deploy', files: [ { path: 'shared.css', blob: stripCidString(shared) }, { path: 'index.html', blob: stripCidString(oldIndex) }, ], createdAt: '2026-08-06T00:00:00.000Z', }, }); const baseCid = encoded.get(uri('assets'))?.cid; const topRecord = { $type: 'dev.pdsjs.site.deploy', files: [{ path: 'index.html', blob: stripCidString(newIndex) }], base: { uri: uri('assets'), cid: baseCid }, createdAt: '2026-08-06T00:00:00.000Z', }; const topBytes = cborEncodeDagCbor(topRecord); encoded.set(uri('home'), { cid: cidToString(await createCid(topBytes)), value: topBytes, }); const site = await loadSite(storage, DID, 'home'); // Base contributes what the top record lacks; top wins on conflict expect(site?.files.get('shared.css')?.cid).toBe(shared.cidString); expect(site?.files.get('index.html')?.cid).toBe(newIndex.cidString); }); it('refuses a base chain deeper than one hop', async () => { const file = await blobRef('x', 'text/plain'); const records = /** @type {Record} */ ({}); records[uri('deep')] = { $type: 'dev.pdsjs.site.deploy', files: [{ path: 'a.txt', blob: stripCidString(file) }], createdAt: '2026-08-06T00:00:00.000Z', }; records[uri('mid')] = { $type: 'dev.pdsjs.site.deploy', files: [], base: { uri: uri('deep'), cid: 'bafyfake' }, createdAt: '2026-08-06T00:00:00.000Z', }; records[uri('top')] = { $type: 'dev.pdsjs.site.deploy', files: [], base: { uri: uri('mid'), cid: 'bafyfake' }, createdAt: '2026-08-06T00:00:00.000Z', }; const { storage } = await mockStorage(records); await expect(loadSite(storage, DID, 'top')).rejects.toThrow(/one hop/); }); it('caches by record CID and invalidates when the base changes', async () => { const v1 = await blobRef('v1', 'text/html'); const v2 = await blobRef('v2', 'text/html'); const { storage, encoded } = await mockStorage({ [uri('assets')]: { $type: 'dev.pdsjs.site.deploy', files: [{ path: 'base.txt', blob: stripCidString(v1) }], createdAt: '2026-08-06T00:00:00.000Z', }, [uri('home')]: { $type: 'dev.pdsjs.site.deploy', files: [{ path: 'index.html', blob: stripCidString(v1) }], base: { uri: uri('assets'), cid: 'bafypin' }, createdAt: '2026-08-06T00:00:00.000Z', }, }); const first = await loadSite(storage, DID, 'home'); const again = await loadSite(storage, DID, 'home'); // Same record CIDs → the exact cached object comes back expect(again).toBe(first); // Redeploy the base only; the merged map must rebuild const newBase = { $type: 'dev.pdsjs.site.deploy', files: [{ path: 'base.txt', blob: stripCidString(v2) }], createdAt: '2026-08-06T00:00:00.000Z', }; const newBytes = cborEncodeDagCbor(newBase); encoded.set(uri('assets'), { cid: cidToString(await createCid(newBytes)), value: newBytes, }); const rebuilt = await loadSite(storage, DID, 'home'); expect(rebuilt).not.toBe(first); expect(rebuilt?.files.get('base.txt')?.cid).toBe(v2.cidString); }); it('round-trips CID bytes through encode and decode', async () => { const file = await blobRef('bytes', 'application/wasm'); const { storage } = await mockStorage({ [uri('home')]: { $type: 'dev.pdsjs.site.deploy', files: [{ path: 'm.wasm', blob: stripCidString(file) }], createdAt: '2026-08-06T00:00:00.000Z', }, }); const site = await loadSite(storage, DID, 'home'); const cid = site?.files.get('m.wasm')?.cid; expect(cid).toBe(file.cidString); // The string form must decode back to the same bytes it came from expect(cidToString(cidToBytes(/** @type {string} */ (cid)))).toBe(cid); }); });