Add server characterization tests for build tag, index stamping and cache headers
This commit is contained in:
@@ -6,7 +6,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "bun server.js",
|
"start": "bun server.js",
|
||||||
"dev": "bun --hot 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": {
|
"dependencies": {
|
||||||
"@hono/node-server": "^1.14.0",
|
"@hono/node-server": "^1.14.0",
|
||||||
|
|||||||
158
server/static-delivery.test.js
Normal file
158
server/static-delivery.test.js
Normal file
@@ -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'), '<!doctype html><meta name="ytp-build" content="__BUILD_TAG__"><link rel="stylesheet" href="app.css"><script src="app.js"></script><script src="https://cdn.example/x.js"></script><link rel="stylesheet" href="/root.css">');
|
||||||
|
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(`<meta name="ytp-build" content="${buildTag}">`);
|
||||||
|
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(`<meta name="ytp-build" content="${buildTag}">`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user