Files
Codeman/test/static-cache-headers.test.ts
T
RandalixandClaude Opus 5.5 aa4217b8b1 test: build every in-process WebServer on port 0 (#440)
The files on the guard's legacy list build new WebServer(0, …) and read server.boundPort after start(), as sse-tile-grid-filter does. Files with several servers read each server's own port; quick-start's local helper lost its port parameter so the literal 0 sits where the server is built. Header comments that named a port say "ephemeral". No assertion changed except where it embedded the port.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 16:28:26 +02:00

85 lines
3.8 KiB
TypeScript

/**
* @fileoverview `@fastify/static`'s `setHeaders` callback is what sets Cache-Control
* on every asset Codeman serves, and v10 silently changed its contract.
*
* In v9 the first argument was a Node `ServerResponse`, so the body called
* `res.setHeader(...)`. In v10 it is a `FastifyReply`, which has no `setHeader`, so
* the v9 body throws `TypeError: res.setHeader is not a function` from inside
* `@fastify/static` on EVERY static request. Nothing in the type-checker catches a
* revert (the callback's parameter is inferred), and nothing else in the suite reads
* these headers, so without this file the whole caching contract is untested.
*
* That contract is load-bearing: assets are served `immutable` for a year, and
* `index.html` must revalidate every time or a deploy leaves browsers on stale
* markup (see `cacheBustAssets` in server.ts). No HTML is left behind the static
* plugin (every page has its own route), so the HTML half is asserted on the route.
*
* These tests drive a REAL WebServer on purpose. Asserting against an inline
* re-registration of the plugin would keep passing after a revert in server.ts.
*/
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { WebServer } from '../src/web/server.js';
/**
* Fetch and fully drain the body. Both halves matter for teardown: an unconsumed
* body leaves undici holding the socket, and a pooled keep-alive socket makes
* `server.stop()` wait for it, which times out the afterAll hook.
*/
async function get(url: string): Promise<Response> {
const res = await fetch(url, { headers: { connection: 'close' } });
await res.arrayBuffer();
return res;
}
describe('static asset Cache-Control headers', () => {
let server: WebServer;
let baseUrl: string;
beforeAll(async () => {
server = new WebServer(0, false, true);
await server.start();
baseUrl = `http://localhost:${server.boundPort}`;
});
afterAll(async () => {
await server.stop();
}, 60000);
it('serves a static asset at all (proves setHeaders did not throw)', async () => {
// The v9-form regression surfaces here first: @fastify/static invokes setHeaders
// while streaming, so a TypeError inside it takes the response down rather than
// merely omitting a header.
const res = await get(`${baseUrl}/app.js`);
expect(res.status).toBe(200);
});
it('marks long-lived assets immutable', async () => {
const res = await get(`${baseUrl}/app.js`);
expect(res.headers.get('cache-control')).toBe('public, max-age=31536000, immutable');
});
it('lets a route keep the Cache-Control it set, so sw.js stays uncached', async () => {
// Regression guard for the OTHER half of the v10 change. setHeaders runs for
// sendFile() too, and v10 moved it from the raw response onto the reply, which
// flipped precedence: the callback started overriding routes instead of being
// overridden by them. That gave /sw.js `immutable, max-age=31536000` in place of
// the `no-cache, no-store` its route sets — a service worker pinned for a year,
// which is unrecoverable from the server side because clients stop asking.
// Measured against v9 to confirm this is the historical behaviour, not a guess.
const res = await get(`${baseUrl}/sw.js`);
expect(res.status).toBe(200);
expect(res.headers.get('cache-control')).toBe('no-cache, no-store');
});
it('serves the rendered index with no-cache too, via its own route', async () => {
// Different code path from the above, same contract: index.html is generated per
// request (cacheBustAssets stamps ?v= onto every asset ref), so caching it would
// pin browsers to the asset versions current at deploy time.
const res = await get(`${baseUrl}/index.html`);
expect(res.status).toBe(200);
expect(res.headers.get('cache-control')).toContain('no-cache');
});
});