Files
Jyotisha/frontend/tests/composer-isolation-contract.test.ts
T
Jesse_Chen 54269fcfcc
Independent Staging Quality Gate / validate (push) Has been cancelled
Independent Staging Quality Gate / publish (push) Has been cancelled
refactor(chat): extract home helpers, chart library, and starter surfaces
page.tsx still owns the chat main chain, but the first product surfaces now
live in their own modules so later splits can land without editing the 4k-line
Home. Source-lock tests follow the moved tokens; the orphan user-data contract
is aligned and added to the quick gate.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 22:58:02 +08:00

145 lines
9.9 KiB
TypeScript

import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { homeSurface as pageSource } from "./home-surface.ts";
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\}/);
});