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, / { 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\(\(\) => 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\}/); });