import { test, expect } from 'bun:test';
import { mkdtempSync, writeFileSync, rmSync } 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');
}));