Files
Jyotisha/frontend/tests/composer-isolation-contract.test.ts
T
Jesse_Chen 649ba32034
Independent Staging Quality Gate / validate (push) Successful in 11m21s
Independent Staging Quality Gate / publish (push) Successful in 17m56s
fix(home): send starter cards instead of filling the composer
Clicking today's reading or a topic card should open a consultation and wait for the model, not leave the question in the input box.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-21 22:02:40 +08:00

145 lines
9.9 KiB
TypeScript

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(", "async function startSuggestedConsultation");
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 (!resuming && process.env.NODE_ENV === \"development\" && uiPreview.current)");
const sendRestore = sourceBetween(
pageSource,
"if (!options.restoreOnFailure && activeSessionIdRef.current === sessionId) {",
"setRequestError({",
);
// Then: synastry still fills the composer; session switches clear, stop restores and send clears.
assert.match(chooseSuggested, /setDraft\(question\);\n\s*setDraftTheme\(theme \?\? null\);\n\s*setDraftEntrypoint\(entrypoint\);/);
const startSuggested = sourceBetween(pageSource, "async function startSuggestedConsultation(", "function startDailyStarlanguageConsultation");
assert.match(startSuggested, /await send\(trimmed, theme, entrypoint, null, targetSession\.id/);
assert.doesNotMatch(startSuggested, /setDraft\(/);
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\(consultEntrypoint\);/);
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\}/);
});