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/);
});
});