import { test } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, writeFileSync, mkdirSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
/*
* What may be served stale, and what may not.
*
* This is not a preference about freshness. The service worker is the app's
* whole update mechanism: a browser holding an old one goes on being served
* the shell that worker knows and never finds out a deploy happened. On
* 2026-09-08 the origin had the new build while Cloudflare handed out the
* previous `sw.js` for hours, because it was neither a hashed asset nor HTML
* and so went out with an hour's max-age that the CDN then extended.
*
* A static root of our own, since CI runs the tests before the build and
* `web/dist` does not exist yet.
*/
const root = mkdtempSync(join(tmpdir(), "ihasmail-cache-"));
mkdirSync(join(root, "assets"));
writeFileSync(join(root, "assets", "app-a1b2c3.js"), "console.log(1)\n");
writeFileSync(join(root, "sw.js"), "/* worker */\n");
writeFileSync(join(root, "manifest.webmanifest"), `{"name":"ihasmail"}`);
writeFileSync(join(root, "index.html"), "
t");
writeFileSync(join(root, "img.png"), "not really a png");
process.env.STATIC_DIR = root;
process.env.STALWART_URL = "http://127.0.0.1:1";
const { createApp } = await import("./app.js");
const cacheControl = async (path: string) => {
const res = await createApp().request(path);
assert.equal(res.status, 200, `${path} should be served`);
return res.headers.get("cache-control") ?? "";
};
test("the service worker is never served from a cache without asking", async () => {
// `no-cache` permits storing it and requires revalidating it, which is a 304
// and costs nothing. What it forbids is a browser or a CDN answering with
// its own copy, which is the whole failure.
assert.match(await cacheControl("/sw.js"), /no-cache/);
});
test("nor is the manifest, which the worker has to agree with", async () => {
// A fresh manifest advertising a share target, answered by a worker that has
// never heard of one, sends the share to the server for a 405. Either being
// old is survivable; the two disagreeing is not.
assert.match(await cacheControl("/manifest.webmanifest"), /no-cache/);
});
test("the manifest is still served as a manifest", async () => {
const res = await createApp().request("/manifest.webmanifest");
assert.match(res.headers.get("content-type") ?? "", /application\/manifest\+json/);
});
test("index.html was already revalidated, and still is", async () => {
assert.match(await cacheControl("/"), /no-cache/);
});
test("hashed assets are still immutable for a year", async () => {
// The name changes when the bytes do, so there is nothing to go stale --
// and this is the caching that makes the app load quickly at all.
const cc = await cacheControl("/assets/app-a1b2c3.js");
assert.match(cc, /immutable/);
assert.match(cc, /max-age=31536000/);
});
test("everything else keeps its ordinary hour", async () => {
// The rule is narrow on purpose: two files, named, rather than a policy that
// quietly stops the icons and fonts being cached too.
assert.match(await cacheControl("/img.png"), /max-age=3600/);
});
test("under a prefix, the worker is still the worker", async () => {
// The mount comes off before the path is matched, so this has to hold for a
// subpath deployment as well -- where a stale worker is exactly as bad.
const res = await createApp("/mail").request("/mail/sw.js");
assert.equal(res.status, 200);
assert.match(res.headers.get("cache-control") ?? "", /no-cache/);
});