Files
Jyotisha/frontend/tests/chat-navigation-a11y-contract.test.ts
Jesse_Chen 54269fcfcc
Independent Staging Quality Gate / validate (push) Has been cancelled
Independent Staging Quality Gate / publish (push) Has been cancelled
refactor(chat): extract home helpers, chart library, and starter surfaces
page.tsx still owns the chat main chain, but the first product surfaces now
live in their own modules so later splits can land without editing the 4k-line
Home. Source-lock tests follow the moved tokens; the orphan user-data contract
is aligned and added to the quick gate.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 22:58:02 +08:00

229 lines
13 KiB
TypeScript

import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
chatReplyAnnouncement,
chatReplyAnnouncer,
type ChatReplyPhase,
} from "../src/lib/chat-reply-announcement.ts";
import { homeSurface as pageSource } from "./home-surface.ts";
const membershipSource = readFileSync(new URL("../src/app/membership/page.tsx", import.meta.url), "utf8");
const noticeSource = readFileSync(new URL("../src/lib/chat-notice.ts", import.meta.url), "utf8");
const announcementSource = readFileSync(new URL("../src/lib/chat-reply-announcement.ts", import.meta.url), "utf8");
function sourceBetween(source: string, startMarker: string, endMarker: string) {
const start = source.indexOf(startMarker);
const end = source.indexOf(endMarker, start);
assert.notEqual(start, -1);
assert.notEqual(end, -1);
return source.slice(start, end);
}
test("in-app destinations navigate client-side so the chat survives the round trip", () => {
// Given: the chat page now owns a router.
assert.match(pageSource, /import \{ useRouter \} from "next\/navigation"/);
assert.match(pageSource, /const router = useRouter\(\)/);
// Then: every same-app destination the user comes back from is a soft push.
assert.match(pageSource, /onOpenReports=\{\(\) => router\.push\("\/reports"\)\}/);
assert.match(pageSource, /onOpenRedeem=\{\(\) => router\.push\(membershipHref\("account-menu"\)\)\}/);
assert.match(pageSource, /onClick=\{\(\) => router\.push\(membershipHref\("credits"\)\)\}/);
assert.match(pageSource, /router\.push\(membershipHref\("insufficient-credits"\)\)/);
assert.match(pageSource, /if \(response\.status === 402\) router\.push\(membershipHref\("insufficient-credits"\)\)/);
// And: none of them fall back to a document load.
assert.doesNotMatch(pageSource, /window\.location\.assign\("\/reports"\)/);
assert.doesNotMatch(pageSource, /window\.location\.assign\(membershipHref\(/);
});
test("both insufficient-credit paths are soft so the typed question is not thrown away", () => {
const creditGuard = sourceBetween(
pageSource,
"if (account.credits <= 0 && !account.activeSubscription) {",
"const [year, month, day]",
);
const sendSource = pageSource.slice(pageSource.indexOf(" async function send("));
// Given: the pre-flight guard runs before send() clears the composer draft.
assert.match(creditGuard, /router\.push\(membershipHref\("insufficient-credits"\)\)/);
assert.doesNotMatch(creditGuard, /window\.location/);
const creditPush = sendSource.indexOf('router.push(membershipHref("insufficient-credits"))');
const sendClearsComposer = sendSource.indexOf("conversationAnchor.anchorToLatest();\n setDraft(\"\");");
assert.ok(creditPush >= 0 && sendClearsComposer > creditPush);
assert.equal(pageSource.match(/router\.push\(membershipHref\("insufficient-credits"\)\)/g)?.length, 2);
});
test("auth redirects stay hard document loads so stale session state cannot survive", () => {
// Given: a 401 means the client is holding a session the server has rejected.
// Then: every login redirect is a deliberate full page load, not router.push.
// Former value: /function redirectToLogin\(\): never \{\n window\.location\.replace\("\/login"\);/
// 401 now stashes a UUID ?c= in sessionStorage so `/` can restore the session
// without adding a `next` query to the login page.
assert.match(pageSource, /function redirectToLogin\(\): never \{/);
assert.match(pageSource, /persistLoginSessionReturn\(\);\n window\.location\.replace\("\/login"\);/);
assert.equal(pageSource.match(/window\.location\.assign\("\/login"\)/g)?.length, 4);
assert.equal(pageSource.match(/window\.location\.replace\("\/login"\)/g)?.length, 1);
assert.doesNotMatch(pageSource, /router\.push\("\/login"\)/);
assert.doesNotMatch(pageSource, /router\.replace\("\/login"\)/);
// And: the login redirect after sign-out drops the whole React tree with the session.
const signOut = sourceBetween(pageSource, "async function signOut()", "function chooseSuggestedQuestion");
assert.match(signOut, /await selfHostedOtpActions\.signOut\(\);\n\s*window\.location\.assign\("\/login"\)/);
// And: the sibling membership surface keeps the same precedent.
assert.match(membershipSource, /import \{ useSearchParams \} from "next\/navigation"/);
assert.equal(membershipSource.match(/window\.location\.assign\("\/login"\)/g)?.length, 3);
assert.doesNotMatch(membershipSource, /router\.(push|replace)\("\/login"\)/);
});
test("bootstrap failure retries with a hard reload because refresh cannot rerun client bootstrap", () => {
// Given: the account, catalog and sessions are fetched from a mount-once client effect,
// which router.refresh() would not re-run because it never remounts the client tree.
assert.match(pageSource, /onClick=\{\(\) => window\.location\.reload\(\)\}>重试<\/button>/);
assert.doesNotMatch(pageSource, /router\.refresh\(\)/);
assert.match(pageSource, /void loadCloudData\(\);\n\s*return \(\) => \{[\s\S]*?\n \}, \[\]\);/);
});
test("the streaming reply announces state transitions, never streamed deltas", () => {
// Given: one page-level status region outside every aria-busy subtree.
assert.match(
pageSource,
/<main className="chat-app">\n\s*<span className="sr-only" role="status" aria-live="polite" aria-atomic="true">\{replyAnnouncement\}<\/span>/,
);
// Then: the region is fed a phase-derived string, not the streaming text.
assert.match(pageSource, /const replyAnnouncement = chatReplyAnnouncement\(replyPhase, activeReplyOutcome\?\.replyOrdinal \?\? 0\)/);
assert.doesNotMatch(announcementSource, /activeStreamingText|streamingReply|answer\.delta/);
assert.doesNotMatch(pageSource, /aria-live=[^>]*\{activeStreamingText/);
assert.doesNotMatch(pageSource, /\{activeStreamingText\}<\/span>/);
// And: the old start-only region that lived inside the busy message list is gone.
assert.doesNotMatch(pageSource, /<span className="sr-only" aria-live="polite">\{isLoading \? "Jyotisha 正在回答" : ""\}<\/span>/);
assert.match(pageSource, /<div className="message-list" aria-busy=\{isLoading\}>\n\s*<ChatTranscript/);
});
test("the reply phase covers start, completion and every terminal state", () => {
const phaseDerivation = sourceBetween(pageSource, "const replyPhase: ChatReplyPhase = isLoading", "const accountId =");
assert.match(phaseDerivation, /consultationPhase === "recovering" \? "recovering" : "generating"/);
assert.match(phaseDerivation, /activeReplyOutcome\?\.phase \?\? "idle"/);
assert.match(pageSource, /const activeReplyOutcome = replyOutcome && replyOutcome\.sessionId === activeSession\?\.id \? replyOutcome : null/);
// Given: each terminal transition records its outcome from an event handler, not an effect.
assert.match(pageSource, /setReplyOutcome\(\{\n\s*sessionId,\n\s*phase: "completed",\n\s*replyOrdinal: completedSession\.messages\.filter\(\(message\) => message\.role === "assistant"\)\.length,\n\s*\}\)/);
assert.match(pageSource, /setReplyOutcome\(\{ sessionId, phase: "failed", replyOrdinal: 0 \}\)/);
assert.match(pageSource, /setReplyOutcome\(\{ sessionId: pending\.sessionId, phase: "stopped", replyOrdinal: 0 \}\)/);
// And: a new question clears the previous outcome so a stale reply is never re-announced.
assert.match(pageSource, /setRequestError\(null\);\n\s*setReplyOutcome\(null\);\n\s*if \(!resuming\) \{\n\s*setComposerNotice\(""\)/);
});
test("completion is announced with where to find the reply, and start says one is coming", () => {
assert.equal(chatReplyAnnouncement("generating", 0), "Jyotisha 正在回答,完成后会提示你阅读。");
assert.equal(
chatReplyAnnouncement("completed", 3),
"Jyotisha 已回答完毕,第 3 条回答已显示在对话区末尾,可以开始阅读。",
);
// Given: two consecutive replies would otherwise repeat identical text and stay unspoken.
assert.notEqual(chatReplyAnnouncement("completed", 3), chatReplyAnnouncement("completed", 4));
// And: every announcement is simplified Chinese in the existing product voice.
for (const phase of ["generating", "completed"] as const) {
const text = chatReplyAnnouncement(phase, 1);
assert.match(text, /^Jyotisha /);
assert.doesNotMatch(text.replace("Jyotisha", ""), /[A-Za-z]/);
}
});
test("toast-owned and alert-owned events are deliberately silent in the status region", () => {
// Given: sonner mounts its own polite live region for every chat notice.
assert.match(noticeSource, /import \{ toast \} from "sonner"/);
// Then: stop, recovery and disconnect stay with the toast instead of double-announcing.
assert.equal(chatReplyAnnouncer("stopped"), "chat_notice_toast");
assert.equal(chatReplyAnnouncer("recovering"), "chat_notice_toast");
assert.equal(chatReplyAnnouncement("stopped", 0), "");
assert.equal(chatReplyAnnouncement("recovering", 0), "");
// And: a failed reply belongs to the assertive conversation alert, not the polite region.
assert.equal(chatReplyAnnouncer("failed"), "conversation_alert");
assert.equal(chatReplyAnnouncement("failed", 0), "");
assert.match(pageSource, /<p className="error-message" role="alert">\{activeError\}<\/p>/);
// And: only the two live-region phases ever produce spoken text.
const phases: readonly ChatReplyPhase[] = ["idle", "generating", "recovering", "completed", "stopped", "failed"];
const spoken = phases.filter((phase) => chatReplyAnnouncement(phase, 1) !== "");
assert.deepEqual(spoken, ["generating", "completed"]);
});
test("the recovered background reply tells the reader where it landed", () => {
assert.equal(pageSource.match(/回答已恢复,已显示在对话区末尾。/g)?.length, 2);
assert.doesNotMatch(pageSource, /setComposerNotice\("回答已恢复。"\)/);
});
test("assertive announcement is reserved for genuine errors", () => {
// Given: the bootstrap failure screen is the only assertive surface on the page.
assert.match(pageSource, /<main className="app-loading app-loading-error">\n\s*<div className="app-loading-content" role="alert">/);
assert.doesNotMatch(pageSource, /aria-live="assertive"/);
// And: the still-loading screen stays polite and marks itself busy.
assert.match(pageSource, /<main className="app-loading" aria-busy="true" aria-live="polite">/);
});
test("the onboarding typewriter announces the finished line once instead of each character", () => {
const onboardingMessage = sourceBetween(pageSource, "function OnboardingChatMessage(", "function isProfileComplete(");
// Given: the visibly typed text is hidden from assistive technology.
assert.match(onboardingMessage, /className=\{`onboarding-stream[^`]*`\} aria-hidden="true"/);
// Then: only the completed line reaches the polite region, as one atomic unit.
assert.match(
onboardingMessage,
/<span className="sr-only" role="status" aria-live="polite" aria-atomic="true">\{length >= text\.length \? text : ""\}<\/span>/,
);
assert.doesNotMatch(onboardingMessage, /aria-live="polite">\{visibleText\}|aria-live="polite">\{protectedVisibleText\}/);
assert.doesNotMatch(onboardingMessage, /aria-busy/);
});
test("aria-busy marks the updating message list without swallowing the status region", () => {
// Given: the activity status inside a streamed row is itself a role="status" region.
const activitySource = readFileSync(
new URL("../src/components/agent-activity-status.tsx", import.meta.url),
"utf8",
);
assert.match(activitySource, /role="status"/);
// Then: aria-busy on the message list suppresses that per-phase chatter while streaming,
// and the reply status region sits outside it so completion is still spoken.
assert.match(pageSource, /<div className="message-list" aria-busy=\{isLoading\}>/);
const messageList = sourceBetween(pageSource, '<div className="message-list" aria-busy={isLoading}>', "</div>");
assert.doesNotMatch(messageList, /aria-live|role="status"/);
});
test("the jump-to-latest control stays keyboard reachable and labelled", () => {
const jumpControl = sourceBetween(pageSource, "{jumpToLatestVisible && (", "</button>");
const buttonTag = sourceBetween(jumpControl, "<button", ">");
// Given: a real button in document order, never removed from the tab sequence.
assert.match(jumpControl, /<button\b/);
assert.match(buttonTag, /type="button"/);
assert.doesNotMatch(buttonTag, /tabIndex/);
assert.doesNotMatch(buttonTag, /aria-hidden/);
assert.doesNotMatch(buttonTag, /disabled/);
// And: a visible label, a matching accessible name, a focus ring and a 44px target.
assert.match(jumpControl, /aria-label="跳到最新"/);
assert.match(jumpControl, /\n\s*跳到最新\n/);
assert.match(jumpControl, /focus-visible:ring-3/);
assert.match(jumpControl, /min-h-11/);
assert.match(jumpControl, /min-w-11/);
// And: the icon is decorative, and pointer-events never gate keyboard activation.
assert.match(jumpControl, /<ArrowDown aria-hidden="true"/);
assert.match(jumpControl, /pointer-events-auto/);
assert.match(jumpControl, /onClick=\{conversationAnchor\.anchorToLatest\}/);
});