Files
Jyotisha/frontend/tests/chat-bundle-splitting-contract.test.ts
T
jesse-uxandGrok (agent loop) d3be9491ee
Independent Staging Quality Gate / validate (push) Successful in 20m4s
Independent Staging Quality Gate / publish (push) Failing after 7m48s
组件库 K0:换上 Motion 底座和 shadcn 零件,页面还没换(#104)。
去掉 GSAP。对话入场改为按需加载的 Motion,仍是 160 毫秒,仍尊重减少动态效果。
用 shadcn@4.21.4 补上任务书列出的组件,不覆盖已有的 8 个。设计令牌映射到 shadcn 变量。
手写控件检查和只减不增的白名单。Motion 预设与四个 store 只是骨架,没有接到页面上。

自测:tsc --noEmit 退出码 0;npm run lint 0 error(122 条原有 warning);
定向测试 37 项、fail 0、cancelled 0;next build 后 / 仍是 Static;
首屏 JS gzip 578349 → 578435(+86 字节,+0.01%)。

Co-Authored-By: Grok (agent loop)
2026-10-24 15:37:29 +08:00

113 lines
6.9 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");
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.
// 原值: 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\)/);
assert.match(contentSource, /prefetchOnIdle\(loadMarkdownRenderer\)/);
// 原值: prefetchOnIdle(loadGsap) / 新值: prefetchOnIdle(loadMessageMotion)
// 原因: 同上,空闲预取的是 Motion 而不是 GSAP。
assert.match(messageRowSource, /if \(motionPreferred\(\)\) prefetchOnIdle\(loadMessageMotion\)/);
});