Files
Jyotisha/frontend/tests/stale-client-recovery.test.ts
T
Jesse_Chen 3198fb6b2b
Independent Staging Quality Gate / validate (push) Successful in 10m14s
Independent Staging Quality Gate / publish (push) Successful in 11m5s
perf(frontend): split chat streaming, load Inter, isolate admin CSS
Settled messages no longer rebuild on every token, Inter is actually
requested, and admin routes drop the 33 KB chat stylesheet. Root
force-dynamic is gone so public shells can prerender without changing
the no-store Cache-Control contract.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-29 03:39:43 +08:00

107 lines
5.1 KiB
TypeScript

import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
STALE_CLIENT_RELOAD_KEY,
clearStaleClientReload,
consumeStaleClientReload,
isChunkLoadFailure,
shouldReloadFrozenBootstrap,
} from "../src/lib/stale-client-recovery.ts";
const pageSource = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const layoutSource = readFileSync(new URL("../src/app/layout.tsx", import.meta.url), "utf8");
const nextConfig = readFileSync(new URL("../next.config.ts", import.meta.url), "utf8");
const recoverySource = readFileSync(new URL("../src/components/stale-client-recovery.tsx", import.meta.url), "utf8");
const stagingCaddy = readFileSync(new URL("../../deploy/Caddyfile.staging", import.meta.url), "utf8");
const productionCaddy = readFileSync(new URL("../../deploy/Caddyfile.production.selfhosted", import.meta.url), "utf8");
const publicCaddy = readFileSync(new URL("../../deploy/Caddyfile", import.meta.url), "utf8");
test("chunk load and missing Next static files are treated as a stale client", () => {
assert.equal(isChunkLoadFailure("Loading chunk 123 failed"), true);
assert.equal(isChunkLoadFailure("ChunkLoadError: Loading chunk app/page failed"), true);
assert.equal(isChunkLoadFailure("Failed to fetch dynamically imported module: https://staging.jyotisha.chat/_next/static/chunks/app/page.js"), true);
assert.equal(isChunkLoadFailure("https://staging.jyotisha.chat/_next/static/chunks/main-app.js"), true);
assert.equal(isChunkLoadFailure("暂时无法读取账户信息"), false);
});
test("a stale client reloads once per tab and can retry after a successful bootstrap", () => {
const storage = new Map<string, string>();
const adapter = {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => {
storage.set(key, value);
},
removeItem: (key: string) => {
storage.delete(key);
},
};
assert.equal(consumeStaleClientReload(adapter), true);
assert.equal(storage.get(STALE_CLIENT_RELOAD_KEY), "1");
assert.equal(consumeStaleClientReload(adapter), false);
clearStaleClientReload(adapter);
assert.equal(consumeStaleClientReload(adapter), true);
});
test("a frozen loading shell on pageshow is reloaded, but the error screen is not", () => {
assert.equal(shouldReloadFrozenBootstrap({
persisted: true,
loadingBusy: true,
loadingError: false,
}), true);
assert.equal(shouldReloadFrozenBootstrap({
persisted: false,
loadingBusy: true,
loadingError: false,
}), false);
assert.equal(shouldReloadFrozenBootstrap({
persisted: true,
loadingBusy: true,
loadingError: true,
}), false);
});
test("the web build stamps the Git SHA as Next deploymentId and does not cache the chat HTML", () => {
assert.match(nextConfig, /deploymentId:\s*process\.env\.NEXT_DEPLOYMENT_ID/);
assert.match(nextConfig, /source: "\/"/);
assert.match(nextConfig, /source: "\/login"/);
assert.match(nextConfig, /Cache-Control["'],\s*value: "private, no-store, must-revalidate"/);
// Root force-dynamic was locking every route into ƒ Dynamic, including shells
// that never read cookies(). Chat HTML stays uncached via the headers above;
// routes that actually need a request stay dynamic on their own modules.
assert.doesNotMatch(layoutSource, /export const dynamic = "force-dynamic"/);
const loginPage = readFileSync(new URL("../src/app/login/page.tsx", import.meta.url), "utf8");
const reportsPage = readFileSync(new URL("../src/app/reports/page.tsx", import.meta.url), "utf8");
const adminLayout = readFileSync(new URL("../src/app/admin/layout.tsx", import.meta.url), "utf8");
assert.match(loginPage, /export const dynamic = "force-dynamic"/);
assert.match(reportsPage, /export const dynamic = "force-dynamic"/);
assert.match(adminLayout, /export const dynamic = "force-dynamic"/);
assert.match(layoutSource, /StaleClientRecovery/);
});
test("edge and layout recover a stale client without looping forever", () => {
assert.match(recoverySource, /consumeStaleClientReload/);
assert.match(recoverySource, /window\.location\.reload\(\)/);
assert.match(recoverySource, /pageshow/);
assert.match(recoverySource, /main\.app-loading\[aria-busy="true"\]/);
assert.match(pageSource, /clearStaleClientReload\(sessionStorage\)/);
});
test("a login redirect leaves the bootstrap timeout running so a failed navigation can escape the spinner", () => {
const bootstrap = pageSource.slice(
pageSource.indexOf("async function loadCloudData()"),
pageSource.indexOf("void loadCloudData();"),
);
assert.match(bootstrap, /if \(caught instanceof LoginRedirectError\) \{\n\s*redirectedToLogin = true;\n\s*return;/);
assert.match(bootstrap, /if \(redirectedToLogin\) return;/);
assert.match(bootstrap, /window\.clearTimeout\(bootstrapTimeout\)/);
});
test("user-facing Caddy files do not let browsers keep the chat HTML across releases", () => {
for (const caddy of [stagingCaddy, productionCaddy, publicCaddy]) {
assert.match(caddy, /@htmlDocuments path \/ \/login/);
assert.match(caddy, /header @htmlDocuments Cache-Control "private, no-store, must-revalidate"/);
}
});