Independent Staging Quality Gate / validate (pull_request) Failing after 9m45s
Independent Staging Quality Gate / publish (pull_request) Skipped
Independent Staging Quality Gate / validate (push) Failing after 9m14s
Independent Staging Quality Gate / publish (push) Skipped
Session URLs are written with history.pushState, so App Router router.push cannot leave the home page. Sidebar destinations now assign the document and stash the current conversation for return.
247 lines
14 KiB
TypeScript
247 lines
14 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 billingHookSource = readFileSync(new URL("../src/hooks/use-billing-panel.ts", 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\(\)/);
|
||
|
||
// 原值: 4 条 membershipHref 软跳 /membership
|
||
// 新值: 账单入口改 openAccountDialog("billing");离开对话的星盘/星历/报告走 document load
|
||
// 原因: 对话用 history.pushState 写 ?c=,App Router 的 router.push 无法离开首页
|
||
assert.match(pageSource, /onOpenReports=\{\(\) => router\.push\("\/reports"\)\}/);
|
||
assert.match(pageSource, /onOpenBilling=\{\(\) => openAccountDialog\("billing", \{ source: "account-menu" \}\)\}/);
|
||
assert.match(pageSource, /openAccountDialog\("billing", \{ returnTarget: event\.currentTarget, source: "credits" \}\)/);
|
||
assert.match(pageSource, /openAccountDialog\("billing", \{ source: "insufficient-credits" \}\)/);
|
||
assert.match(pageSource, /if \(response\.status === 402\) openAccountDialog\("billing", \{ source: "insufficient-credits" \}\)/);
|
||
|
||
// And: membership stays in-page; leaving chat uses a document load in the sidebar.
|
||
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, /openAccountDialog\("billing", \{ source: "insufficient-credits" \}\)/);
|
||
assert.doesNotMatch(creditGuard, /window\.location/);
|
||
const creditPush = sendSource.indexOf('openAccountDialog("billing", { source: "insufficient-credits" })');
|
||
const sendClearsComposer = sendSource.indexOf("conversationAnchor.anchorToLatest();\n setDraft(\"\");");
|
||
assert.ok(creditPush >= 0 && sendClearsComposer > creditPush);
|
||
assert.equal(pageSource.match(/openAccountDialog\("billing", \{ source: "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"\);/);
|
||
// 原值: pageSource.match(/window\.location\.assign\("\/login"\)/g)?.length === 4
|
||
// 新值: 3
|
||
// 原因: 8144fca2 删除建议问题请求,连同它的 OnboardingAuthenticationError 分支里
|
||
// 那一次 window.location.assign("/login")。少的是调用点,不是把硬跳转换成了
|
||
// router.push——同 test 里两条 doesNotMatch 仍然成立。
|
||
assert.equal(pageSource.match(/window\.location\.assign\("\/login"\)/g)?.length, 3);
|
||
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()", "return {\n refreshAccount,");
|
||
assert.match(signOut, /await selfHostedOtpActions\.signOut\(\);\n\s*window\.location\.assign\("\/login"\)/);
|
||
|
||
// And: the billing pane keeps the same 401 precedent without a dedicated membership page.
|
||
assert.doesNotMatch(billingHookSource, /import \{ useSearchParams \} from "next\/navigation"/);
|
||
assert.equal(billingHookSource.match(/window\.location\.assign\("\/login"\)/g)?.length, 4);
|
||
assert.doesNotMatch(billingHookSource, /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", () => {
|
||
// Former locks pinned the inline Tailwind button in page.tsx (`min-h-11`, `focus-visible:ring-3`,
|
||
// `shadow-md`, `pointer-events-auto`, `absolute inset-x-0 bottom-full`). That button was one of
|
||
// two jump controls with two styles, and `shadow-md` bypassed the §7 shadow token (BUG-478).
|
||
// Both surfaces now render `JumpToLatestButton`; its semantics live in the component and CSS.
|
||
const jumpControl = sourceBetween(pageSource, "{jumpToLatestVisible && (", ")}");
|
||
assert.match(jumpControl, /<JumpToLatestButton onClick=\{conversationAnchor\.anchorToLatest\} \/>/);
|
||
|
||
const jumpSource = readFileSync(new URL("../src/components/jump-to-latest-button.tsx", import.meta.url), "utf8");
|
||
const buttonTag = sourceBetween(jumpSource, "<button", ">");
|
||
const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
|
||
const buttonRule = styles.match(/\.jump-to-latest__button \{[^}]*\}/)?.[0] ?? "";
|
||
const wrapRule = styles.match(/\.jump-to-latest \{[^}]*\}/)?.[0] ?? "";
|
||
|
||
// Given: a real button in document order, never removed from the tab sequence.
|
||
assert.match(jumpSource, /<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(jumpSource, /aria-label="跳到最新"/);
|
||
assert.match(jumpSource, /\n\s*跳到最新\n/);
|
||
assert.match(styles, /\.jump-to-latest__button:focus-visible \{[^}]*outline: 3px solid/);
|
||
assert.match(buttonRule, /min-height: 44px/);
|
||
assert.match(buttonRule, /min-width: 44px/);
|
||
|
||
// And: the icon is decorative, and pointer-events never gate keyboard activation.
|
||
assert.match(jumpSource, /<ArrowDown aria-hidden="true"/);
|
||
assert.match(wrapRule, /pointer-events: none/);
|
||
assert.match(buttonRule, /pointer-events: auto/);
|
||
assert.match(jumpSource, /onClick=\{onClick\}/);
|
||
});
|