Move the home startup sequence and consultation recovery poll into plain functions, and the error and onboarding screens into components. Landing rules, the 20s fatal screen, retry, and the chart settings deep link stay the same.
116 lines
6.0 KiB
TypeScript
116 lines
6.0 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"/);
|
|
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"/);
|
|
}
|
|
});
|