import { test, expect } from 'bun:test';
import { mkdtempSync, writeFileSync, rmSync, mkdirSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createHash } from 'node:crypto';
import { createAssetManifest } from './asset-manifest.js';
const hash = value => createHash('sha256').update(value).digest('hex').slice(0, 10);
function fixture(fn) {
const dir = mkdtempSync(join(tmpdir(), 'ytp-manifest-'));
try {
writeFileSync(join(dir, 'index.html'), '');
writeFileSync(join(dir, 'app.js'), 'window.app=1');
writeFileSync(join(dir, 'app.css'), 'body{}');
writeFileSync(join(dir, 'sw.js'), "const BUILD_TAG = typeof __BUILD_TAG__ !== 'undefined' ? __BUILD_TAG__ : 'old';");
writeFileSync(join(dir, 'assets.json'), JSON.stringify({ groups: { core: { contract: 1, eager: true, files: ['/index.html', '/app.js', '/app.css'] } } }));
fn(dir);
} finally { rmSync(dir, { recursive: true, force: true }); }
}
test('manifest determinism, final served byte hashes and one-file URL isolation', () => fixture(dir => {
const first = createAssetManifest(dir);
expect(createAssetManifest(dir)).toEqual(first);
expect(first.manifest.buildTag).toMatch(/^[a-f0-9]{12}$/);
expect(first.manifest.files['/app.js']).toEqual({ h: hash('window.app=1'), s: 12, g: 'core' });
expect(first.manifest.contracts.core).toBe(1);
expect(first.manifest.groups.core.eager).toBe(true);
expect(first.index).toContain(`/app.css?v=${first.manifest.files['/app.css'].h}`);
expect(first.index).toContain(`app.js?old=1&v=${first.manifest.files['/app.js'].h}`);
expect(first.index).toContain('https://example.com/x.js');
expect(first.index).toContain(`content="${first.manifest.buildTag}"`);
expect(first.manifest.files['/index.html'].h).toBe(hash(first.index));
expect(first.manifest.files['/sw.js'].h).toBe(hash(first.sw));
writeFileSync(join(dir, 'app.js'), 'window.app=2');
const second = createAssetManifest(dir);
expect(second.manifest.buildTag).not.toBe(first.manifest.buildTag);
for (const path of ['/app.css', '/assets.json']) expect(second.manifest.files[path]).toEqual(first.manifest.files[path]);
expect(second.manifest.files['/app.js'].h).not.toBe(first.manifest.files['/app.js'].h);
}));
test('unlisted files affect build identity; group contracts and order affect identity', () => fixture(dir => {
const first = createAssetManifest(dir);
writeFileSync(join(dir, 'other.bin'), 'shipped');
const second = createAssetManifest(dir);
expect(second.manifest.buildTag).not.toBe(first.manifest.buildTag);
expect(second.manifest.files['/other.bin'].h).toBe(hash('shipped'));
writeFileSync(join(dir, 'assets.json'), JSON.stringify({ groups: { core: { contract: 2, eager: true, files: ['/app.css', '/app.js', '/index.html'] } } }));
expect(createAssetManifest(dir).manifest.buildTag).not.toBe(second.manifest.buildTag);
}));
test('invalid group definitions fail visibly', () => fixture(dir => {
writeFileSync(join(dir, 'assets.json'), JSON.stringify({ groups: { core: { contract: 1, eager: true, files: ['/missing.js'] } } }));
expect(() => createAssetManifest(dir)).toThrow('missing');
}));
test('rollback retains legacy tag algorithm and relative-only single-tag stamping', () => fixture(dir => {
const result = createAssetManifest(dir, { hashing: false });
const expected = createHash('sha256');
for (const name of ['app.css', 'app.js', 'assets.json', 'index.html', 'sw.js']) {
expected.update(`./public/${name}`);
const { readFileSync } = require('node:fs');
expected.update(readFileSync(join(dir, name)));
}
expect(result.manifest.buildTag).toBe(expected.digest('hex').slice(0, 12));
expect(result.index).toContain('href="/app.css"');
}));
test('sync rollback changes build identity and injected worker byte hash', () => fixture(dir => {
writeFileSync(join(dir,'sw.js'), "const VERSION = typeof __BUILD_TAG__ !== 'undefined' ? __BUILD_TAG__ : 'old'; const SYNC = typeof __ASSET_SYNC__ !== 'undefined' ? __ASSET_SYNC__ : true;");
const on=createAssetManifest(dir),off=createAssetManifest(dir,{assetSync:false});
expect(on.sw).toContain('const SYNC = true');expect(off.sw).toContain('const SYNC = false');
expect(on.manifest.buildTag).not.toBe(off.manifest.buildTag);
expect(off.manifest.files['/sw.js'].h).toBe(hash(off.sw));
}));
test('single-tag hashing rollback also selects the legacy worker', () => fixture(dir => {
writeFileSync(join(dir,'sw.js'), "const VERSION = typeof __BUILD_TAG__ !== 'undefined' ? __BUILD_TAG__ : 'old'; const SYNC = typeof __ASSET_SYNC__ !== 'undefined' ? __ASSET_SYNC__ : true;");
expect(createAssetManifest(dir,{hashing:false}).sw).toContain('const SYNC = false');
}));
test('index embeds build-local group hashes without recursively embedding index/SW hashes', () => fixture(dir => {
writeFileSync(join(dir, 'index.html'), '');
const result=createAssetManifest(dir);
const map=JSON.parse(result.index.match(/id="ytp-assets">([^<]+)<\/script>/)[1]);
expect(map.buildTag).toBe(result.manifest.buildTag);
expect(map.groups).toEqual(result.manifest.groups);
expect(map.files['/app.js'].h).toBe(result.manifest.files['/app.js'].h);
expect(map.files['/index.html']).toBeUndefined();
expect(map.files['/sw.js']).toBeUndefined();
expect(createAssetManifest(dir).manifest).toEqual(result.manifest);
expect(result.manifest.files['/index.html'].h).toBe(hash(result.index));
const off=createAssetManifest(dir,{hashing:false});
const legacy=JSON.parse(off.index.match(/id="ytp-assets">([^<]+)<\/script>/)[1]);
expect(legacy.files['/app.js'].h).toBe(off.manifest.buildTag);
}));
test('non-runtime files stay deliverable but never affect manifest or build identity in either mode', () => fixture(dir => {
for (const hashing of [true, false]) {
const first = createAssetManifest(dir, { hashing });
mkdirSync(join(dir, 'perf'), { recursive: true });
for (const name of ['app.test.js', 'app.test.mjs', 'README.md', 'license.txt', 'admin.html', 'perf/fixture.js', 'sha256.c', 'framework.entry.js']) {
writeFileSync(join(dir, name), 'non-runtime');
const next = createAssetManifest(dir, { hashing });
expect(next.manifest.buildTag).toBe(first.manifest.buildTag);
expect(next.manifest.files['/' + name]).toBeUndefined();
expect(next.deliveryFiles['/' + name].h).toBe(hash('non-runtime'));
expect(next.bytes.get('/' + name).toString()).toBe('non-runtime');
writeFileSync(join(dir, name), 'edited non-runtime');
expect(createAssetManifest(dir, { hashing }).manifest.buildTag).toBe(first.manifest.buildTag);
}
writeFileSync(join(dir, 'app.js'), 'changed runtime ' + hashing);
expect(createAssetManifest(dir, { hashing }).manifest.buildTag).not.toBe(first.manifest.buildTag);
}
}));
test('explicit runtime includes override exclusions and excluded group members fail visibly', () => fixture(dir => {
writeFileSync(join(dir, 'runtime.txt'), 'required at runtime');
const groups = { core: { contract: 1, eager: true, files: ['/index.html', '/app.js', '/runtime.txt'] } };
writeFileSync(join(dir, 'assets.json'), JSON.stringify({ groups }));
expect(() => createAssetManifest(dir)).toThrow('excluded');
writeFileSync(join(dir, 'assets.json'), JSON.stringify({ groups, include: ['/runtime.txt'] }));
expect(createAssetManifest(dir).manifest.files['/runtime.txt'].h).toBe(hash('required at runtime'));
}));