import { cidToString, createBlobCid } from '@pdsjs/core/repo'; import { describe, expect, it } from 'vitest'; import { cacheControlFor, checkUploadedRef, compileGlob, planDeploy, uploadContentType, } from '../src/deploy.js'; /** @param {string} content */ const bytes = (content) => new TextEncoder().encode(content); /** @param {string} content @param {string} mimeType */ async function jsonRef(content, mimeType) { const data = bytes(content); return { $type: /** @type {'blob'} */ ('blob'), ref: { $link: cidToString(await createBlobCid(data)) }, mimeType, size: data.length, }; } describe('uploadContentType', () => { it('maps extensions and defaults to octet-stream', () => { expect(uploadContentType('index.html')).toBe('text/html; charset=utf-8'); expect(uploadContentType('a/b/app.JS')).toBe( 'text/javascript; charset=utf-8', ); expect(uploadContentType('photo.webp')).toBe('image/webp'); expect(uploadContentType('mystery')).toBe('application/octet-stream'); }); }); describe('planDeploy', () => { it('uploads everything on a first deploy and builds the record', async () => { const plan = await planDeploy({ site: 'home', files: [ { path: 'index.html', bytes: bytes('

hi

') }, { path: '/assets/app.js', bytes: bytes('let x') }, ], existing: null, fallback: 'index.html', now: '2026-08-06T00:00:00.000Z', }); expect(plan.uploads).toHaveLength(2); expect(plan.reused).toEqual([]); expect(plan.removed).toEqual([]); expect(plan.record).toMatchObject({ $type: 'dev.pdsjs.site.deploy', fallback: 'index.html', createdAt: '2026-08-06T00:00:00.000Z', }); const paths = /** @type {Array<{path: string}>} */ (plan.record.files).map( (file) => file.path, ); expect(paths).toEqual(['index.html', 'assets/app.js']); }); it('reuses unchanged files by CID and lists removals', async () => { const unchanged = await jsonRef('same bytes', 'text/css; charset=utf-8'); const plan = await planDeploy({ site: 'home', files: [ { path: 'style.css', bytes: bytes('same bytes') }, { path: 'new.html', bytes: bytes('fresh') }, ], existing: { value: { files: [ { path: 'style.css', blob: unchanged, cacheControl: 'public, max-age=31536000, immutable', }, { path: 'gone.html', blob: await jsonRef('old', 'text/html') }, ], }, }, }); expect(plan.reused).toEqual(['style.css']); expect(plan.uploads.map((upload) => upload.path)).toEqual(['new.html']); expect(plan.removed).toEqual(['gone.html']); const kept = /** @type {Array<{path: string, blob: {ref: {$link: string}}, cacheControl?: string}>} */ ( plan.record.files ).find((file) => file.path === 'style.css'); // The existing ref rides along verbatim, cacheControl included expect(kept?.blob).toBe(unchanged); expect(kept?.cacheControl).toBe('public, max-age=31536000, immutable'); }); it('re-uploads when bytes changed', async () => { const old = await jsonRef('version one', 'text/html; charset=utf-8'); const plan = await planDeploy({ site: 'home', files: [{ path: 'index.html', bytes: bytes('version two') }], existing: { value: { files: [{ path: 'index.html', blob: old }] } }, }); expect(plan.reused).toEqual([]); expect(plan.uploads).toHaveLength(1); expect(plan.uploads[0].cid).not.toBe(old.ref.$link); }); it('rejects invalid site names', async () => { await expect( planDeploy({ site: 'Not_A_Label', files: [], existing: null }), ).rejects.toThrow(/DNS label/); }); it('aborts before uploading when a file exceeds the blob limit', async () => { await expect( planDeploy({ site: 'home', files: [ { path: 'small.txt', bytes: bytes('ok') }, { path: 'huge.bin', bytes: new Uint8Array(101) }, ], existing: null, blobLimit: 100, }), ).rejects.toThrow(/huge\.bin \(101 bytes\)/); }); it('aborts when the manifest outgrows the record ceiling', async () => { const files = Array.from({ length: 6000 }, (_, i) => ({ path: `deep/nested/directory/structure/file-number-${i}.html`, bytes: bytes(`content ${i}`), })); await expect( planDeploy({ site: 'home', files, existing: null }), ).rejects.toThrow(/record ceiling/); }, 60_000); it('rejects a fallback that is not deployed', async () => { await expect( planDeploy({ site: 'home', files: [{ path: 'index.html', bytes: bytes('hi') }], existing: null, fallback: 'missing.html', }), ).rejects.toThrow(/not among/); }); it('rejects duplicate paths', async () => { await expect( planDeploy({ site: 'home', files: [ { path: 'a.txt', bytes: bytes('one') }, { path: 'a.txt', bytes: bytes('two') }, ], existing: null, }), ).rejects.toThrow(/Duplicate/); }); it('carries an existing base ref through untouched', async () => { const base = { uri: 'at://did:plc:x/dev.pdsjs.site.deploy/assets', cid: 'b1', }; const plan = await planDeploy({ site: 'home', files: [{ path: 'index.html', bytes: bytes('hi') }], existing: { value: { files: [], base } }, }); expect(plan.record.base).toBe(base); }); }); describe('compileGlob', () => { it('keeps * within a segment and lets ** cross them', () => { expect(compileGlob('*.css').test('style.css')).toBe(true); expect(compileGlob('*.css').test('assets/style.css')).toBe(false); expect(compileGlob('assets/**').test('assets/deep/app.js')).toBe(true); expect(compileGlob('**/*.js').test('assets/deep/app.js')).toBe(true); expect(compileGlob('index.html').test('index2.html')).toBe(false); }); }); describe('cacheControlFor', () => { const rules = [ { value: 'public, max-age=60' }, { glob: 'assets/**', value: 'public, max-age=31536000, immutable' }, ]; it('applies the last matching rule, globless rules matching everything', () => { expect(cacheControlFor('index.html', rules)).toBe('public, max-age=60'); expect(cacheControlFor('assets/app-abc123.js', rules)).toBe( 'public, max-age=31536000, immutable', ); expect(cacheControlFor('anything', [])).toBeUndefined(); }); }); describe('planDeploy cache rules', () => { it('sets matched files, leaving unmatched ones on their record value', async () => { const kept = await jsonRef('body{}', 'text/css; charset=utf-8'); const plan = await planDeploy({ site: 'home', files: [ { path: 'index.html', bytes: bytes('

hi

') }, { path: 'style.css', bytes: bytes('body{}') }, ], existing: { value: { files: [ { path: 'style.css', blob: kept, cacheControl: 'public, max-age=5', }, ], }, }, cacheRules: [{ glob: '*.html', value: 'public, max-age=60' }], }); const entries = /** @type {Array<{path: string, cacheControl?: string}>} */ ( plan.record.files ); expect(entries.find((e) => e.path === 'index.html')?.cacheControl).toBe( 'public, max-age=60', ); // Unmatched by any rule: the reused file keeps what the record said expect(entries.find((e) => e.path === 'style.css')?.cacheControl).toBe( 'public, max-age=5', ); }); it('overrides a carried-forward value when a rule matches it', async () => { const kept = await jsonRef('body{}', 'text/css; charset=utf-8'); const plan = await planDeploy({ site: 'home', files: [{ path: 'style.css', bytes: bytes('body{}') }], existing: { value: { files: [ { path: 'style.css', blob: kept, cacheControl: 'public, max-age=5', }, ], }, }, cacheRules: [{ value: 'public, max-age=31536000, immutable' }], }); const entries = /** @type {Array<{path: string, cacheControl?: string}>} */ ( plan.record.files ); expect(entries[0].cacheControl).toBe('public, max-age=31536000, immutable'); expect(plan.uploads).toHaveLength(0); }); }); describe('checkUploadedRef', () => { it('passes matching CIDs through and rejects mismatches', async () => { const ref = await jsonRef('content', 'text/plain'); expect(checkUploadedRef({ path: 'a.txt', cid: ref.ref.$link }, ref)).toBe( ref, ); expect(() => checkUploadedRef({ path: 'a.txt', cid: 'bafyother' }, ref), ).toThrow(/Not deploying/); }); });