teach/test/frontend.test.js
Ramon 456d664718
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 1m14s
perf: compressie, cache-headers, cache-busting en parallel ladende scripts (v0.3.81-beta)
- @fastify/compress (brotli quality 4 + gzip, global): alle statische assets én API-JSON (zoals /me/data) gaan gecomprimeerd over de lijn - eerste lading van ~715 KB naar grofweg een derde
- Nieuw src/frontend.js: index.html wordt bij boot één keer gelezen, ?v=__V__ wordt vervangen door de app-versie en de pagina wordt uit het geheugen geserveerd met no-cache
- Cache-beleid per prefix: js/css een jaar immutable (veilig dankzij de geversioneerde URL's), fonts 7 dagen, afbeeldingen 1 dag met zwakke etag als 304-vangnet - herhaalbezoek kost vrijwel geen bytes meer
- Alle 31 scripts staan nu met defer in de head: parallel downloaden, uitvoeren in dezelfde dwingende volgorde (core → widgets → board → app); font-preload bewust ongeversioneerd (moet byte-gelijk blijven aan de url() in teach.css)
- Compress geregistreerd vóór de api/frontend-registraties (onSend-hook geldt alleen voor latere routes) - geborgd met een teksttest
- 6 nieuwe tests (test/frontend.test.js): versie-injectie, cache-headers per prefix, gzip+brotli round-trip, 304 op etag, defer/?v= op elk script

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0149FgUQvuwxKKEdvQGmNngF
2026-07-16 19:01:25 +02:00

109 lines
5 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import zlib from 'node:zlib';
import { readFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import Fastify from 'fastify';
import fastifyCompress from '@fastify/compress';
import { registerFrontend, resolveAppVersion } from '../src/frontend.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const publicRoot = join(__dirname, '..', 'public');
// Zelfde volgorde als src/server.js: compress vóór de frontend-registratie.
async function makeApp(version = '1.2.3-test') {
const app = Fastify();
await app.register(fastifyCompress, {
global: true,
encodings: ['br', 'gzip'],
threshold: 1024,
brotliOptions: { params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 4 } },
zlibOptions: { level: 6 },
});
await registerFrontend(app, { root: publicRoot, version });
await app.ready();
return app;
}
test('index.html wordt geserveerd met versie-injectie en no-cache', async () => {
const app = await makeApp();
const res = await app.inject({ url: '/' });
assert.equal(res.statusCode, 200);
assert.match(res.headers['content-type'], /text\/html/);
assert.equal(res.headers['cache-control'], 'no-cache');
assert.ok(res.payload.includes('?v=1.2.3-test'), 'versie hoort geïnjecteerd te zijn');
assert.ok(!res.payload.includes('__V__'), 'geen placeholder meer in de uitvoer');
const alias = await app.inject({ url: '/index.html' });
assert.equal(alias.headers['cache-control'], 'no-cache');
await app.close();
});
test('js en css zijn een jaar immutable; fonts en img korter zonder immutable', async () => {
const app = await makeApp();
for (const url of ['/js/core.js', '/css/teach.css']) {
const res = await app.inject({ url });
assert.equal(res.statusCode, 200, url);
assert.equal(res.headers['cache-control'], 'public, max-age=31536000, immutable', url);
}
const font = await app.inject({ url: '/fonts/quicksand.woff2' });
assert.equal(font.headers['cache-control'], 'public, max-age=604800');
assert.ok(font.headers.etag, 'etag als 304-vangnet voor ongeversioneerde assets');
const img = await app.inject({ url: '/img/logo.svg' });
assert.equal(img.headers['cache-control'], 'public, max-age=86400');
await app.close();
});
test('assets gaan gecomprimeerd over de lijn (gzip én brotli)', async () => {
const app = await makeApp();
const gz = await app.inject({ url: '/css/teach.css', headers: { 'accept-encoding': 'gzip' } });
assert.equal(gz.headers['content-encoding'], 'gzip');
assert.match(gz.headers.vary ?? '', /accept-encoding/i);
const cssRaw = zlib.gunzipSync(gz.rawPayload).toString('utf8');
assert.ok(cssRaw.includes('toolbar'), 'gzip-inhoud is geldige css');
const br = await app.inject({ url: '/js/core.js', headers: { 'accept-encoding': 'br' } });
assert.equal(br.headers['content-encoding'], 'br');
const jsRaw = zlib.brotliDecompressSync(br.rawPayload).toString('utf8');
assert.ok(jsRaw.includes('const VERSION'), 'brotli-inhoud is geldige js');
assert.ok(br.rawPayload.length < jsRaw.length / 2, 'compressie levert echt winst op');
await app.close();
});
test('conditional GET levert 304 zonder body', async () => {
const app = await makeApp();
const first = await app.inject({ url: '/fonts/quicksand.woff2' });
const res = await app.inject({ url: '/fonts/quicksand.woff2', headers: { 'if-none-match': first.headers.etag } });
assert.equal(res.statusCode, 304);
assert.equal(res.rawPayload.length, 0);
await app.close();
});
test('resolveAppVersion geeft de echte versie door en valt lokaal nooit stale terug', () => {
assert.equal(resolveAppVersion({ APP_VERSION: '0.4.0-beta' }), '0.4.0-beta');
const a = resolveAppVersion({});
const b = resolveAppVersion({ APP_VERSION: 'dev' });
assert.match(a, /^dev-\d+$/);
assert.match(b, /^dev-\d+$/);
});
test('index.html: elk script heeft defer + cache-busting, font-preload blijft ongeversioneerd', async () => {
const [html, server] = await Promise.all([
readFile(join(publicRoot, 'index.html'), 'utf8'),
readFile(join(__dirname, '..', 'src', 'server.js'), 'utf8'),
]);
const scripts = [...html.matchAll(/<script [^>]*src="([^"]+)"[^>]*>/g)];
assert.ok(scripts.length >= 30, 'alle app-scripts staan in index.html');
for (const [tag, src] of scripts) {
assert.match(src, /\?v=__V__$/, `script zonder cache-busting: ${src}`);
assert.match(tag, / defer/, `script zonder defer: ${src}`);
}
assert.match(html, /href="css\/teach\.css\?v=__V__"/);
/* de preload-URL moet byte-gelijk blijven aan de url() in teach.css,
anders downloadt de browser het font dubbel */
assert.match(html, /rel="preload" href="fonts\/quicksand\.woff2" /);
/* hook-volgorde: compress moet vóór de api- en frontend-registraties staan */
assert.ok(server.indexOf('fastifyCompress') < server.indexOf('register(api'),
'compress hoort vóór de api-registratie');
assert.ok(server.indexOf('fastifyCompress') < server.indexOf('registerFrontend(app'),
'compress hoort vóór de frontend-registratie');
});