213 lines
8.4 KiB
TypeScript
213 lines
8.4 KiB
TypeScript
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\}/);
|
|
});
|