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(); 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"/); assert.match(layoutSource, /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"/); } });