Files
Jyotisha/frontend/tests/chat-bundle-splitting-contract.test.ts
T
Jesse_ChenandClaude Opus 5.5 55ebdd7a95
Independent Staging Quality Gate / publish (push) Canceled after 0s
Independent Staging Quality Gate / validate (push) Canceled after 9m27s
Home first screen no longer carries the rectification surface or react-markdown (issue #124, BUG-1474)
page.tsx imported the whole rectification surface statically; its verification
report also imported chat-markdown-view statically, which pulled react-markdown
into / even though chat-message-content already loaded it lazily.

- Home renders a same-named lazy wrapper; openRectificationCase starts the
  download with the open request and awaits it before setting the ids that
  mount the surface, so it still reveals once, drawn.
- The markdown loader moves to lib/chat-markdown-loader; the home bootstrap
  awaits it before revealing a conversation with messages (no plain-text flash
  on ?c=). The blank home does not wait.

/ first load, gzip-9 of every referenced JS/CSS: 704,165 -> 609,606 B (-13.4%),
still Static.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 11:25:43 +08:00

119 lines
7.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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");
const markdownLoaderSource = readProjectFile("src/lib/chat-markdown-loader.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.
// 原值:chat-message-content.tsx 里直接 import("@/components/chat-markdown-view")
// 新值:动态 import 挪到 lib/chat-markdown-loader.ts,组件和首页启动共用这一份
// 原因:issue #124 首页启动要在揭幕前等 Markdown(`?c=` 打开带消息的会话)
assert.doesNotMatch(contentSource, /^import .*"@\/components\/chat-markdown-view"/m);
assert.match(markdownLoaderSource, /import\("@\/components\/chat-markdown-view"\)/);
assert.match(markdownLoaderSource, /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.
// 原值: contentSource.match(/className="message-markdown"/g)?.length === 1
// 新值: 2
// 原因: 回答改为两层可见性(口语层 + 折叠的完整分析),两层各有一个包裹。
// 这条仍然数包裹个数,不得放宽成 >= 1——它防的是"升级到 markdown 时换了壳"。
assert.equal(contentSource.match(/className="message-markdown"/g)?.length, 2);
assert.match(contentSource, /className="message-answer"/);
assert.match(contentSource, /TechniqueAuditDisclosure/);
assert.match(contentSource, /<p key=\{index\}>\{paragraph\}<\/p>/);
// 原值: assert.match(contentSource, /plainParagraphs\(spoken\) \?\? \[\]/)
// 新值: 断言 renderProse 内部的回退路径
// 原因: 口语层与报告层共用 renderProse,plainParagraphs 只在它内部调用一次。
assert.match(contentSource, /\(plainParagraphs\(text\) \?\? \[\]\)\.map/);
// 原值: 口语层与报告层都直接 `renderProse(spoken/report, renderMarkdown)`,计数为 2。
// 新值: 结算两层都走 `StableMarkdownPrefix`;流式尾块仍走 `renderProse(split.tail)`。
// 原因: BUG-725 结算态按文本记忆化。计数仍锁死为 2,不得放宽成 >= 1。
assert.match(contentSource, /<StableMarkdownPrefix text=\{spoken\} renderMarkdown=\{renderMarkdown\} \/>/);
assert.match(contentSource, /<StableMarkdownPrefix text=\{report\} renderMarkdown=\{renderMarkdown\} \/>/);
assert.equal(
contentSource.match(/<StableMarkdownPrefix text=\{(?:spoken|report)\} renderMarkdown=\{renderMarkdown\} \/>/g)?.length,
2,
);
assert.match(contentSource, /renderProse\(split\.tail, renderMarkdown\)/);
assert.doesNotMatch(contentSource, /\{renderMarkdown\((?:spoken|report)\)\}/);
assert.doesNotMatch(contentSource, />\{(?:spoken|report)\}<\/div>/);
});
test("message motion loads on demand while keeping the reduced-motion gate", () => {
// 原值: 静态图里没有 gsap,动态 import("gsap"),gsap.matchMedia(),if (!gsapCore)
// 新值: 静态图里没有 motion/react,动态 import("motion"),matchMedia(no-preference),if (!messageMotion)
// 原因: 入场从 GSAP 换成 Motion,仍按需加载,模块没到就不要补播。时长、曲线、清属性不变。
assert.doesNotMatch(messageRowSource, /gsap|@gsap\/react/);
assert.doesNotMatch(messageRowSource, /^import .*from ["']motion/m);
assert.match(messageRowSource, /import\("motion"\)/);
assert.match(messageRowSource, /prefers-reduced-motion: reduce/);
assert.match(messageRowSource, /prefers-reduced-motion: no-preference/);
assert.match(messageRowSource, /matchMedia\("\(prefers-reduced-motion: no-preference\)"\)/);
assert.match(messageRowSource, /duration: 0\.16/);
assert.match(messageRowSource, /clearProps: "opacity,transform,visibility"/);
assert.match(messageRowSource, /if \(!messageMotion\) \{/);
assert.match(messageRowSource, /useEntryEffect\(\(\) => \{/);
});
test("the live marker is the shared inline spinner, with no on-demand orb chunk", () => {
// Former test: "the thinking orb loads on demand behind a same-size placeholder". The canvas
// orb was a second live-marker vocabulary beside the timeline's InlineSpinner, undocumented in
// DESIGN.md §9, and clipped inside the rectification surface's 14px marker (BUG-476). Removed.
assert.doesNotMatch(activitySource, /thinking-orbs|ThinkingOrb|next\/dynamic/);
assert.match(activitySource, /<InlineSpinner size=\{12\} \/>/);
// And: the live region and the decorative marking survive.
assert.match(activitySource, /role="status"/);
assert.match(activitySource, /className="agent-thinking-marker is-live-marker" aria-hidden="true"/);
});
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\)/);
// 原值:prefetchOnIdle(loadMarkdownRenderer);新值与原因同上(issue #124)
assert.match(contentSource, /prefetchOnIdle\(loadChatMarkdown\)/);
// 原值: prefetchOnIdle(loadGsap) / 新值: prefetchOnIdle(loadMessageMotion)
// 原因: 同上,空闲预取的是 Motion 而不是 GSAP。
assert.match(messageRowSource, /if \(motionPreferred\(\)\) prefetchOnIdle\(loadMessageMotion\)/);
});