import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { createServer } from 'node:net'; const repo = resolve(import.meta.dir, '..'); const serverFile = join(repo, 'server', 'server.js'); const root = mkdtempSync(join(tmpdir(), 'ytp-static-delivery-')); const publicDir = join(root, 'public'); const dataDir = join(root, 'data'); mkdirSync(publicDir); mkdirSync(dataDir); mkdirSync(join(publicDir, 'fonts')); mkdirSync(join(publicDir, 'icons')); writeFileSync(join(publicDir, 'index.html'), ''); writeFileSync(join(publicDir, 'app.css'), 'body{}'); writeFileSync(join(publicDir, 'app.js'), 'window.app=true;'); writeFileSync(join(publicDir, 'root.css'), 'body{color:red}'); writeFileSync(join(publicDir, 'sw.js'), "const BUILD_TAG = typeof __BUILD_TAG__ !== 'undefined' ? __BUILD_TAG__ : 'v-test';\n"); writeFileSync(join(publicDir, 'fonts', 'font.woff2'), 'font'); writeFileSync(join(publicDir, 'icons', 'icon.png'), 'icon'); async function freePort() { return await new Promise((resolvePort, reject) => { const probe = createServer(); probe.once('error', reject); probe.listen(0, '127.0.0.1', () => { const port = probe.address().port; probe.close((error) => error ? reject(error) : resolvePort(port)); }); }); } async function startServer(hashing = true) { const port = await freePort(); const child = Bun.spawn([process.execPath, serverFile], { cwd: root, env: { ...process.env, PORT: String(port), ASSET_HASHING: hashing ? '1' : '0', BUILD_TAG: '', DB_PATH: join(dataDir, `db-${port}.sqlite`), MEDIA_DIR: join(dataDir, 'media'), UPLOAD_DIR: join(dataDir, 'uploads') }, stdout: 'pipe', stderr: 'pipe', }); const stdout = new Response(child.stdout).text(); const stderr = new Response(child.stderr).text(); const base = `http://127.0.0.1:${port}`; const deadline = Date.now() + 20000; while (Date.now() < deadline) { if (child.exitCode !== null) throw new Error(`server exited (${child.exitCode})\n${await stdout}\n${await stderr}`); try { if ((await fetch(`${base}/api/version`)).ok) return { child, base, stdout, stderr }; } catch {} await Bun.sleep(100); } child.kill(); throw new Error(`server did not start\n${await stdout}\n${await stderr}`); } async function stopServer(server) { if (!server) return; server.child.kill(); await server.child.exited; await server.stdout; await server.stderr; } let server; beforeAll(async () => { server = await startServer(); }); afterAll(async () => { await stopServer(server); rmSync(root, { recursive: true, force: true }); }); describe('static delivery characterization', () => { test('build tag is stable for the same tree and changes when any served file changes', async () => { const first = await (await fetch(`${server.base}/api/version`)).json(); await stopServer(server); server = await startServer(); const same = await (await fetch(`${server.base}/api/version`)).json(); expect(same.buildTag).toBe(first.buildTag); writeFileSync(join(publicDir, 'unlisted.txt'), 'included in recursive build tag'); await stopServer(server); server = await startServer(); const changed = await (await fetch(`${server.base}/api/version`)).json(); expect(changed.buildTag).not.toBe(first.buildTag); }); test('unreadable public tree uses the fixed dev-build fallback', async () => { const moved = join(root, 'public.saved'); Bun.spawnSync(['mv', publicDir, moved]); let fallback; try { await stopServer(server); server = await startServer(); fallback = await (await fetch(`${server.base}/api/version`)).json(); } finally { await stopServer(server); Bun.spawnSync(['mv', moved, publicDir]); server = await startServer(); } expect(fallback.buildTag).toBe('dev-build'); }); test('index stamps only relative local css/js and serves expected cache headers', async () => { const index = await fetch(`${server.base}/`); const html = await index.text(); const { buildTag, files } = await (await fetch(`${server.base}/api/manifest`)).json(); expect(html).toContain(``); expect(html).toContain(`href="app.css?v=${files['/app.css'].h}"`); expect(html).toContain(`src="app.js?v=${files['/app.js'].h}"`); expect(html).toContain('https://cdn.example/x.js'); expect(html).toContain(`href="/root.css?v=${files['/root.css'].h}"`); expect(index.headers.get('cache-control')).toBe('no-cache'); expect((await fetch(`${server.base}/index.html`)).headers.get('cache-control')).toBe('no-cache'); const current = await fetch(`${server.base}/app.js?v=${files['/app.js'].h}`, { headers: { 'Accept-Encoding': 'br' } }); expect(current.headers.get('cache-control')).toBe('public, max-age=31536000, immutable'); expect(current.headers.get('content-encoding')).toBe('br'); expect(current.headers.get('etag')).toBeTruthy(); expect((await fetch(`${server.base}/app.js?v=stale`)).headers.get('cache-control')).toBe('no-store'); expect((await fetch(`${server.base}/app.js`)).headers.get('cache-control')).toBe('no-cache'); expect((await fetch(`${server.base}/fonts/font.woff2`)).headers.get('cache-control')).toBe('public, max-age=2592000'); expect((await fetch(`${server.base}/icons/icon.png`)).headers.get('cache-control')).toBe('public, max-age=2592000'); for (const path of ['/api/version', '/sw.js']) { expect((await fetch(`${server.base}${path}`)).headers.get('cache-control')).toBe('no-store, no-cache, must-revalidate'); } const etag = current.headers.get('etag'); const notModified = await fetch(`${server.base}/app.js?v=${files['/app.js'].h}`, { headers: { 'If-None-Match': etag } }); expect(notModified.status).toBe(304); expect(await notModified.text()).toBe(''); }); test('manifest API, ETag/304 and hashes cover compressed and binary assets', async () => { const response = await fetch(`${server.base}/api/manifest`); expect(response.headers.get('cache-control')).toBe('no-store'); const manifest = await response.json(); expect(manifest.buildTag).toBe((await (await fetch(`${server.base}/api/version`)).json()).buildTag); expect(manifest.groups.core.eager).toBe(true); expect(manifest.contracts.core).toBe(1); expect(response.headers.get('etag')).toBe(`"${manifest.buildTag}"`); const unchanged = await fetch(`${server.base}/api/manifest`, { headers: { 'If-None-Match': response.headers.get('etag') } }); expect(unchanged.status).toBe(304); expect(await unchanged.text()).toBe(''); const { createHash } = await import('node:crypto'); for (const path of ['/app.js', '/fonts/font.woff2', '/icons/icon.png', '/index.html', '/sw.js']) { const h = manifest.files[path].h; for (const query of ['', `?v=${h}`, '?v=oldTag', '?__ytpfresh=123', '?v=']) { const asset = await fetch(`${server.base}${path}${query}`); expect(asset.status).toBe(200); expect(asset.headers.get('x-asset-hash')).toBe(h); expect(createHash('sha256').update(Buffer.from(await asset.arrayBuffer())).digest('hex').slice(0, 10)).toBe(h); if (path !== '/sw.js') { if (query === `?v=${h}`) expect(asset.headers.get('cache-control')).toBe('public, max-age=31536000, immutable'); if (query === '?v=oldTag' || query === '?v=') expect(asset.headers.get('cache-control')).toBe('no-store'); } } } const canonical = await (await fetch(`${server.base}/`)).text(); for (const path of ['/index.html', '/search', '/playlist/x']) expect(await (await fetch(`${server.base}${path}`)).text()).toBe(canonical); }); test('ASSET_HASHING=0 restores single-tag stamping and old cache matrix', async () => { await stopServer(server); server = await startServer(false); try { const { buildTag } = await (await fetch(`${server.base}/api/version`)).json(); const html = await (await fetch(`${server.base}/`)).text(); expect(html).toContain(`src="app.js?v=${buildTag}"`); expect(html).toContain('href="/root.css"'); expect((await fetch(`${server.base}/app.js?v=${buildTag}`)).headers.get('cache-control')).toBe('public, max-age=31536000, immutable'); expect((await fetch(`${server.base}/app.js?v=stale`)).headers.get('cache-control')).toBe('no-cache'); } finally { await stopServer(server); server = await startServer(); } }); test('service worker injection tracks fallback literal and reports missing placeholder', async () => { let sw = await (await fetch(`${server.base}/sw.js`)).text(); const { buildTag } = await (await fetch(`${server.base}/api/version`)).json(); expect(sw).toContain(`const BUILD_TAG = "${buildTag}";`); writeFileSync(join(publicDir, 'sw.js'), "const BUILD_TAG = typeof __BUILD_TAG__ !== 'undefined' ? __BUILD_TAG__ : 'v-next-literal';\n"); await stopServer(server); server = await startServer(); sw = await (await fetch(`${server.base}/sw.js`)).text(); expect(sw).toContain(`const BUILD_TAG = "${(await (await fetch(`${server.base}/api/version`)).json()).buildTag}";`); expect(sw).not.toContain('__BUILD_TAG__'); writeFileSync(join(publicDir, 'sw.js'), "const BUILD_TAG = 'missing-placeholder';\n"); await stopServer(server); server = await startServer(); const response = await fetch(`${server.base}/sw.js`); expect(await response.text()).toContain('missing-placeholder'); // The startup process logs the existing server.js diagnostic when injection misses. server.child.kill(); const stderr = await server.stderr; await server.child.exited; expect(stderr).toContain('[sw] BUILD_TAG injection failed — placeholder not found in sw.js'); server = await startServer(); }); test('SPA routes fall back to the stamped index', async () => { const { buildTag } = await (await fetch(`${server.base}/api/version`)).json(); for (const path of ['/search', '/playlist/x']) { const response = await fetch(`${server.base}${path}`); expect(response.status).toBe(200); expect(await response.text()).toContain(``); } }); });