teach/test/frontend.test.js
Ramon 9dc896964d
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 1m11s
fix: maak mobiele opdrachten volledig zichtbaar
2026-07-23 19:05:53 +02:00

127 lines
6.1 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');
});
test('mobiele leerlingweergave volgt de zichtbare viewport zonder witte onderstrook', async () => {
const [css, core] = await Promise.all([
readFile(join(publicRoot, 'css', 'teach.css'), 'utf8'),
readFile(join(publicRoot, 'js', 'core.js'), 'utf8'),
]);
assert.match(css, /height:var\(--app-height,100dvh\)/);
assert.match(css, /body\{\s*position:relative;/);
assert.match(core, /window\.visualViewport\?\.height \|\| window\.innerHeight/);
assert.match(core, /visualViewport\?\.addEventListener\("resize",update/);
const phone = css.slice(css.indexOf('Widgetinhoud op telefoonformaat'));
assert.match(phone, /body\.pupil-mode #pupilView\{[^}]*safe-area-inset-bottom[^}]*overscroll-behavior:contain;/);
assert.match(phone, /\.widget-body\{[^}]*overflow-y:auto[^}]*-webkit-overflow-scrolling:touch;/);
assert.match(phone, /\.widget-body>:is\(\.lg,\.mt\)\{flex:none;min-height:100%\}/);
assert.match(phone, /\.lg-card\{min-height:210px\}/);
assert.match(phone, /\.mt-card\{min-height:180px\}/);
assert.match(phone, /\.lg-bottom\{min-height:0\}/);
});