Files
Jyotisha/frontend/tests/stale-client-recovery.test.ts
T
Jesse_ChenandClaude Fable 5.1 fb77c86585 test(ui): 侧栏只读模式、共享外壳与列表缓存的合同回归
新增 8 条:`sidebar-data-cache.test.ts`(命中不重拉 / 过期重拉一次 / 写操作后
拿到新标题并逐条锁住五个写路径 / 双账户不串 / 只存内存 / 401 清空)、
`sidebar-state.test.ts` +2(收起后重挂仍收起,含三种降级;移动端不读不写)、
`sidebar-contract.test.ts` +1(只读模式只少三样)、`chart-page-view.test.tsx` +1
(`(secondary)` layout 恰好挂一份只读侧栏,数据 hook 无写方法)。

改写 9 处既有断言,每处带「原值 / 新值 / 原因」三栏注释,均未削弱:
`window.location.assign(path)` → `<SidebarMenuLink href>` 并追加反向断言;
`onOpenReports` / `useRouter` 改成 doesNotMatch;`SecondaryShell` → `SecondaryHeader`;
导航顺序改在 `NAV_PAGES` 常量里量;两个 render 辅助改为裹 `SidebarProvider`
(provider 上移到 layout);三处源码路径跟随路由组移动。

测试总数 3391 → 3399,失败清单与基线逐条一致(47 条均为无 Docker 的既有缺口)。

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0193vBv6w5MV2cifdTUu9H5P
2026-09-16 11:23:11 +00:00

111 lines
5.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/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/(secondary)/reports/page.tsx`
// 原因:四个次级路由移进 `(secondary)` 路由组共享一份外壳(TASK-sidebar-unify D2)。
// 路由组括号不进 URL,`/reports` 一字未改;这里改的只是源码位置。
const reportsPage = readFileSync(new URL("../src/app/(secondary)/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"/);
}
});