Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
120 lines
6.4 KiB
TypeScript
120 lines
6.4 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/(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");
|
|
// 原值:`../src/app/reports/page.tsx`
|
|
// 新值:`../src/app/(app)/reports/page.tsx`
|
|
// 原因:四个次级路由移进 `(secondary)` 路由组共享一份外壳(TASK-sidebar-unify D2)。
|
|
// 路由组括号不进 URL,`/reports` 一字未改;这里改的只是源码位置。
|
|
const reportsPage = readFileSync(new URL("../src/app/(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"/)
|
|
// 新值: 断言不再 force-dynamic
|
|
// 原因: BUG-1123——/reports 是不读 cookies 的客户端外壳,force-dynamic 让每次点「我的报告」
|
|
// 先等一轮服务端渲染;与 BUG-716 的 /chart 同样改为静态外壳,数据由客户端取。
|
|
assert.doesNotMatch(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"\]/);
|
|
// 原值: page.tsx 里 `clearStaleClientReload(sessionStorage)`。
|
|
// 新值: 首页把 `storage: sessionStorage` 交给 runHomeBootstrap,函数内 `clearStaleClientReload(deps.storage)`。
|
|
// 原因: 清的仍是会话存储,不是别的 storage。
|
|
const bootstrapRun = readFileSync(new URL("../src/lib/home-bootstrap-run.ts", import.meta.url), "utf8");
|
|
assert.match(pageSource, /storage: sessionStorage/);
|
|
assert.match(bootstrapRun, /clearStaleClientReload\(deps\.storage\)/);
|
|
});
|
|
|
|
test("a login redirect leaves the bootstrap timeout running so a failed navigation can escape the spinner", () => {
|
|
// 原值: 从 page.tsx 的 loadCloudData 切片。
|
|
// 新值: 同一段 try/finally 在 home-bootstrap-run.ts。
|
|
// 原因: 登录跳转仍不清除 20 秒超时,失败的导航还能离开加载屏。
|
|
const bootstrap = readFileSync(new URL("../src/lib/home-bootstrap-run.ts", import.meta.url), "utf8");
|
|
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"/);
|
|
}
|
|
});
|