9c296f1e3f
Framework-level UX fixes found while auditing staging (BUG-216..220).
- chat: route 44 previously discarded composer notices to sonner with
dedupe, so recovery, cancel and archive feedback is actually visible
(BUG-216)
- chat: anchor stream auto-scroll to bottom proximity and add a
jump-to-latest control, so reading history is no longer interrupted
on every token (BUG-218)
- reports: replace the silent 120s poll cutoff with an explicit
timed-out state, an 8m budget, stepped backoff and an elapsed
counter (BUG-217)
- reports: pause polling while the tab is hidden, via a shared hook
- app: add root error, global-error and not-found boundaries (BUG-219)
- admin: add antd SSR style extraction and the React 19 render adapter,
and move admin-only css out of the global stylesheet (BUG-220)
- membership: run bootstrap fetches concurrently and pause payment
polling while hidden
- build: configure optimizePackageImports
Verified on top of 2d370f2e: tsc, eslint, next build, and the related
frontend contract suites.
Co-authored-by: Cursor <cursoragent@cursor.com>
148 lines
7.5 KiB
TypeScript
148 lines
7.5 KiB
TypeScript
import assert from "node:assert/strict";
|
||
import { readFileSync } from "node:fs";
|
||
import test from "node:test";
|
||
|
||
import {
|
||
formatWaitedDuration,
|
||
POLL_BUDGET_MS,
|
||
pollIntervalForElapsed,
|
||
} from "../src/components/personal-report/personal-report-page.tsx";
|
||
|
||
const pageSource = readFileSync(
|
||
new URL("../src/components/personal-report/personal-report-page.tsx", import.meta.url),
|
||
"utf8",
|
||
);
|
||
const centerSource = readFileSync(
|
||
new URL("../src/components/personal-report/personal-report-center.tsx", import.meta.url),
|
||
"utf8",
|
||
);
|
||
const hookSource = readFileSync(
|
||
new URL("../src/hooks/use-visibility-aware-poll.ts", import.meta.url),
|
||
"utf8",
|
||
);
|
||
|
||
test("an exhausted poll budget becomes an explicit state instead of an endless spinner", () => {
|
||
assert.match(pageSource, /\| \{ phase: "timed-out" \}/, "ReportLoadState carries an explicit timed-out phase");
|
||
assert.match(pageSource, /setState\(\(current\) => \(current\.phase === "generating" \? \{ phase: "timed-out" \} : current\)\)/);
|
||
assert.match(pageSource, /if \(state\.phase === "timed-out"\)/, "the timed-out phase has its own render branch");
|
||
// The old silent give-up must be gone.
|
||
assert.doesNotMatch(pageSource, /MAX_POLLS/);
|
||
assert.doesNotMatch(pageSource, /polls >= /);
|
||
});
|
||
|
||
test("the timed-out branch is honest and offers both resume and the report center", () => {
|
||
const branchAt = pageSource.indexOf('if (state.phase === "timed-out")');
|
||
assert.ok(branchAt >= 0, "timed-out branch must exist");
|
||
const branchEnd = pageSource.indexOf('if (state.phase === "unauthorized")');
|
||
assert.ok(branchEnd > branchAt, "the timed-out branch must be self-contained");
|
||
const branch = pageSource.slice(branchAt, branchEnd);
|
||
assert.match(branch, /生成时间超出预期/);
|
||
assert.match(branch, /已等待 \{formatWaitedDuration\(waitedMs\)\}/);
|
||
assert.match(branch, /页面已暂停自动刷新。/, "we must say polling stopped rather than imply it continues");
|
||
assert.match(branch, /报告仍在后台生成/);
|
||
assert.match(branch, /继续等待/);
|
||
assert.match(branch, /onClick=\{\(\) => keepWaiting\(\)\}/);
|
||
assert.match(branch, /href="\/reports"/);
|
||
assert.match(branch, /返回报告中心/);
|
||
assert.doesNotMatch(branch, /[!!]/, "copy carries no exclamation marks");
|
||
});
|
||
|
||
test("keepWaiting restarts the wait clock and immediately refetches", () => {
|
||
const resumeAt = pageSource.indexOf("const keepWaiting = useCallback(");
|
||
assert.ok(resumeAt >= 0);
|
||
const resume = pageSource.slice(resumeAt, resumeAt + 320);
|
||
assert.match(resume, /setWaitStartedAt\(Date\.now\(\)\)/);
|
||
assert.match(resume, /setWaitedMs\(0\)/);
|
||
assert.match(resume, /setState\(\{ phase: "generating" \}\)/);
|
||
assert.match(resume, /void load\(\)/);
|
||
});
|
||
|
||
test("the wait budget is an explicit wall clock well beyond the old 120s", () => {
|
||
assert.equal(POLL_BUDGET_MS, 8 * 60 * 1000);
|
||
assert.ok(POLL_BUDGET_MS > 120_000, "120s was too short for report generation");
|
||
assert.match(pageSource, /export const POLL_BUDGET_MS = 8 \* 60 \* 1000;/);
|
||
});
|
||
|
||
test("polling backs off without slowing the first minute", () => {
|
||
assert.equal(pollIntervalForElapsed(0), 3000);
|
||
assert.equal(pollIntervalForElapsed(59_999), 3000);
|
||
assert.equal(pollIntervalForElapsed(60_000), 6000);
|
||
assert.equal(pollIntervalForElapsed(179_999), 6000);
|
||
assert.equal(pollIntervalForElapsed(180_000), 10_000);
|
||
assert.equal(pollIntervalForElapsed(360_000), 15_000);
|
||
assert.equal(pollIntervalForElapsed(POLL_BUDGET_MS), 15_000);
|
||
|
||
let previous = 0;
|
||
for (let elapsed = 0; elapsed <= POLL_BUDGET_MS; elapsed += 1000) {
|
||
const interval = pollIntervalForElapsed(elapsed);
|
||
assert.ok(interval >= previous, `interval must never shrink at ${elapsed}ms`);
|
||
assert.ok(interval >= 3000 && interval <= 15_000, `interval stays within 3s..15s at ${elapsed}ms`);
|
||
previous = interval;
|
||
}
|
||
|
||
// A full budget must cost far fewer requests than a flat 3s interval would.
|
||
let requests = 0;
|
||
for (let elapsed = 0; elapsed < POLL_BUDGET_MS; elapsed += pollIntervalForElapsed(elapsed)) {
|
||
requests += 1;
|
||
}
|
||
assert.ok(requests < POLL_BUDGET_MS / 3000, "backoff must reduce the request count");
|
||
assert.ok(requests < 80, `a full wait should stay under 80 requests, got ${requests}`);
|
||
|
||
assert.match(pageSource, /intervalMs: pollIntervalForElapsed\(waitedMs\)/);
|
||
});
|
||
|
||
test("elapsed wait is rendered in Simplified Chinese minutes and seconds", () => {
|
||
assert.equal(formatWaitedDuration(0), "0 秒");
|
||
assert.equal(formatWaitedDuration(9_400), "9 秒");
|
||
assert.equal(formatWaitedDuration(60_000), "1 分 0 秒");
|
||
assert.equal(formatWaitedDuration(130_000), "2 分 10 秒");
|
||
assert.equal(formatWaitedDuration(-5), "0 秒");
|
||
assert.match(pageSource, /已等待 \{formatWaitedDuration\(waitedMs\)\}/);
|
||
const generatingAt = pageSource.indexOf('{generating ? "报告正在生成中,请稍候…"');
|
||
assert.ok(generatingAt >= 0, "the generating spinner still exists");
|
||
assert.match(pageSource.slice(generatingAt, generatingAt + 700), /已等待 \{formatWaitedDuration\(waitedMs\)\}/);
|
||
});
|
||
|
||
test("the shared hook pauses on hidden, refreshes on visible and always cleans up", () => {
|
||
assert.match(hookSource, /export function useVisibilityAwarePoll/);
|
||
assert.match(hookSource, /document\.hidden/);
|
||
assert.match(hookSource, /document\.addEventListener\("visibilitychange", handleVisibilityChange\)/);
|
||
assert.match(hookSource, /document\.removeEventListener\("visibilitychange", handleVisibilityChange\)/);
|
||
assert.match(hookSource, /if \(document\.hidden\) \{\s*stop\(\);\s*return;\s*\}/);
|
||
assert.match(hookSource, /if \(refreshOnVisible\) \{\s*pollRef\.current\(\);\s*\}/);
|
||
assert.match(hookSource, /window\.clearInterval\(timer\)/);
|
||
// The callback lives in a ref so an inline closure cannot restart the interval every render.
|
||
assert.match(hookSource, /const pollRef = useRef\(onPoll\)/);
|
||
assert.match(hookSource, /pollRef\.current = onPoll;/);
|
||
assert.match(hookSource, /\}, \[enabled, intervalMs, refreshOnVisible\]\);/);
|
||
// Reusable: no report-specific imports.
|
||
assert.doesNotMatch(hookSource, /personal-report|\/api\/reports/);
|
||
});
|
||
|
||
test("both report surfaces poll through the visibility-aware hook and nothing else", () => {
|
||
for (const [name, source] of [["page", pageSource], ["center", centerSource]] as const) {
|
||
assert.match(
|
||
source,
|
||
/import \{ useVisibilityAwarePoll \} from "@\/hooks\/use-visibility-aware-poll";/,
|
||
`${name} imports the shared hook`,
|
||
);
|
||
assert.match(source, /useVisibilityAwarePoll\(\{/, `${name} calls the shared hook`);
|
||
assert.doesNotMatch(source, /setInterval\(/, `${name} must not hand-roll an interval poll`);
|
||
assert.doesNotMatch(source, /addEventListener\("visibilitychange"/, `${name} must not duplicate the listener`);
|
||
}
|
||
assert.match(centerSource, /enabled: hasGenerating,/);
|
||
assert.match(centerSource, /intervalMs: LIST_POLL_INTERVAL_MS,/);
|
||
assert.match(centerSource, /const LIST_POLL_INTERVAL_MS = 3000;/);
|
||
assert.match(pageSource, /enabled: generating,/);
|
||
});
|
||
|
||
test("cancellation guards survive the rewrite and no poll runs outside the generating phase", () => {
|
||
assert.match(pageSource, /const cancelledRef = useRef\(false\)/);
|
||
assert.match(pageSource, /if \(cancelledRef\.current\) \{\s*return;\s*\}/);
|
||
assert.match(pageSource, /cancelledRef\.current = true;/);
|
||
assert.match(centerSource, /const cancelled = useRef\(false\)/);
|
||
assert.match(centerSource, /if \(cancelled\.current\) return;/);
|
||
assert.match(centerSource, /cancelled\.current = true;/);
|
||
assert.match(pageSource, /const generating = state\.phase === "generating";/);
|
||
});
|