6a0394aa57
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>
225 lines
13 KiB
TypeScript
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\}/);
|
|
});
|