Files
Jyotisha/frontend/tests/chat-navigation-a11y-contract.test.ts
T
Jesse_Chen 6a0394aa57 fix(web): update consult source contracts for the declared-window path
BUG-341 added a third agentic consultation stream and renamed the homepage draft entrypoint, but the exact-count contracts still described two paths, so staging validate failed before publish.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-21 17:59:07 +08:00

225 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";
const pageSource = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
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.
assert.match(pageSource, /function redirectToLogin\(\): never \{\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*\{chatMessageViews\(/);
});
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\}/);
});