fix(web): 次级页稳定外壳与缓存预取,跨部署导航自愈(BUG-966/967)
This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
hrefFromLinkTarget,
|
||||
interceptAppNavigation,
|
||||
isStaleClientBuild,
|
||||
navigateAppPath,
|
||||
readHealthGitCommit,
|
||||
rememberServerBuildCommit,
|
||||
resetAppNavigationForTests,
|
||||
setAppNavigationHostForTests,
|
||||
setClientBuildCommitForTests,
|
||||
} from "../src/lib/app-navigation.ts";
|
||||
import {
|
||||
peekChartPage,
|
||||
peekEphemerisPage,
|
||||
peekReportsPage,
|
||||
prefetchSecondaryPage,
|
||||
refreshChartPage,
|
||||
refreshEphemerisPage,
|
||||
refreshReportsPage,
|
||||
resetSecondaryPageDataForTests,
|
||||
writeChartPage,
|
||||
} from "../src/lib/secondary-page-data.ts";
|
||||
import { cssDeclarations } from "./css-contract-test-support.ts";
|
||||
|
||||
const chartView = readFileSync(new URL("../src/components/chart-page/chart-page-view.tsx", import.meta.url), "utf8");
|
||||
const ephemerisPage = readFileSync(new URL("../src/components/ephemeris/ephemeris-page.tsx", import.meta.url), "utf8");
|
||||
const reportsCenter = readFileSync(new URL("../src/components/personal-report/personal-report-center.tsx", import.meta.url), "utf8");
|
||||
const sidebar = readFileSync(new URL("../src/components/app-sidebar.tsx", import.meta.url), "utf8");
|
||||
const layout = readFileSync(new URL("../src/app/layout.tsx", import.meta.url), "utf8");
|
||||
const dockerfile = readFileSync(new URL("../../deploy/railway-web.Dockerfile", import.meta.url), "utf8");
|
||||
const globalStyles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
|
||||
const chartHook = readFileSync(new URL("../src/hooks/use-chart-page.ts", import.meta.url), "utf8");
|
||||
|
||||
function clickEvent(overrides: Partial<{
|
||||
button: number;
|
||||
metaKey: boolean;
|
||||
ctrlKey: boolean;
|
||||
shiftKey: boolean;
|
||||
altKey: boolean;
|
||||
defaultPrevented: boolean;
|
||||
}> = {}) {
|
||||
let defaultPrevented = overrides.defaultPrevented ?? false;
|
||||
return {
|
||||
button: overrides.button ?? 0,
|
||||
metaKey: overrides.metaKey ?? false,
|
||||
ctrlKey: overrides.ctrlKey ?? false,
|
||||
shiftKey: overrides.shiftKey ?? false,
|
||||
altKey: overrides.altKey ?? false,
|
||||
get defaultPrevented() {
|
||||
return defaultPrevented;
|
||||
},
|
||||
preventDefault() {
|
||||
defaultPrevented = true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("the three entry pages import the shared SecondaryPageShell", () => {
|
||||
for (const source of [chartView, ephemerisPage, reportsCenter]) {
|
||||
assert.match(source, /from "@\/components\/secondary-page-shell"/);
|
||||
assert.match(source, /<SecondaryPageShell/);
|
||||
}
|
||||
assert.doesNotMatch(chartView, /<SecondaryHeader title="星盘"/);
|
||||
assert.doesNotMatch(ephemerisPage, /<SecondaryHeader/);
|
||||
assert.doesNotMatch(reportsCenter, /<SecondaryHeader/);
|
||||
assert.doesNotMatch(chartView, /InlineSpinner|正在加载|skeleton/i);
|
||||
assert.doesNotMatch(ephemerisPage, /InlineSpinner|正在加载|skeleton/i);
|
||||
assert.doesNotMatch(reportsCenter, /正在读取报告|正在加载/);
|
||||
});
|
||||
|
||||
test("the secondary-page body is a stable remaining-viewport cell", () => {
|
||||
const page = cssDeclarations(".secondary-page", globalStyles);
|
||||
assert.match(page, /display:\s*grid/);
|
||||
assert.match(page, /min-height:\s*0/);
|
||||
const waitingParent = cssDeclarations(".secondary-page:has(> .secondary-page-waiting)", globalStyles);
|
||||
assert.match(waitingParent, /align-content:\s*center/);
|
||||
const waiting = cssDeclarations(".secondary-page-waiting", globalStyles);
|
||||
assert.match(waiting, /text-align:\s*center/);
|
||||
assert.doesNotMatch(waiting, /spinner|skeleton/i);
|
||||
});
|
||||
|
||||
test("a warm chart cache does not go through a null waiting state", async () => {
|
||||
resetSecondaryPageDataForTests();
|
||||
assert.equal(peekChartPage(), null);
|
||||
writeChartPage({
|
||||
kind: "view",
|
||||
view: { status: "chart_unavailable", billed: false, message: "这一张盘还没拿到。" },
|
||||
});
|
||||
const warm = peekChartPage();
|
||||
assert.equal(warm?.kind, "view");
|
||||
assert.notEqual(warm, null);
|
||||
const secondMountView = peekChartPage()?.kind === "view" ? peekChartPage() : null;
|
||||
assert.equal(secondMountView?.kind, "view");
|
||||
assert.match(chartHook, /useState<ChartViewResponse \| null>\(\(\) => viewFromSnapshot\(peekChartPage\(\)\)\)/);
|
||||
});
|
||||
|
||||
test("chart / ephemeris / reports caches share one inflight fetch and keep the last result", async () => {
|
||||
resetSecondaryPageDataForTests();
|
||||
const originalFetch = globalThis.fetch;
|
||||
let chartCalls = 0;
|
||||
let ephemerisCalls = 0;
|
||||
let reportsCalls = 0;
|
||||
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url.includes("/api/chart-view")) {
|
||||
chartCalls += 1;
|
||||
return new Response("{}", { status: 401 });
|
||||
}
|
||||
if (url.includes("/api/ephemeris")) {
|
||||
ephemerisCalls += 1;
|
||||
return new Response("{}", { status: 401 });
|
||||
}
|
||||
if (url.includes("/api/reports")) {
|
||||
reportsCalls += 1;
|
||||
return new Response("{}", { status: 401 });
|
||||
}
|
||||
throw new Error(`unexpected fetch ${url}`);
|
||||
}) as typeof fetch;
|
||||
try {
|
||||
prefetchSecondaryPage("/chart");
|
||||
prefetchSecondaryPage("/ephemeris");
|
||||
prefetchSecondaryPage("/reports");
|
||||
const [chart, ephemeris, reports] = await Promise.all([
|
||||
refreshChartPage(),
|
||||
refreshEphemerisPage(),
|
||||
refreshReportsPage(),
|
||||
]);
|
||||
assert.equal(chart.kind, "unauthenticated");
|
||||
assert.equal(ephemeris.kind, "unauthorized");
|
||||
assert.equal(reports.kind, "unauthorized");
|
||||
assert.equal(chartCalls, 1);
|
||||
assert.equal(ephemerisCalls, 1);
|
||||
assert.equal(reportsCalls, 1);
|
||||
assert.equal(peekChartPage()?.kind, "unauthenticated");
|
||||
assert.equal(peekEphemerisPage()?.kind, "unauthorized");
|
||||
assert.equal(peekReportsPage()?.kind, "unauthorized");
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
resetSecondaryPageDataForTests();
|
||||
}
|
||||
});
|
||||
|
||||
test("sidebar prefetches the three entries on pointerenter and pointerdown", () => {
|
||||
assert.match(sidebar, /prefetchSecondaryPage\(href\)/);
|
||||
assert.match(sidebar, /onPointerEnter=\{\(\) => prefetchSecondaryPage\(href\)\}/);
|
||||
assert.match(sidebar, /onPointerDown=\{\(\) => prefetchSecondaryPage\(href\)\}/);
|
||||
assert.match(sidebar, /from "@\/lib\/secondary-page-data"/);
|
||||
});
|
||||
|
||||
test("version mismatch navigates with location.assign; a match keeps the client router", () => {
|
||||
resetAppNavigationForTests();
|
||||
const assigned: string[] = [];
|
||||
setAppNavigationHostForTests({ assign: (href) => assigned.push(href) });
|
||||
setClientBuildCommitForTests("old-sha");
|
||||
rememberServerBuildCommit("new-sha");
|
||||
assert.equal(isStaleClientBuild(), true);
|
||||
|
||||
const staleClick = clickEvent();
|
||||
interceptAppNavigation(staleClick, "/chart");
|
||||
assert.equal(staleClick.defaultPrevented, true);
|
||||
assert.deepEqual(assigned, ["/chart"]);
|
||||
|
||||
assigned.length = 0;
|
||||
navigateAppPath("/ephemeris", (href) => assigned.push(`client:${href}`));
|
||||
assert.deepEqual(assigned, ["/ephemeris"]);
|
||||
|
||||
assigned.length = 0;
|
||||
rememberServerBuildCommit("old-sha");
|
||||
assert.equal(isStaleClientBuild(), false);
|
||||
const freshClick = clickEvent();
|
||||
interceptAppNavigation(freshClick, "/reports");
|
||||
assert.equal(freshClick.defaultPrevented, false);
|
||||
assert.equal(assigned.length, 0);
|
||||
navigateAppPath("/reports", (href) => assigned.push(`client:${href}`));
|
||||
assert.deepEqual(assigned, ["client:/reports"]);
|
||||
|
||||
assigned.length = 0;
|
||||
rememberServerBuildCommit(null);
|
||||
interceptAppNavigation(clickEvent(), "/chart");
|
||||
assert.equal(assigned.length, 0);
|
||||
resetAppNavigationForTests();
|
||||
});
|
||||
|
||||
test("modified clicks and health-shaped payloads stay out of the hard-nav path", () => {
|
||||
resetAppNavigationForTests();
|
||||
const assigned: string[] = [];
|
||||
setAppNavigationHostForTests({ assign: (href) => assigned.push(href) });
|
||||
setClientBuildCommitForTests("old-sha");
|
||||
rememberServerBuildCommit("new-sha");
|
||||
interceptAppNavigation(clickEvent({ metaKey: true }), "/chart");
|
||||
interceptAppNavigation(clickEvent({ button: 1 }), "/chart");
|
||||
interceptAppNavigation(clickEvent(), "https://example.com");
|
||||
assert.equal(assigned.length, 0);
|
||||
assert.equal(readHealthGitCommit({ deployment: { gitCommit: "abc" } }), "abc");
|
||||
assert.equal(readHealthGitCommit({ status: "ok" }), null);
|
||||
assert.equal(hrefFromLinkTarget("/chart"), "/chart");
|
||||
assert.equal(hrefFromLinkTarget({ pathname: "/reports", search: "?x=1" }), "/reports?x=1");
|
||||
resetAppNavigationForTests();
|
||||
});
|
||||
|
||||
test("the root layout checks health on visibility, not on a timer", () => {
|
||||
const guard = readFileSync(new URL("../src/components/stale-build-guard.tsx", import.meta.url), "utf8");
|
||||
assert.match(layout, /StaleBuildGuard/);
|
||||
assert.match(guard, /visibilitychange/);
|
||||
assert.match(guard, /syncBuildCommitFromHealth/);
|
||||
assert.doesNotMatch(guard, /setInterval|setTimeout/);
|
||||
assert.match(dockerfile, /NEXT_PUBLIC_GIT_COMMIT=\$\{NEXT_DEPLOYMENT_ID\}/);
|
||||
});
|
||||
@@ -228,13 +228,18 @@ test("the same component renders read-only when `/` is not the one mounting it",
|
||||
// `.session-main` — and drop only the menu trigger and its reserved column.
|
||||
const sessionRow = readProjectFile("src/components/sidebar-session-row.tsx");
|
||||
assert.match(sessionRow, /<div className="session-row" data-readonly="true">/);
|
||||
assert.match(sessionRow, /<Link\n\s*className="session-main"/);
|
||||
// 原值:只读行是 `<Link className="session-main">`,页脚是 `<Link className="profile-trigger" href="/">`
|
||||
// 新值:两者都改成 `AppLink`,属性与 href 一字未改
|
||||
// 原因:BUG-967。跨部署旧标签页上 Next 的客户端导航会静默失败;AppLink 在版本不一致时
|
||||
// 走 `window.location.assign`,一致时仍是客户端路由。断言主语(只读行仍是
|
||||
// `.session-main` 链接、页脚仍去 `/`、没有菜单按钮)没变。
|
||||
assert.match(sessionRow, /<AppLink\n\s*className="session-main"/);
|
||||
assert.match(cssBlock('.session-row[data-readonly="true"]'), /grid-template-columns:\s*minmax\(0,\s*1fr\)/);
|
||||
const readonlyRow = sessionRow.slice(sessionRow.indexOf('data-readonly="true"'), sessionRow.indexOf("const {\n disabled,"));
|
||||
assert.doesNotMatch(readonlyRow, /session-menu-trigger/);
|
||||
|
||||
// The footer is the same 56px `.profile-trigger`, as a link with no chevron.
|
||||
assert.match(appSidebar, /<Link\n\s*className="profile-trigger"\n\s*href="\/"/);
|
||||
assert.match(appSidebar, /<AppLink\n\s*className="profile-trigger"\n\s*href="\/"/);
|
||||
|
||||
// And nothing in read-only mode can write: the write callbacks only exist
|
||||
// inside `controls`, which the secondary layout never passes.
|
||||
|
||||
Reference in New Issue
Block a user