perf(chat): isolate the composer, split heavy chunks, fix dead tokens
Second batch from the staging UX audit (BUG-248..253). - css: expose the 32 palette tokens through @theme. Seven utilities including text-ink, text-danger and text-warning compiled to no CSS at all, so 30 call sites had been silently inert (BUG-248) - chat: move the composer into its own component behind a draft store, so a keystroke no longer re-renders a 2723-line component, and persist the draft across reloads (BUG-249) - chat: load gsap, react-markdown and thinking-orbs on demand. First Load JS for / drops 549.5 kB to 476.3 kB gzipped (BUG-250) - chat: route the five in-app destinations through router.push, and keep the five auth redirects and the bootstrap retry as hard loads on purpose (BUG-251) - a11y: announce reply completion, and move the live region out of the aria-busy subtree that was likely suppressing even the start announcement (BUG-252) - docs: give the 27 collided bug ids unique numbers and repair their inbound references; require search rather than a full read of a 3690-line file (BUG-253) Verified: tsc, eslint, next build, and 1592 assertions across the 199 non-database test files. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import { plainParagraphs } from "../src/components/chat-message-paragraphs.ts";
|
||||
|
||||
const readProjectFile = (path: string) => readFileSync(new URL(`../${path}`, import.meta.url), "utf8");
|
||||
const contentSource = readProjectFile("src/components/chat-message-content.tsx");
|
||||
const markdownViewSource = readProjectFile("src/components/chat-markdown-view.tsx");
|
||||
const messageRowSource = readProjectFile("src/components/chat-message-row.tsx");
|
||||
const activitySource = readProjectFile("src/components/agent-activity-status.tsx");
|
||||
const prefetchSource = readProjectFile("src/components/chat-chunk-prefetch.ts");
|
||||
|
||||
test("the markdown pipeline is split out of the initial chat bundle", () => {
|
||||
// Given: the chat surface statically imports the message content component.
|
||||
assert.doesNotMatch(contentSource, /^import .*"react-markdown"/m);
|
||||
assert.doesNotMatch(contentSource, /^import .*"remark-gfm"/m);
|
||||
|
||||
// Then: the renderer only arrives through a dynamic import.
|
||||
assert.match(contentSource, /import\("@\/components\/chat-markdown-view"\)/);
|
||||
assert.match(contentSource, /markdownRenderer = module\.renderChatMarkdown/);
|
||||
assert.match(markdownViewSource, /^import ReactMarkdown, \{ type Components \} from "react-markdown";$/m);
|
||||
assert.match(markdownViewSource, /^import remarkGfm from "remark-gfm";$/m);
|
||||
assert.match(markdownViewSource, /remarkPlugins=\{\[remarkGfm\]\}/);
|
||||
assert.match(markdownViewSource, /skipHtml/);
|
||||
});
|
||||
|
||||
test("the pre-markdown fallback renders prose but never raw markdown", () => {
|
||||
// Given: a reply that carries markdown syntax.
|
||||
assert.equal(plainParagraphs("**重点**在这里"), null);
|
||||
assert.equal(plainParagraphs("## 阶段判断"), null);
|
||||
assert.equal(plainParagraphs("- 第一点"), null);
|
||||
assert.equal(plainParagraphs("1. 第一点"), null);
|
||||
assert.equal(plainParagraphs("| 宫位 | 行星 |"), null);
|
||||
assert.equal(plainParagraphs("`Vimshottari`"), null);
|
||||
assert.equal(plainParagraphs("参见 https://example.com"), null);
|
||||
assert.equal(plainParagraphs("> 引用"), null);
|
||||
assert.equal(plainParagraphs("[链接](/x)"), null);
|
||||
assert.equal(plainParagraphs("行尾软换行 \n下一行"), null);
|
||||
|
||||
// Then: only plain prose takes the fallback path, split exactly like CommonMark.
|
||||
assert.deepEqual(plainParagraphs("第一段。\n\n第二段。"), ["第一段。", "第二段。"]);
|
||||
assert.deepEqual(plainParagraphs("一行\n下一行"), ["一行\n下一行"]);
|
||||
assert.deepEqual(plainParagraphs("只有一段。"), ["只有一段。"]);
|
||||
|
||||
// And: one stable wrapper serves both paths, so no styling or caret is lost on upgrade.
|
||||
assert.equal(contentSource.match(/className="message-markdown"/g)?.length, 1);
|
||||
assert.match(contentSource, /<p key=\{index\}>\{paragraph\}<\/p>/);
|
||||
assert.match(contentSource, /plainParagraphs\(text\) \?\? \[\]/);
|
||||
});
|
||||
|
||||
test("gsap loads on demand while keeping the reduced-motion gate", () => {
|
||||
// Given: the animation engine is no longer part of the static import graph.
|
||||
assert.doesNotMatch(messageRowSource, /^import .*from "gsap"/m);
|
||||
assert.doesNotMatch(messageRowSource, /@gsap\/react/);
|
||||
assert.match(messageRowSource, /import\("gsap"\)/);
|
||||
|
||||
// Then: reduced-motion users never request it, and the tween contract is unchanged.
|
||||
assert.match(messageRowSource, /prefers-reduced-motion: reduce/);
|
||||
assert.match(messageRowSource, /prefers-reduced-motion: no-preference/);
|
||||
assert.match(messageRowSource, /gsap\.matchMedia\(\)/);
|
||||
assert.match(messageRowSource, /duration: 0\.18/);
|
||||
assert.match(messageRowSource, /clearProps: "opacity,transform,visibility"/);
|
||||
|
||||
// And: a row that mounts before the chunk lands renders unanimated instead of flashing.
|
||||
assert.match(messageRowSource, /if \(!gsapCore\) \{/);
|
||||
assert.match(messageRowSource, /useEntryEffect\(\(\) => \{/);
|
||||
});
|
||||
|
||||
test("the thinking orb loads on demand behind a same-size placeholder", () => {
|
||||
// Given: only the state type is imported at build time.
|
||||
assert.doesNotMatch(activitySource, /^import \{ ThinkingOrb/m);
|
||||
assert.match(activitySource, /^import type \{ OrbState \} from "thinking-orbs";$/m);
|
||||
assert.match(activitySource, /dynamic\(async \(\) => \(await importThinkingOrb\(\)\)\.ThinkingOrb/);
|
||||
assert.match(activitySource, /ssr: false/);
|
||||
|
||||
// Then: the placeholder reserves the exact 20px orb box so the row cannot shift.
|
||||
assert.match(activitySource, /loading: \(\) => \(/);
|
||||
assert.match(activitySource, /height: 20, width: 20/);
|
||||
assert.match(activitySource, /flex: "0 0 auto"/);
|
||||
|
||||
// And: the live region and the decorative marking survive.
|
||||
assert.match(activitySource, /role="status"/);
|
||||
assert.match(activitySource, /<ThinkingOrb aria-hidden="true" state=\{state\} size=\{20\}/);
|
||||
});
|
||||
|
||||
test("deferred chat chunks are requested during idle time after first paint", () => {
|
||||
assert.match(prefetchSource, /if \(typeof window === "undefined"\) return;/);
|
||||
assert.match(prefetchSource, /window\.requestIdleCallback\(request, \{ timeout: 2_000 \}\)/);
|
||||
assert.match(prefetchSource, /window\.setTimeout\(request, 300\)/);
|
||||
assert.match(contentSource, /prefetchOnIdle\(loadMarkdownRenderer\)/);
|
||||
assert.match(activitySource, /prefetchOnIdle\(importThinkingOrb\)/);
|
||||
assert.match(messageRowSource, /if \(motionPreferred\(\)\) prefetchOnIdle\(loadGsap\)/);
|
||||
});
|
||||
@@ -0,0 +1,223 @@
|
||||
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/);
|
||||
assert.ok(sendSource.indexOf('router.push(membershipHref("insufficient-credits"))')
|
||||
< sendSource.indexOf(' setDraft("");'));
|
||||
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*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 && (", "</div>\n )}");
|
||||
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\}/);
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
const pageSource = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
|
||||
const composerSource = readFileSync(new URL("../src/components/chat-composer.tsx", import.meta.url), "utf8");
|
||||
const draftStoreSource = readFileSync(new URL("../src/lib/composer-draft.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("keystrokes never re-render the chat page root", () => {
|
||||
// Given: the composer text used to live in page-level React state.
|
||||
assert.doesNotMatch(pageSource, /const \[draft, setDraft\] = useState/);
|
||||
assert.doesNotMatch(pageSource, /const \[draftTheme, setDraftTheme\] = useState/);
|
||||
assert.doesNotMatch(pageSource, /const \[draftEntrypoint, setDraftEntrypoint\] = useState/);
|
||||
|
||||
// When: the page keeps only the hidden routing values it reads at submit time.
|
||||
assert.match(pageSource, /const draftTheme = useRef<Theme \| null>\(null\)/);
|
||||
assert.match(pageSource, /const draftEntrypoint = useRef<ConsultationEntrypoint \| null>\(null\)/);
|
||||
|
||||
// Then: every writer the page still exposes updates a ref or the external store.
|
||||
assert.match(pageSource, /function setDraft\(value: string\) \{\n setComposerDraft\(value\);\n \}/);
|
||||
assert.match(pageSource, /function setDraftTheme\(theme: Theme \| null\) \{\n draftTheme\.current = theme;\n \}/);
|
||||
assert.match(pageSource, /function setDraftEntrypoint\(entrypoint: ConsultationEntrypoint \| null\) \{\n draftEntrypoint\.current = entrypoint;\n \}/);
|
||||
assert.match(pageSource, /import \{ composerDraftSnapshot, setComposerDraft \} from "@\/lib\/composer-draft"/);
|
||||
assert.match(pageSource, /import \{ showChatNotice as setComposerNotice \} from "@\/lib\/chat-notice"/);
|
||||
});
|
||||
|
||||
test("the textarea lives in an isolated composer that owns the draft subscription", () => {
|
||||
// Given: the page renders the composer without handing it the draft text.
|
||||
assert.match(pageSource, /import \{ ChatComposer \} from "@\/components\/chat-composer"/);
|
||||
const composerElement = sourceBetween(pageSource, "<ChatComposer", "/>");
|
||||
assert.doesNotMatch(composerElement, /\bdraft=/);
|
||||
assert.doesNotMatch(pageSource, /<Textarea/);
|
||||
assert.doesNotMatch(pageSource, /value=\{draft\}/);
|
||||
|
||||
// When: the composer resolves the draft itself.
|
||||
assert.match(composerSource, /useSyncExternalStore\(\n\s*subscribeComposerDraft,\n\s*composerDraftSnapshot,\n\s*serverComposerDraftSnapshot,\n\s*\)/);
|
||||
assert.match(composerSource, /const draft = useComposerDraft\(\)/);
|
||||
|
||||
// Then: only the composer subtree reads the value that changes on every keystroke.
|
||||
assert.match(composerSource, /<Textarea[\s\S]*?value=\{draft\}/);
|
||||
assert.match(composerSource, /disabled=\{!draft\.trim\(\) \|\| submitBlocked\}/);
|
||||
});
|
||||
|
||||
test("the composer keeps its Chinese input, focus and accessibility contract", () => {
|
||||
// Given: Enter submits only outside an IME composition.
|
||||
const keyHandler = sourceBetween(pageSource, "function handleComposerKeyDown", "\n\n if (!hydrated");
|
||||
assert.match(keyHandler, /if \(event\.nativeEvent\.isComposing\) return/);
|
||||
assert.match(keyHandler, /event\.currentTarget\.form\?\.requestSubmit\(\)/);
|
||||
assert.match(pageSource, /onKeyDown=\{handleComposerKeyDown\}/);
|
||||
|
||||
// When: the page still focuses the same textarea element through its own ref.
|
||||
assert.match(pageSource, /const composerInput = useRef<HTMLTextAreaElement>\(null\)/);
|
||||
assert.match(pageSource, /inputRef=\{composerInput\}/);
|
||||
assert.match(composerSource, /<Textarea\n\s*ref=\{inputRef\}/);
|
||||
assert.match(pageSource, /composerInput\.current\?\.focus\(\)/);
|
||||
|
||||
// Then: the form shell, labels, hit targets and the stop control are unchanged.
|
||||
assert.match(composerSource, /<form className="composer" onSubmit=\{onSubmit\}>/);
|
||||
assert.match(composerSource, /aria-label=\{inputLabel\}/);
|
||||
assert.match(composerSource, /rows=\{1\}/);
|
||||
assert.match(composerSource, /maxLength=\{maxLength\}/);
|
||||
assert.match(composerSource, /className="composer-stop"/);
|
||||
assert.match(composerSource, /aria-label=\{stopLabel\}/);
|
||||
assert.match(composerSource, /title=\{stopTitle\}/);
|
||||
assert.match(composerSource, /aria-label=\{submitLabel\} disabled=/);
|
||||
assert.equal(composerSource.match(/size="icon"/g)?.length, 2);
|
||||
assert.match(pageSource, /inputLabel=\{!profileComplete && onboardingStep === "name" \? "输入你的称呼" : "输入你的问题"\}/);
|
||||
assert.match(pageSource, /submitLabel=\{!profileComplete && onboardingStep === "name" \? "确认称呼" : "发送"\}/);
|
||||
});
|
||||
|
||||
test("every external draft writer keeps working through the page-owned setters", () => {
|
||||
// Given: editing clears the hidden routing that a suggested question attached.
|
||||
assert.match(pageSource, /onChange=\{\(event\) => \{\n\s*setDraft\(event\.target\.value\);\n\s*setDraftTheme\(null\);\n\s*setDraftEntrypoint\(null\);\n\s*setComposerNotice\(""\);\n\s*\}\}/);
|
||||
|
||||
// When: each existing write path is inspected.
|
||||
const chooseSuggested = sourceBetween(pageSource, "function chooseSuggestedQuestion(", "function chooseConversationSuggestion");
|
||||
const startNewChat = sourceBetween(pageSource, "async function startNewChat()", "function selectSession(");
|
||||
const selectSession = sourceBetween(pageSource, "function selectSession(sessionId: string)", "async function selectSessionModel");
|
||||
const saveOnboardingName = sourceBetween(pageSource, "async function saveOnboardingName()", "async function saveOnboardingBirth");
|
||||
const stopRestore = sourceBetween(pageSource, "updateSession(pending.sessionId, () => pending.previousSession);", "function completeConsultationInterface");
|
||||
const sendClear = sourceBetween(pageSource, " updateSession(sessionId, () => userSession);", "if (process.env.NODE_ENV === \"development\" && uiPreview.current)");
|
||||
const sendRestore = sourceBetween(pageSource, "if (activeSessionIdRef.current === sessionId) {", "setRequestError({");
|
||||
|
||||
// Then: suggestions fill, session switches clear, stop restores and send clears.
|
||||
assert.match(chooseSuggested, /setDraft\(question\);\n\s*setDraftTheme\(theme \?\? null\);\n\s*setDraftEntrypoint\(entrypoint\);/);
|
||||
assert.match(startNewChat, /setDraft\(""\)/);
|
||||
assert.match(selectSession, /setDraft\(""\)/);
|
||||
assert.match(saveOnboardingName, /composerDraftSnapshot\(\)\.replace\(/);
|
||||
assert.match(saveOnboardingName, /setDraft\(""\)/);
|
||||
assert.match(stopRestore, /setDraft\(pending\.question\);\n\s*setDraftTheme\(pending\.theme\);\n\s*setDraftEntrypoint\(pending\.entrypoint\);/);
|
||||
assert.match(sendClear, /setDraft\(""\);\n\s*setDraftTheme\(null\);\n\s*setDraftEntrypoint\(null\);/);
|
||||
assert.match(sendRestore, /setDraft\(originalQuestion\);\n\s*setDraftTheme\(theme\);\n\s*setDraftEntrypoint\(entrypoint\);/);
|
||||
assert.match(pageSource, /void send\(composerDraftSnapshot\(\), draftTheme\.current \?\? undefined, draftEntrypoint\.current\)/);
|
||||
|
||||
// And: opening a rectification case still empties the composer it replaces.
|
||||
const openRectification = sourceBetween(pageSource, "async function openRectificationCase", "async function openRectificationFromHomepage");
|
||||
assert.match(openRectification, /setDraft\(""\)/);
|
||||
});
|
||||
|
||||
test("the draft survives a reload without resurrecting a sent question", () => {
|
||||
// Given: the draft key follows the existing session-scoped storage convention.
|
||||
assert.match(pageSource, /const pendingConsultationStorageKey = "jyotisha\.pending-consultation"/);
|
||||
assert.match(draftStoreSource, /export const composerDraftStorageKey = "jyotisha\.composer-draft"/);
|
||||
assert.match(draftStoreSource, /return window\.sessionStorage;/);
|
||||
|
||||
// When: the store restores exactly once and every write mirrors the new value.
|
||||
assert.match(draftStoreSource, /function restoreComposerDraft\(\) \{\n if \(restored\) return;\n restored = true;\n draft = readStoredComposerDraft\(\);\n\}/);
|
||||
assert.match(draftStoreSource, /export function composerDraftSnapshot\(\): string \{\n restoreComposerDraft\(\);\n return draft;\n\}/);
|
||||
assert.match(draftStoreSource, /export function setComposerDraft\(value: string\) \{\n restoreComposerDraft\(\);/);
|
||||
assert.match(draftStoreSource, /writeStoredComposerDraft\(value\);\n for \(const listener of \[\.\.\.listeners\]\) listener\(\);/);
|
||||
|
||||
// Then: clearing after a send removes the key instead of persisting an empty draft.
|
||||
assert.match(draftStoreSource, /function writeStoredComposerDraft\(value: string\) \{\n if \(!value\) \{\n clearStoredDraft\(\);\n return;\n \}/);
|
||||
|
||||
// And: a corrupt, foreign, oversized or stale value is discarded, never surfaced.
|
||||
const readStored = sourceBetween(draftStoreSource, "export function readStoredComposerDraft()", "function writeStoredComposerDraft");
|
||||
assert.match(readStored, /typeof text !== "string"/);
|
||||
assert.match(readStored, /typeof savedAt !== "number"/);
|
||||
assert.match(readStored, /!Number\.isFinite\(savedAt\)/);
|
||||
assert.match(readStored, /savedAt > Date\.now\(\)/);
|
||||
assert.match(readStored, /Date\.now\(\) - savedAt > composerDraftMaxAgeMs/);
|
||||
assert.match(readStored, /clearStoredDraft\(\);\n return "";\n \}/);
|
||||
assert.match(readStored, /catch \{\n clearStoredDraft\(\);\n return "";\n \}/);
|
||||
assert.match(readStored, /text\.slice\(0, composerDraftLimit\)/);
|
||||
|
||||
// And: an unavailable storage or a server render degrades to an empty composer.
|
||||
assert.match(draftStoreSource, /if \(typeof window === "undefined"\) return null;/);
|
||||
assert.match(draftStoreSource, /export function serverComposerDraftSnapshot\(\): string \{\n return "";\n\}/);
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync, readdirSync } from "node:fs";
|
||||
import { extname, join } from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
const root = new URL("../", import.meta.url);
|
||||
const css = readFileSync(new URL("src/app/globals.css", root), "utf8");
|
||||
|
||||
/**
|
||||
* Tailwind v4 builds its utility namespace only from `@theme`. A `:root` custom
|
||||
* property is invisible to the compiler, so `text-ink` compiles to nothing at
|
||||
* all while still looking correct in the JSX. Text assertions on class names
|
||||
* cannot catch that, so this file cross-references the two sides instead.
|
||||
*/
|
||||
function themeDeclarations() {
|
||||
const block = css.match(/@theme[^{]*\{([\s\S]*?)\n\}/);
|
||||
assert.ok(block, "globals.css must declare a @theme block");
|
||||
return new Map(
|
||||
[...block[1].matchAll(/^\s*(--[a-z0-9-]+):\s*([^;]+);/gm)].map(([, name, value]) => [name, value.trim()]),
|
||||
);
|
||||
}
|
||||
|
||||
function rootDeclarations() {
|
||||
const entries = new Map<string, string>();
|
||||
for (const [, body] of css.matchAll(/^:root\s*\{([\s\S]*?)\n\}/gm)) {
|
||||
for (const [, name, value] of body.matchAll(/^\s*(--[a-z0-9-]+):\s*([^;]+);/gm)) {
|
||||
if (!entries.has(name)) entries.set(name, value.trim());
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function sourceFiles(path: string): string[] {
|
||||
const directory = new URL(path, root);
|
||||
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
||||
const child = join(directory.pathname, entry.name);
|
||||
if (entry.isDirectory()) return sourceFiles(`${path}/${entry.name}`);
|
||||
return [".ts", ".tsx"].includes(extname(entry.name)) ? [child] : [];
|
||||
});
|
||||
}
|
||||
|
||||
// Utility prefixes that resolve their argument through the `--color-*` namespace.
|
||||
const colorPrefixes = [
|
||||
"accent", "bg", "border", "border-b", "border-e", "border-l", "border-r", "border-s",
|
||||
"border-t", "border-x", "border-y", "caret", "decoration", "divide", "fill", "from",
|
||||
"inset-shadow", "outline", "placeholder", "ring", "ring-offset", "shadow", "stroke",
|
||||
"text", "text-shadow", "to", "via",
|
||||
];
|
||||
|
||||
/** Every `--color-*` token that `src/` uses as a Tailwind utility, with its call sites. */
|
||||
function colorTokensUsedAsUtilities(palette: Map<string, string>) {
|
||||
const used = new Map<string, Set<string>>();
|
||||
for (const file of sourceFiles("src")) {
|
||||
const label = file.slice(file.indexOf("/src/") + 1);
|
||||
readFileSync(file, "utf8").split("\n").forEach((line, index) => {
|
||||
for (const [candidate] of line.matchAll(/[a-z][A-Za-z0-9:\/.-]*/g)) {
|
||||
// Strip variants (`hover:`), the important marker and any opacity modifier.
|
||||
const utility = candidate.split(":").pop()!.replace(/^!/, "").split("/")[0];
|
||||
for (const prefix of colorPrefixes) {
|
||||
if (!utility.startsWith(`${prefix}-`)) continue;
|
||||
const token = `--color-${utility.slice(prefix.length + 1)}`;
|
||||
if (!palette.has(token)) continue;
|
||||
if (!used.has(token)) used.set(token, new Set());
|
||||
used.get(token)!.add(`${label}:${index + 1} (${utility})`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
return used;
|
||||
}
|
||||
|
||||
test("every color token used as a Tailwind utility is exposed through @theme", () => {
|
||||
const theme = themeDeclarations();
|
||||
const palette = rootDeclarations();
|
||||
const dead: string[] = [];
|
||||
for (const [token, callSites] of colorTokensUsedAsUtilities(palette)) {
|
||||
if (theme.has(token)) continue;
|
||||
dead.push(`${token} -> ${[...callSites].sort().join(", ")}`);
|
||||
}
|
||||
assert.deepEqual(
|
||||
dead,
|
||||
[],
|
||||
`these utilities compile to no CSS because the token is missing from @theme:\n${dead.join("\n")}`,
|
||||
);
|
||||
});
|
||||
|
||||
test("the whole :root colour palette is reachable from the utility namespace", () => {
|
||||
const theme = themeDeclarations();
|
||||
const missing = [...rootDeclarations().keys()]
|
||||
.filter((token) => token.startsWith("--color-") && !theme.has(token))
|
||||
.sort();
|
||||
assert.deepEqual(missing, [], `add these to @theme so utilities generate: ${missing.join(", ")}`);
|
||||
});
|
||||
|
||||
test("@theme keeps the shadcn aliases pointing at the same palette tokens", () => {
|
||||
const theme = themeDeclarations();
|
||||
const aliases: Record<string, string> = {
|
||||
"--color-background": "var(--color-canvas)",
|
||||
"--color-foreground": "var(--color-ink)",
|
||||
"--color-primary": "var(--color-action)",
|
||||
"--color-primary-foreground": "var(--color-on-dark)",
|
||||
"--color-secondary": "var(--color-canvas-muted)",
|
||||
"--color-secondary-foreground": "var(--color-ink)",
|
||||
"--color-muted": "var(--color-canvas-muted)",
|
||||
"--color-muted-foreground": "var(--color-ink-secondary)",
|
||||
"--color-destructive": "var(--color-danger)",
|
||||
};
|
||||
for (const [name, value] of Object.entries(aliases)) {
|
||||
assert.equal(theme.get(name), value, `@theme must keep ${name} as ${value}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("@theme resolves palette aliases inline so the :root values stay authoritative", () => {
|
||||
// `inline` makes a utility emit the declared value rather than a reference to
|
||||
// the theme variable, which is what lets `--color-background` forward to
|
||||
// `--color-canvas` instead of shadowing it.
|
||||
assert.match(css, /@theme inline \{/);
|
||||
// Tailwind emits `@theme` into `@layer theme`, and unlayered declarations beat
|
||||
// layered ones, so the literal values below must stay outside any layer.
|
||||
assert.ok(css.indexOf("@theme inline {") < css.indexOf(":root {"), "@theme must precede the :root palette");
|
||||
assert.match(css, /^:root \{\n color-scheme: light;\n --color-action: #85432f;/m);
|
||||
});
|
||||
|
||||
test("@theme literals do not drift from the :root token they duplicate", () => {
|
||||
const palette = rootDeclarations();
|
||||
for (const [name, value] of themeDeclarations()) {
|
||||
if (!name.startsWith("--color-") || value.startsWith("var(")) continue;
|
||||
const token = palette.get(name);
|
||||
if (token === undefined) continue;
|
||||
assert.equal(value, token, `@theme ${name} must match the :root token ${token}`);
|
||||
}
|
||||
});
|
||||
@@ -192,7 +192,8 @@ test("global report copy uses the canonical profile, preserves accepted/confirme
|
||||
test("entry is global in the sidebar and absent from the active session header", () => {
|
||||
assert.match(sidebarSource, /我的报告/);
|
||||
assert.match(sidebarSource, /onOpenReports/);
|
||||
assert.match(pageSource, /onOpenReports=\{\(\) => window\.location\.assign\("\/reports"\)\}/);
|
||||
assert.match(pageSource, /onOpenReports=\{\(\) => router\.push\("\/reports"\)\}/);
|
||||
assert.doesNotMatch(pageSource, /window\.location\.assign\("\/reports"\)/);
|
||||
assert.doesNotMatch(pageSource, /GeneratePersonalReportButton|reportEntryVisible|reportEvidenceState/);
|
||||
assert.match(
|
||||
globalStyles,
|
||||
|
||||
Reference in New Issue
Block a user