diff --git a/server/package.json b/server/package.json index 6a6375e..b2371ae 100644 --- a/server/package.json +++ b/server/package.json @@ -6,7 +6,7 @@ "scripts": { "start": "bun server.js", "dev": "bun --hot server.js", - "test": "bun test ./recommendations.test.js && bun test --timeout 60000 ./media-cache.test.js && bun test ./notes.test.js && bun test ./transcriptions.test.js && bun test ./admin-analytics.test.js && bun test ./remote.test.js && bun test ./party.test.js && bun test ./uploads.test.js && bun test ./innertube.test.js && bun test ./related.test.js && bun test ./ytdlp-pool.test.js && bun test ./warm-queue.test.js && bun test ./p2p-db.test.js && bun test ./p2p-admit.test.js && bun test ./p2p-retention.test.js && bun test ./p2p-routes.test.js && bun test ./p2p-hub.test.js && bun test --timeout 60000 ./p2p-intake.test.js && bun test ./flags.test.js" + "test": "bun test ./recommendations.test.js && bun test --timeout 60000 ./media-cache.test.js && bun test ./notes.test.js && bun test ./transcriptions.test.js && bun test ./admin-analytics.test.js && bun test ./remote.test.js && bun test ./party.test.js && bun test ./uploads.test.js && bun test ./innertube.test.js && bun test ./related.test.js && bun test ./ytdlp-pool.test.js && bun test ./warm-queue.test.js && bun test ./p2p-db.test.js && bun test ./p2p-admit.test.js && bun test ./p2p-retention.test.js && bun test ./p2p-routes.test.js && bun test ./p2p-hub.test.js && bun test --timeout 60000 ./p2p-intake.test.js && bun test ./flags.test.js && bun test ./static-delivery.test.js" }, "dependencies": { "@hono/node-server": "^1.14.0", diff --git a/server/static-delivery.test.js b/server/static-delivery.test.js new file mode 100644 index 0000000..f3d7a0a --- /dev/null +++ b/server/static-delivery.test.js @@ -0,0 +1,158 @@ +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() { + const port = await freePort(); + const child = Bun.spawn([process.execPath, serverFile], { + cwd: root, + env: { ...process.env, PORT: String(port), 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 } = await (await fetch(`${server.base}/api/version`)).json(); + expect(html).toContain(``); + expect(html).toContain(`href="app.css?v=${buildTag}"`); + expect(html).toContain(`src="app.js?v=${buildTag}"`); + expect(html).toContain('https://cdn.example/x.js'); + expect(html).toContain('href="/root.css"'); + 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=${buildTag}`, { 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-cache'); + 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=${buildTag}`, { headers: { 'If-None-Match': etag } }); + expect(notModified.status).toBe(304); + expect(await notModified.text()).toBe(''); + }); + + 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(``); + } + }); +});