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"'); }));