Files
ytplayer/server/static-delivery.test.js

245 lines
13 KiB
JavaScript

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><script type="application/json" id="ytp-assets">{}</script><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(hashing = true, sync = 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', ASSET_SYNC: sync ? '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 relative and root-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(`<meta name="ytp-build" content="${buildTag}">`);
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');
expect((await fetch(`${server.base}/index.html?v=${buildTag}`)).headers.get('cache-control')).toBe('no-cache');
expect((await fetch(`${server.base}/fonts/font.woff2?v=${buildTag}`)).headers.get('cache-control')).toBe('public, max-age=2592000');
expect((await fetch(`${server.base}/fonts/font.woff2?v=stale`)).headers.get('cache-control')).toBe('public, max-age=2592000');
} finally { await stopServer(server); server = await startServer(); }
});
test('manifest compression preserves build ETag and sync mode is injected', async () => {
const version=await (await fetch(`${server.base}/api/version`)).json();
const response=await fetch(`${server.base}/api/manifest`,{headers:{'Accept-Encoding':'gzip'}});
expect(response.headers.get('etag')).toBe(`"${version.buildTag}"`);
expect(response.headers.get('content-encoding')).toBe('gzip');
expect((await response.json()).buildTag).toBe(version.buildTag);
writeFileSync(join(publicDir,'sw.js'), "const BUILD_TAG = typeof __BUILD_TAG__ !== 'undefined' ? __BUILD_TAG__ : 'v-test'; const SYNC = typeof __ASSET_SYNC__ !== 'undefined' ? __ASSET_SYNC__ : true;");
await stopServer(server);server=await startServer(true,false);
expect(await (await fetch(`${server.base}/sw.js`)).text()).toContain('const SYNC = false');
await stopServer(server);server=await startServer(true,true);
expect(await (await fetch(`${server.base}/sw.js`)).text()).toContain('const SYNC = true');
});
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('served bytes remain pinned to their boot-time hashes', async () => {
const before = await (await fetch(`${server.base}/app.js`)).text();
const swBefore = await (await fetch(`${server.base}/sw.js`)).text();
writeFileSync(join(publicDir, 'app.js'), 'changed after boot');
writeFileSync(join(publicDir, 'sw.js'), 'changed after boot');
try {
expect(await (await fetch(`${server.base}/app.js`)).text()).toBe(before);
expect(await (await fetch(`${server.base}/sw.js`)).text()).toBe(swBefore);
} finally {
writeFileSync(join(publicDir, 'app.js'), before);
writeFileSync(join(publicDir, 'sw.js'), "const BUILD_TAG = 'missing-placeholder';\n");
}
});
test('all index routes carry the identical embedded build-local map', async () => {
const manifest=await (await fetch(`${server.base}/api/manifest`)).json();
let expected;
for(const path of ['/', '/index.html','/search','/playlist/x']) {
const html=await (await fetch(`${server.base}${path}`)).text();
const map=JSON.parse(html.match(/id="ytp-assets">([^<]+)<\/script>/)[1]);
expect(map.buildTag).toBe(manifest.buildTag);
expect(map.groups).toEqual(manifest.groups);
expect(map.files['/app.js'].h).toBe(manifest.files['/app.js'].h);
expect(map.files['/index.html']).toBeUndefined();
if(expected)expect(html).toBe(expected);else expected=html;
}
});
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}">`);
}
});
});