Files
Jyotisha/frontend/tests/chat-stream-layout.test.ts
T
Jesse_Chen 3198fb6b2b
Independent Staging Quality Gate / validate (push) Successful in 10m14s
Independent Staging Quality Gate / publish (push) Successful in 11m5s
perf(frontend): split chat streaming, load Inter, isolate admin CSS
Settled messages no longer rebuild on every token, Inter is actually
requested, and admin routes drop the 33 KB chat stylesheet. Root
force-dynamic is gone so public shells can prerender without changing
the no-store Cache-Control contract.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-29 03:39:43 +08:00

199 lines
12 KiB
TypeScript

import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { chatMessageViews } from "../src/lib/chat-message-view.ts";
const pageSource = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const transcriptSource = readFileSync(new URL("../src/components/chat-transcript.tsx", import.meta.url), "utf8");
const globalStyles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
const messageRowSource = readFileSync(new URL("../src/components/chat-message-row.tsx", import.meta.url), "utf8");
const activitySource = readFileSync(new URL("../src/components/agent-activity-status.tsx", import.meta.url), "utf8");
const reportSource = readFileSync(new URL("../src/components/consultation-thinking-report.tsx", import.meta.url), "utf8");
const treeSource = readFileSync(new URL("../src/components/thinking-step-tree.tsx", import.meta.url), "utf8");
const timelineSource = readFileSync(new URL("../src/components/consultation-run-timeline.tsx", import.meta.url), "utf8");
const reducerSource = readFileSync(new URL("../src/lib/consultation-run-timeline.ts", import.meta.url), "utf8");
const previousMessages = [
{ role: "user", text: "问题" },
] as const;
test("keeps the assistant render identity stable when streaming settles", () => {
// Given: one assistant answer exists first as a transient stream.
const streaming = chatMessageViews(previousMessages, true, "完整答案");
// When: the same answer becomes part of the persisted transcript.
const settled = chatMessageViews([
...previousMessages,
{ role: "assistant", text: "完整答案" },
], false, "");
// Then: React receives the same key and can reuse the existing message shell.
assert.equal(streaming.at(-1)?.renderKey, settled.at(-1)?.renderKey);
assert.equal(streaming.at(-1)?.state, "streaming");
assert.equal(settled.at(-1)?.state, "settled");
});
test("does not duplicate a completed assistant answer while loading state settles", () => {
// Given: the final answer has entered the transcript while request cleanup lags.
const completedMessages = [
...previousMessages,
{ role: "assistant", text: "完整答案" },
] as const;
// When: the render view is derived with the old loading flag still true.
const views = chatMessageViews(completedMessages, true, "完整答案");
// Then: only the persisted answer is rendered.
assert.equal(views.length, completedMessages.length);
assert.equal(views.at(-1)?.state, "settled");
});
test("shows honest agent activity states before and during streamed text", () => {
const activity = { phase: "chart-calculation", label: "正在计算本命盘…" } as const;
const view = chatMessageViews(previousMessages, true, "", activity).at(-1);
assert.deepEqual(view?.activity, activity);
const thinkingView = chatMessageViews(previousMessages, true, "", activity, "先看事业宫。").at(-1);
assert.equal(thinkingView?.thinkingText, "先看事业宫。");
assert.equal(thinkingView?.state, "thinking");
const sectionView = chatMessageViews(previousMessages, true, "", activity, undefined, [{
id: "foundation",
title: "先整理本盘的统一参数",
heading: "统一参数与原始结构",
steps: [{ id: "raw", label: "列出岁差、上升与宫位结构", status: "pending" }],
}]).at(-1);
assert.equal(sectionView?.thinkingSections?.[0]?.heading, "统一参数与原始结构");
assert.match(messageRowSource, /"loading-method": "searching"/);
assert.match(messageRowSource, /"chart-calculation": "solving"/);
assert.match(messageRowSource, /"evidence-validation": "working"/);
assert.match(messageRowSource, /"answer-composition": "composing"/);
assert.match(messageRowSource, /message\.activity\?\.label/);
assert.doesNotMatch(messageRowSource, /正在核对星盘信息/);
assert.match(messageRowSource, /正在处理…/);
assert.match(messageRowSource, /showActivity = message\.state !== "settled"/);
assert.match(messageRowSource, /showThinkingPanel &&/);
assert.match(messageRowSource, /showSpokenAnswer/);
assert.match(messageRowSource, /<ChatMessageContent[\s\S]*text=\{message\.text\}[\s\S]*auditRows=\{message\.agentExecutionReceipt\?\.techniqueAuditTable\}/);
assert.match(messageRowSource, /stackedThinkingAndAnswer/);
assert.match(messageRowSource, /aria-label="回复"/);
assert.match(messageRowSource, /className="message-stage-and-answer"/);
assert.match(messageRowSource, /vargaSentence=\{showThinkingPanel \? null : vargaSentence\}/);
assert.match(globalStyles, /\.message-stage-and-answer/);
assert.match(globalStyles, /\.message-stage-and-answer \{[\s\S]*gap: var\(--space-2\)/);
assert.match(globalStyles, /\.agent-activity-status \+ \.message-answer/);
assert.match(activitySource, /<ThinkingOrb aria-hidden="true" state=\{state\} size=\{20\}/);
assert.match(activitySource, /className="agent-activity-status__text"/);
assert.match(activitySource, /className="agent-activity-status__elapsed" aria-hidden="true"/);
assert.match(activitySource, /className="agent-thinking-timeline"/);
assert.match(activitySource, /role="status"/);
assert.match(globalStyles, /@keyframes agent-activity-shimmer/);
assert.match(globalStyles, /agent-activity-status-in 160ms ease-out/);
assert.match(globalStyles, /\.agent-thinking-step/);
assert.match(globalStyles, /\.agent-thinking-timeline/);
assert.match(globalStyles, /@media \(prefers-reduced-motion: reduce\)[\s\S]*\.agent-activity-status__text[\s\S]*animation: none/);
assert.match(pageSource, /nextActivityView/);
assert.match(pageSource, /chartCalculationProgressLabel|CONSULTATION_CHART_CALCULATION_LABEL/);
assert.match(pageSource, /activityCompletedTrail\(\[CONSULTATION_DONE_SKILL_LABEL\]\)/);
assert.doesNotMatch(activitySource, /CircleCheck|回答已完成/);
assert.doesNotMatch(activitySource, /state === "completed"|: "completed"/);
assert.doesNotMatch(messageRowSource, /: "completed"/);
assert.doesNotMatch(globalStyles, /\.thinking\b/);
assert.match(pageSource, /application\/x-ndjson/);
assert.match(pageSource, /createNdjsonParser/);
assert.match(pageSource, /event.type === "thinking.section"/);
assert.match(pageSource, /event.type === "thinking.delta"/);
assert.match(pageSource, /activeStreamingSections/);
assert.match(pageSource, /streamedThinking/);
assert.match(messageRowSource, /showLiveActivity/);
assert.match(messageRowSource, /ConsultationThinkingReport/);
assert.match(messageRowSource, /thinkingText=\{hasTrace \? undefined : message\.thinkingText\}/);
assert.match(messageRowSource, /activityTrace=\{message\.activityTrace\}/);
assert.match(messageRowSource, /showLiveActivity \|\| \(!showReport && consultTimeline === undefined && \(Boolean\(message\.thinkingText/);
assert.match(activitySource, /activityTrace \?\? \[\]/);
assert.match(activitySource, /function TraceStep/);
assert.match(globalStyles, /\.agent-thinking-step\.is-think:has\(\.message-thinking\)/);
assert.match(reportSource, /caption="思考"/);
assert.match(reportSource, /reasoning=\{thinkingText\}/);
assert.match(reportSource, /aria-label="分析"/);
assert.match(treeSource, /consultation-step-tree__reasoning/);
assert.match(reportSource, /revealAll/);
assert.match(reportSource, /groups=\{progressed\.map/);
assert.doesNotMatch(reportSource, /analysisForSection|splitAnswerByHeadings/);
assert.match(globalStyles, /\.markdown-list/);
assert.match(activitySource, />思考</);
assert.match(pageSource, /event\.type === "run\.failed"/);
assert.match(pageSource, /event\.code === "answer_truncated"/);
assert.match(pageSource, /throw new ConsultationResponseError/);
assert.match(pageSource, /if \(!runCompleted && !truncatedFailure\) \{\s*throw new ConsultationResponseError/);
assert.match(pageSource, /agentExecutionReceipt = event\.receipt/);
assert.match(pageSource, /const failedSession: ChatSession/);
assert.match(pageSource, /\.\.\.\(thinkingSections\.length \? \{ thinkingSections \} : \{\}\)/);
assert.match(pageSource, /reduceConsultationTimeline/);
assert.match(pageSource, /timeline: timelineState\.rows/);
assert.match(pageSource, /activeStreamingTimeline/);
assert.match(messageRowSource, /ConsultationRunTimeline/);
assert.match(messageRowSource, /message\.timeline/);
assert.match(timelineSource, /BookOpen/);
assert.match(timelineSource, /Layers/);
assert.match(timelineSource, /ListTodo/);
assert.match(timelineSource, /PenLine/);
assert.match(timelineSource, /LoaderCircle/);
assert.match(timelineSource, /data-kind=\{row\.kind\}/);
assert.doesNotMatch(timelineSource, /thinking-orbs|Globe|Cloud|favicon/i);
assert.match(globalStyles, /\.consultation-run-timeline/);
assert.match(reducerSource, /kind: "method"/);
assert.match(reducerSource, /kind: "calculate"/);
assert.match(globalStyles, /\.consultation-report-analysis/);
assert.match(globalStyles, /\.consultation-thinking-report/);
assert.match(globalStyles, /\.consultation-thinking-report \.message-thinking/);
assert.match(treeSource, /consultation-step-tree__stage/);
assert.match(treeSource, /阶段 \{index \+ 1\}/);
assert.doesNotMatch(treeSource, /consultation-step-tree__intent/);
assert.match(globalStyles, /\.consultation-step-tree__stage-index/);
assert.match(globalStyles, /\.consultation-step-tree__group \+ \.consultation-step-tree__group[^}]*margin-top:\s*var\(--space-6\)/);
});
test("nothing sits between the transcript and the composer to shift height while streaming", () => {
// The follow-up chip row used to appear and disappear here, changing composer height
// mid-stream; the jump-to-latest overlay is the only remaining sibling and it is
// absolutely positioned, so it cannot reflow the composer.
const composerWrap = pageSource.slice(
pageSource.indexOf('composer-wrap ${starterHomeVisible'),
pageSource.indexOf("<ChatComposer"));
assert.doesNotMatch(composerWrap, /composer-suggestions/);
assert.match(composerWrap, /pointer-events-none absolute inset-x-0 bottom-full/);
});
test("docks the composer inside the chat panel instead of floating over content", () => {
assert.match(pageSource, /<div className=\{`composer-wrap \$\{starterHomeVisible \? "composer-wrap-starter" : ""\}`\}>/);
assert.match(globalStyles, /\.chat-panel[^}]*grid-template-rows:\s*68px minmax\(0,\s*1fr\) auto/);
assert.match(globalStyles, /\.conversation[^}]*padding-bottom:\s*var\(--composer-reserve\)/);
assert.match(globalStyles, /\.composer-wrap[^}]*position:\s*sticky/);
assert.match(globalStyles, /\.composer-wrap[^}]*bottom:\s*0/);
assert.doesNotMatch(globalStyles, /\.composer-wrap[^}]*position:\s*fixed/);
});
test("ordinary consultation replies reuse the shared Agent action bar", () => {
const actionsSource = readFileSync(new URL("../src/components/chat-message-actions.tsx", import.meta.url), "utf8");
const rectificationChat = readFileSync(
new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url),
"utf8",
);
for (const label of ["赞", "踩", "复制回答", "重新生成回答"]) {
assert.match(actionsSource, new RegExp(`aria-label="${label}"`));
}
assert.match(transcriptSource, /<ChatMessageActions/);
assert.match(pageSource, /toggleChatMessageFeedback/);
assert.match(pageSource, /function regenerateLatestAnswer\(renderKey: string\)/);
assert.match(pageSource, /messages: session\.messages\.slice\(0, -1\)/);
assert.match(pageSource, /restoreOnFailure: session/);
assert.doesNotMatch(rectificationChat, /appendActivityTraceThinking/);
assert.doesNotMatch(rectificationChat, /event\.type === "thinking\.delta"/);
assert.match(rectificationChat, /startActivityTraceStep/);
assert.match(rectificationChat, /completeActivityTraceStep/);
assert.match(rectificationChat, /<ChatMessageActions/);
assert.match(globalStyles, /\.message-actions \{/);
assert.doesNotMatch(globalStyles, /\.rectification-message-actions \{/);
});