Files
Jyotisha/frontend/tests/chat-bundle-splitting-contract.test.ts
T
jesse-ux ff5023a282
Independent Staging Quality Gate / validate (push) Successful in 9m19s
Independent Staging Quality Gate / publish (push) Successful in 11m32s
test(chat): restore two-layer markdown contract counts
Spoken and report layers each keep a message-markdown shell and both must call renderProse; login hard-jumps drop from 4 to 3 after the onboarding suggestion effect went away. No product code.
2026-09-15 13:27:17 +08:00

112 lines
6.5 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 内部的回退路径
// 原因: 口语层与报告层共用 renderProseplainParagraphs 只在它内部调用一次。
assert.match(contentSource, /\(plainParagraphs\(text\) \?\? \[\]\)\.map/);
// 两层必须都走 renderProse,不得一层 markdown 一层裸文本。
// 口语层若改成 {spoken} / renderMarkdown(spoken)、报告层仍走 renderProse(或反过来),
// 下面的精确计数会从 2 掉到 1。不得改成 >= 1。
assert.match(contentSource, /renderProse\(spoken,\s*renderMarkdown\)/);
assert.match(contentSource, /renderProse\(report,\s*renderMarkdown\)/);
assert.equal(
contentSource.match(/renderProse\((?:spoken|report),\s*renderMarkdown\)/g)?.length,
2,
);
assert.doesNotMatch(contentSource, /\{renderMarkdown\((?:spoken|report)\)\}/);
assert.doesNotMatch(contentSource, />\{(?:spoken|report)\}<\/div>/);
});
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 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\)/);
assert.match(messageRowSource, /if \(motionPreferred\(\)\) prefetchOnIdle\(loadGsap\)/);
});