Files
Jyotisha/frontend/tests/chat-bundle-splitting-contract.test.ts
T
Jesse_Chen be5e810ac9 fix(chat): keep the latest reply mounted through settlement and animate the timeline collapse
The streaming and settled versions of the trailing assistant reply were
two components, so settling unmounted one and mounted the other and the
entrance tween replayed over text the reader was already on. One
LatestAssistantEntry now owns that row under a single key, and the
history list excludes it. The CSS entrance keyframe that doubled the
GSAP tween is gone and the tween matches the documented 160ms. The step
timeline no longer remounts on settle: it is a button-controlled
disclosure with a 180ms grid-rows transition, the reader's own toggle
wins over the live default, and an in-flight request with no events yet
shows a queued row instead of an empty shell.

BUG-474 BUG-475

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
2026-09-02 04:02:15 +00:00

99 lines
5.5 KiB
TypeScript

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, /className="message-answer"/);
assert.match(contentSource, /TechniqueAuditDisclosure/);
assert.match(contentSource, /<p key=\{index\}>\{paragraph\}<\/p>/);
assert.match(contentSource, /plainParagraphs\(spoken\) \?\? \[\]/);
});
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\(\)/);
// Former value: `duration: 0\.18`. DESIGN.md §6 gives the message entrance 160ms and the CSS
// keyframe that used to run alongside was 160ms too; 0.18 was the GSAP copy drifting.
assert.match(messageRowSource, /duration: 0\.16/);
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\)/);
});