mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-10-26 01:34:51 +08:00
Rebuild the session page around structured transcripts. - Session list: project groups start collapsed and remember the ones expanded by hand; unchanged session files reuse their parsed metadata. - Backend: all nine agents' transcripts parse into structured blocks (text, thinking, tool call / result, image, event, step); the Codex rollout parser is rewritten. New commands stream transcripts over a Channel, serve full block content, image bytes and Finder reveal behind validated content refs, and export a session as a Markdown file; a transcript cache (8 entries / 96 MB) keyed by source fingerprints. open_external now only allows http, https and mailto. - Reader: turns laid out as prompt -> execution steps -> final reply, with steps folded into a one-line summary, failures always shown, each agent's native symbols and verbs, all / conversation / changes filters, a conversation outline, in-session find, Markdown rendering (ported from #6332), image lightbox and copy / export as Markdown. - Review follow-ups: clippy, Codex 0.119-0.128 duplicate steps and late failure status, Grok Build in-progress refresh, full arguments for long shell commands, and shared block helpers across the parsers. Co-authored-by: bigben446 <bigben446@users.noreply.github.com> Co-authored-by: Jason <farion1231@gmail.com>
625 lines
22 KiB
TypeScript
625 lines
22 KiB
TypeScript
import type { ReactElement } from "react";
|
||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||
import {
|
||
act,
|
||
fireEvent,
|
||
render,
|
||
screen,
|
||
waitFor,
|
||
within,
|
||
} from "@testing-library/react";
|
||
import userEvent from "@testing-library/user-event";
|
||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||
|
||
import { SessionReader } from "@/components/sessions/reader/SessionReader";
|
||
import { sessionsApi } from "@/lib/api/sessions";
|
||
import {
|
||
clearSessionImageCache,
|
||
type SessionTranscriptResult,
|
||
} from "@/lib/query/sessions";
|
||
import type { SessionMessage, SessionMeta } from "@/types";
|
||
import { call, fixtures, msg, result, text } from "./sessions/reader/helpers";
|
||
|
||
// jsdom 没有布局,虚拟列表一条都不渲染;这里让它把全部行都画出来
|
||
vi.mock("@tanstack/react-virtual", () => ({
|
||
useVirtualizer: ({ count }: { count: number }) => ({
|
||
getTotalSize: () => count * 40,
|
||
getVirtualItems: () =>
|
||
Array.from({ length: count }, (_, index) => ({
|
||
index,
|
||
key: index,
|
||
start: index * 40,
|
||
})),
|
||
measureElement: () => undefined,
|
||
scrollToIndex: () => undefined,
|
||
}),
|
||
}));
|
||
|
||
const PROJECT = "/Users/yovinchen/Projects/demo-app";
|
||
|
||
const meta = (providerId: string): SessionMeta => ({
|
||
providerId,
|
||
sessionId: `${providerId}-session`,
|
||
title: `${providerId} 会话`,
|
||
projectDir: PROJECT,
|
||
createdAt: 1791014400000,
|
||
lastActiveAt: 1791018000000,
|
||
sourcePath: `/mock/${providerId}.jsonl`,
|
||
resumeCommand: `${providerId} resume`,
|
||
});
|
||
|
||
const transcriptOf = (messages: SessionMessage[]): SessionTranscriptResult => ({
|
||
header: null,
|
||
messages,
|
||
isStreaming: false,
|
||
progress: { loaded: messages.length, total: messages.length },
|
||
isLoading: false,
|
||
isError: false,
|
||
error: null,
|
||
refetch: async () => undefined,
|
||
});
|
||
|
||
const onCopy = vi.fn();
|
||
|
||
const renderReader = (
|
||
providerId: string,
|
||
messages: SessionMessage[],
|
||
): ReturnType<typeof render> => {
|
||
const client = new QueryClient({
|
||
defaultOptions: { queries: { retry: false } },
|
||
});
|
||
const ui: ReactElement = (
|
||
<QueryClientProvider client={client}>
|
||
<SessionReader
|
||
session={meta(providerId)}
|
||
appName={providerId === "claude" ? "Claude Code" : providerId}
|
||
transcript={transcriptOf(messages)}
|
||
listQuery=""
|
||
launchTerminal={null}
|
||
hasPrev={false}
|
||
hasNext={false}
|
||
onPrev={vi.fn()}
|
||
onNext={vi.fn()}
|
||
onBack={vi.fn()}
|
||
onLaunch={vi.fn()}
|
||
onCopy={onCopy}
|
||
onOpenTerminalSettings={vi.fn()}
|
||
onReload={vi.fn()}
|
||
onDelete={vi.fn()}
|
||
/>
|
||
</QueryClientProvider>
|
||
);
|
||
return render(ui);
|
||
};
|
||
|
||
/** 正文滚动区(右侧对话目录里也有同样的摘要,断言正文时限定在这里) */
|
||
const conversation = () => screen.getByRole("region", { name: "对话内容" });
|
||
|
||
/** 步骤行按钮(accessible name 带完整标题与状态) */
|
||
const stepButton = (name: RegExp) => screen.getByRole("button", { name });
|
||
|
||
describe("SessionReader", () => {
|
||
beforeEach(() => {
|
||
onCopy.mockReset();
|
||
window.localStorage.clear();
|
||
clearSessionImageCache();
|
||
// jsdom 没有 IntersectionObserver,图片会直接加载;统一给个假图
|
||
URL.createObjectURL = vi.fn(() => "blob:mock-image");
|
||
URL.revokeObjectURL = vi.fn();
|
||
vi.spyOn(sessionsApi, "getImage").mockResolvedValue(new ArrayBuffer(8));
|
||
});
|
||
|
||
afterEach(() => {
|
||
vi.restoreAllMocks();
|
||
});
|
||
|
||
describe("五家 Agent 共用骨架,只换符号、动词与合并规则", () => {
|
||
it("Claude:⏺ 符号、Name(arg) 标题、失败步骤常显、主题色", () => {
|
||
const { container } = renderReader("claude", fixtures.claude);
|
||
|
||
expect(
|
||
container.querySelector('[data-reader-style="claude"]'),
|
||
).toHaveStyle({ "--reader-accent": "var(--agent-claude)" });
|
||
// 标题用会话标题,不是路径
|
||
expect(
|
||
screen.getByRole("heading", { level: 1, name: "claude 会话" }),
|
||
).toBeInTheDocument();
|
||
expect(
|
||
within(conversation()).getByText(
|
||
/cargo build 一直报 tokio_util 找不到/,
|
||
),
|
||
).toBeInTheDocument();
|
||
// 第一轮有最终回复 → 执行过程折叠;失败的 Bash 仍常显并带 exit
|
||
expect(
|
||
screen.getAllByRole("button", { name: /^展开执行过程/ }).length,
|
||
).toBeGreaterThan(0);
|
||
const failed = stepButton(/Bash\(cargo build 2>&1 \| tail -50\)/);
|
||
expect(failed).toHaveAttribute("aria-expanded", "true");
|
||
expect(within(failed).getByText("exit 101")).toBeInTheDocument();
|
||
// 成功的 Edit 折在摘要里,Claude 显示为 Update
|
||
expect(
|
||
screen.queryByRole("button", { name: /Update\(Cargo\.toml\)/ }),
|
||
).not.toBeInTheDocument();
|
||
// 最终回复:⏺ + Agent 名 + 模型
|
||
expect(screen.getAllByText("Claude Code").length).toBeGreaterThan(0);
|
||
expect(screen.getAllByText(/claude-opus-5-5/).length).toBeGreaterThan(0);
|
||
// 事件:PR 链接、上下文压缩、Hook 错误
|
||
expect(
|
||
screen.getByRole("button", { name: /在浏览器中打开/ }),
|
||
).toHaveTextContent("已关联 PR #128");
|
||
expect(screen.getByText("上下文已压缩")).toBeInTheDocument();
|
||
expect(screen.getByText("Hook 执行出错")).toBeInTheDocument();
|
||
});
|
||
|
||
it("Claude:连续成功的同一 MCP 服务器调用合并为 Called X N times", () => {
|
||
const mcp = (id: string) =>
|
||
call(id, "mcp", {
|
||
rawName: "mcp__slack__post",
|
||
title: "slack.post",
|
||
server: "slack",
|
||
});
|
||
renderReader("claude", [
|
||
msg("user", [text("发两条消息")], { turnId: "t1" }),
|
||
msg("assistant", [mcp("m1"), mcp("m2")], { turnId: "t1" }),
|
||
msg("tool", [result("m1"), result("m2")], { turnId: "t1" }),
|
||
msg("assistant", [call("b1")], { turnId: "t1" }),
|
||
msg("tool", [result("b1", "error")], { turnId: "t1" }),
|
||
]);
|
||
// 没有最终回复 → 整轮自动展开
|
||
expect(stepButton(/Called slack 2 times/)).toBeInTheDocument();
|
||
});
|
||
|
||
it("Codex:• 符号、Ran / Search 动词、连续读取合并为 Explored", () => {
|
||
renderReader("codex", fixtures.codex);
|
||
expect(stepButton(/Search rg -n "--legacy" docs/)).toHaveAttribute(
|
||
"aria-expanded",
|
||
"true",
|
||
);
|
||
// 第二轮被中断 → 自动展开,能看到中断的命令
|
||
expect(stepButton(/Ran pnpm build/)).toBeInTheDocument();
|
||
expect(screen.getAllByText("已中断").length).toBeGreaterThan(0);
|
||
// 注入的 AGENTS.md 默认隐藏
|
||
expect(screen.queryByText(/上下文 · AGENTS\.md/)).not.toBeInTheDocument();
|
||
});
|
||
|
||
it("Codex:连续成功的 read / search 合并成 Explored", () => {
|
||
renderReader("codex", [
|
||
msg("user", [text("看看代码")], { turnId: "t1" }),
|
||
msg(
|
||
"assistant",
|
||
[
|
||
call("r1", "read", { title: "a.ts" }),
|
||
call("s1", "search", { title: "foo in src" }),
|
||
],
|
||
{ turnId: "t1" },
|
||
),
|
||
msg("tool", [result("r1"), result("s1")], { turnId: "t1" }),
|
||
msg("assistant", [call("x1")], { turnId: "t1" }),
|
||
msg("tool", [result("x1", "error")], { turnId: "t1" }),
|
||
]);
|
||
const explored = stepButton(/Explored/);
|
||
expect(explored).toHaveTextContent("查看了 1 个文件、搜索 1 次");
|
||
fireEvent.click(explored);
|
||
expect(stepButton(/Read a\.ts/)).toBeInTheDocument();
|
||
});
|
||
|
||
it("Gemini:✓ / x 状态符与 ✦ 助手符号", () => {
|
||
renderReader("gemini", fixtures.gemini);
|
||
// 第一轮失败的 build 用 x,第二轮被中断的用 -
|
||
const [failed, interrupted] = screen.getAllByRole("button", {
|
||
name: /Shell npm run build/,
|
||
});
|
||
expect(within(failed).getByText("x")).toBeInTheDocument();
|
||
expect(within(interrupted).getByText("-")).toBeInTheDocument();
|
||
expect(screen.getAllByText("✦").length).toBeGreaterThan(0);
|
||
expect(
|
||
screen.getByText(/You have exhausted your capacity/),
|
||
).toBeInTheDocument();
|
||
});
|
||
|
||
it("OpenCode:$ / → 图标标题,摘要合计 tokens 与费用", () => {
|
||
renderReader("opencode", fixtures.opencode);
|
||
expect(stepButton(/\$ bunx biome --version/)).toHaveAttribute(
|
||
"aria-expanded",
|
||
"true",
|
||
);
|
||
const summaries = screen.getAllByRole("button", {
|
||
name: /执行过程/,
|
||
});
|
||
expect(summaries[0].textContent).toMatch(/tok/);
|
||
expect(summaries[0].textContent).toMatch(/\$0\.0\d+/);
|
||
});
|
||
|
||
it("Pi:小写动词与 ● 状态符", () => {
|
||
renderReader("pi", fixtures.pi);
|
||
const failed = stepButton(/bash pnpm test -- LoginButton(失败)/);
|
||
expect(within(failed).getByText("●")).toBeInTheDocument();
|
||
});
|
||
|
||
it("其余来源用通用动词(i18n)", () => {
|
||
renderReader("hermes", fixtures.generic);
|
||
fireEvent.click(
|
||
screen.getAllByRole("button", { name: /^展开执行过程/ })[0],
|
||
);
|
||
expect(stepButton(/运行 df -h \//)).toBeInTheDocument();
|
||
});
|
||
});
|
||
|
||
it("失败步骤常显,输出预览默认展开前 6 行", () => {
|
||
renderReader("claude", fixtures.claude);
|
||
const region = screen.getByRole("region", {
|
||
name: /Bash\(cargo build 2>&1 \| tail -50\)/,
|
||
});
|
||
expect(region).toHaveTextContent("error[E0433]");
|
||
expect(region).toHaveTextContent("还有 82 行");
|
||
});
|
||
|
||
it("参数超过预览上限的 shell 命令:显示「参数」区并按需取完整命令", async () => {
|
||
const longCommand = `echo ${"x".repeat(560)} && echo LONG_COMMAND_TAIL`;
|
||
const fullInput = JSON.stringify({ command: longCommand });
|
||
const getBlockContent = vi
|
||
.spyOn(sessionsApi, "getBlockContent")
|
||
.mockResolvedValue({
|
||
text: fullInput,
|
||
totalLen: fullInput.length,
|
||
truncated: false,
|
||
});
|
||
const messages: SessionMessage[] = [
|
||
{
|
||
role: "user",
|
||
content: "run it",
|
||
ts: 1791014400000,
|
||
turnId: "t1",
|
||
blocks: [{ type: "text", text: "run it" }],
|
||
},
|
||
{
|
||
role: "assistant",
|
||
content: "",
|
||
ts: 1791014401000,
|
||
turnId: "t1",
|
||
blocks: [
|
||
{
|
||
type: "tool_call",
|
||
id: "toolu_long",
|
||
rawName: "Bash",
|
||
kind: "shell",
|
||
title: longCommand.slice(0, 200),
|
||
inputPreview: fullInput.slice(0, 400),
|
||
inputTotalLen: fullInput.length,
|
||
inputFull: {
|
||
kind: "jsonl",
|
||
offset: 10,
|
||
len: 700,
|
||
pointer: "/message/content/0/input",
|
||
},
|
||
},
|
||
],
|
||
},
|
||
{
|
||
role: "tool",
|
||
content: "",
|
||
ts: 1791014402000,
|
||
turnId: "t1",
|
||
blocks: [
|
||
{
|
||
type: "tool_result",
|
||
callId: "toolu_long",
|
||
status: "success",
|
||
preview: "ok",
|
||
totalLen: 2,
|
||
lineCount: 1,
|
||
truncated: false,
|
||
},
|
||
],
|
||
},
|
||
{
|
||
role: "assistant",
|
||
content: "",
|
||
ts: 1791014403000,
|
||
turnId: "t1",
|
||
blocks: [{ type: "text", text: "done" }],
|
||
},
|
||
];
|
||
renderReader("claude", messages);
|
||
|
||
fireEvent.click(stepButton(/Bash\(echo x/));
|
||
const region = screen.getByRole("region", { name: /Bash\(echo x/ });
|
||
expect(within(region).getByText("参数")).toBeInTheDocument();
|
||
expect(region).not.toHaveTextContent("LONG_COMMAND_TAIL");
|
||
|
||
fireEvent.click(within(region).getByRole("button", { name: "显示全部" }));
|
||
expect(
|
||
await within(region).findByText(/LONG_COMMAND_TAIL/),
|
||
).toBeInTheDocument();
|
||
expect(getBlockContent).toHaveBeenCalledWith(
|
||
"claude",
|
||
"/mock/claude.jsonl",
|
||
expect.objectContaining({ kind: "jsonl", offset: 10 }),
|
||
expect.objectContaining({ offset: 0 }),
|
||
);
|
||
});
|
||
|
||
it("「显示全部」按需取全文", async () => {
|
||
const getBlockContent = vi
|
||
.spyOn(sessionsApi, "getBlockContent")
|
||
.mockResolvedValue({
|
||
text: "FULL OUTPUT line 1\nFULL OUTPUT line 88",
|
||
totalLen: 40,
|
||
truncated: false,
|
||
});
|
||
renderReader("claude", fixtures.claude);
|
||
const region = screen.getByRole("region", {
|
||
name: /Bash\(cargo build 2>&1 \| tail -50\)/,
|
||
});
|
||
expect(getBlockContent).not.toHaveBeenCalled();
|
||
fireEvent.click(within(region).getByRole("button", { name: "显示全部" }));
|
||
|
||
expect(
|
||
await within(region).findByText(/FULL OUTPUT line 88/),
|
||
).toBeInTheDocument();
|
||
expect(getBlockContent).toHaveBeenCalledWith(
|
||
"claude",
|
||
"/mock/claude.jsonl",
|
||
expect.objectContaining({ kind: "jsonl", offset: 201733 }),
|
||
expect.objectContaining({ offset: 0 }),
|
||
);
|
||
});
|
||
|
||
it("压缩摘要只下发预览,展开后按引用取全文", async () => {
|
||
const getBlockContent = vi
|
||
.spyOn(sessionsApi, "getBlockContent")
|
||
.mockResolvedValue({
|
||
text: "Summary: FULL COMPACTION SUMMARY",
|
||
totalLen: 31,
|
||
truncated: false,
|
||
});
|
||
renderReader("codex", fixtures.codex);
|
||
fireEvent.click(screen.getByRole("button", { name: /上下文已压缩/ }));
|
||
expect(getBlockContent).not.toHaveBeenCalled();
|
||
fireEvent.click(screen.getByRole("button", { name: "显示全部" }));
|
||
|
||
expect(
|
||
await screen.findByText(/FULL COMPACTION SUMMARY/),
|
||
).toBeInTheDocument();
|
||
expect(getBlockContent).toHaveBeenCalledWith(
|
||
"codex",
|
||
"/mock/codex.jsonl",
|
||
expect.objectContaining({ kind: "jsonl", pointer: "/payload/message" }),
|
||
expect.objectContaining({ offset: 0 }),
|
||
);
|
||
});
|
||
|
||
it("图片进入视口才请求,缩略图可放大", async () => {
|
||
const observers: IntersectionObserverCallback[] = [];
|
||
vi.stubGlobal(
|
||
"IntersectionObserver",
|
||
class {
|
||
constructor(callback: IntersectionObserverCallback) {
|
||
observers.push(callback);
|
||
}
|
||
observe() {}
|
||
disconnect() {}
|
||
unobserve() {}
|
||
},
|
||
);
|
||
const getImage = vi.mocked(sessionsApi.getImage);
|
||
|
||
renderReader("claude", fixtures.claude);
|
||
expect(screen.getByRole("img", { name: "图 1" })).toHaveAttribute(
|
||
"aria-busy",
|
||
"true",
|
||
);
|
||
expect(getImage).not.toHaveBeenCalled();
|
||
|
||
act(() => {
|
||
observers.forEach((callback) =>
|
||
callback(
|
||
[{ isIntersecting: true } as IntersectionObserverEntry],
|
||
{} as IntersectionObserver,
|
||
),
|
||
);
|
||
});
|
||
await waitFor(() => expect(getImage).toHaveBeenCalled());
|
||
const open = await screen.findByRole("button", { name: /放大查看:图 1/ });
|
||
expect(within(open).getByRole("img")).toHaveAttribute(
|
||
"src",
|
||
"blob:mock-image",
|
||
);
|
||
fireEvent.click(open);
|
||
expect(await screen.findByRole("dialog")).toBeInTheDocument();
|
||
vi.unstubAllGlobals();
|
||
});
|
||
|
||
it("查找命中在折叠的步骤里时自动展开该轮与该步", async () => {
|
||
renderReader("claude", fixtures.claude);
|
||
expect(
|
||
screen.queryByRole("button", { name: /Grep\(tokio_util in src\)/ }),
|
||
).not.toBeInTheDocument();
|
||
|
||
fireEvent.click(screen.getByRole("button", { name: "在会话中查找" }));
|
||
fireEvent.change(screen.getByRole("textbox", { name: "查找内容" }), {
|
||
target: { value: "LinesCodec;" },
|
||
});
|
||
await waitFor(() =>
|
||
expect(stepButton(/Grep\(tokio_util in src\)/)).toHaveAttribute(
|
||
"aria-expanded",
|
||
"true",
|
||
),
|
||
);
|
||
expect(
|
||
screen.getByRole("region", { name: /Grep\(tokio_util in src\)/ }),
|
||
).toHaveTextContent("src/net/client.rs:7");
|
||
expect(screen.getByText(/^1 \/ \d+$/)).toBeInTheDocument();
|
||
});
|
||
|
||
it("右侧对话目录区分人和 Agent,可开关", () => {
|
||
renderReader("claude", fixtures.claude);
|
||
const outline = screen.getByRole("navigation", { name: "对话目录" });
|
||
// 每轮一条「你」的提问 + 一条 Agent 的输出
|
||
const questions = within(outline).getAllByText("你", { exact: false });
|
||
expect(questions.length).toBeGreaterThan(0);
|
||
expect(
|
||
within(outline).getByText(/cargo build 一直报 tokio_util 找不到/),
|
||
).toBeInTheDocument();
|
||
expect(within(outline).getByText(/构建已经通过/)).toBeInTheDocument();
|
||
|
||
// 点 Agent 那条能跳过去(不报错即可,滚动由虚拟列表负责)
|
||
fireEvent.click(within(outline).getByText(/构建已经通过/));
|
||
|
||
// 关掉后目录消失,开关记住状态
|
||
fireEvent.click(screen.getByRole("button", { name: "对话目录" }));
|
||
expect(
|
||
screen.queryByRole("navigation", { name: "对话目录" }),
|
||
).not.toBeInTheDocument();
|
||
expect(window.localStorage.getItem("cc-switch.sessionReader.outline")).toBe(
|
||
"false",
|
||
);
|
||
});
|
||
|
||
it("导出为 Markdown 文件:用会话标题做文件名,内容与复制为 Markdown 一致", async () => {
|
||
const exportMarkdown = vi
|
||
.spyOn(sessionsApi, "exportMarkdown")
|
||
.mockResolvedValue("/tmp/claude 会话.md");
|
||
renderReader("claude", fixtures.claude);
|
||
|
||
fireEvent.click(
|
||
screen.getByRole("button", { name: "导出为 Markdown 文件" }),
|
||
);
|
||
|
||
await waitFor(() => expect(exportMarkdown).toHaveBeenCalledTimes(1));
|
||
const [name, content] = exportMarkdown.mock.calls[0];
|
||
expect(name).toBe("claude 会话.md");
|
||
expect(content).toContain("claude 会话");
|
||
expect(content).toContain("cargo build 一直报 tokio_util 找不到");
|
||
});
|
||
|
||
it("全部 / 对话 / 改动 三选一", () => {
|
||
renderReader("claude", fixtures.claude);
|
||
fireEvent.click(screen.getByRole("button", { name: "对话" }));
|
||
expect(screen.getByRole("button", { name: "对话" })).toHaveAttribute(
|
||
"aria-pressed",
|
||
"true",
|
||
);
|
||
expect(
|
||
screen.queryByRole("button", { name: /Bash\(/ }),
|
||
).not.toBeInTheDocument();
|
||
expect(
|
||
within(conversation()).getByText(/cargo build 一直报 tokio_util 找不到/),
|
||
).toBeInTheDocument();
|
||
|
||
fireEvent.click(screen.getByRole("button", { name: "改动" }));
|
||
expect(screen.getByRole("button", { name: "对话" })).toHaveAttribute(
|
||
"aria-pressed",
|
||
"false",
|
||
);
|
||
expect(stepButton(/Update\(Cargo\.toml\)/)).toBeInTheDocument();
|
||
expect(stepButton(/Write\(CHANGELOG\.md\)/)).toBeInTheDocument();
|
||
expect(
|
||
screen.queryByRole("button", { name: /Bash\(/ }),
|
||
).not.toBeInTheDocument();
|
||
|
||
fireEvent.click(screen.getByRole("button", { name: "全部" }));
|
||
expect(screen.getByRole("button", { name: "全部" })).toHaveAttribute(
|
||
"aria-pressed",
|
||
"true",
|
||
);
|
||
});
|
||
|
||
it("复制 Markdown 含步骤列表,默认不含思考", async () => {
|
||
renderReader("claude", fixtures.claude);
|
||
await userEvent.click(
|
||
screen.getByRole("button", { name: "claude 会话 的更多操作" }),
|
||
);
|
||
await userEvent.click(
|
||
await screen.findByRole("menuitem", { name: "复制整段为 Markdown" }),
|
||
);
|
||
expect(onCopy).toHaveBeenCalledTimes(1);
|
||
const [markdown] = onCopy.mock.calls[0] as [string, string];
|
||
expect(markdown).toMatch(/^# claude 会话/);
|
||
expect(markdown).toContain("**你**");
|
||
expect(markdown).toContain(
|
||
"- ⏺ Bash(cargo build 2>&1 | tail -50) — 失败 · exit 101 · 12s",
|
||
);
|
||
expect(markdown).toContain("- ⏺ Update(Cargo.toml) — +1 −0");
|
||
expect(markdown).toContain("**Claude Code**");
|
||
expect(markdown).not.toContain("先看 Cargo.toml 里的 [dependencies]");
|
||
});
|
||
|
||
it("复制这一轮", () => {
|
||
renderReader("claude", fixtures.claude);
|
||
fireEvent.click(screen.getAllByRole("button", { name: "复制这一轮" })[0]);
|
||
const [markdown, message] = onCopy.mock.calls[0] as [string, string];
|
||
expect(message).toBe("已复制这一轮");
|
||
expect(markdown).toContain("Grep(tokio_util in src)");
|
||
});
|
||
|
||
it("键盘:Enter 展开折叠的执行过程与步骤", async () => {
|
||
const user = userEvent.setup();
|
||
renderReader("claude", fixtures.claude);
|
||
const summary = screen.getAllByRole("button", {
|
||
name: /^展开执行过程/,
|
||
})[0];
|
||
summary.focus();
|
||
await user.keyboard("{Enter}");
|
||
expect(summary).toHaveAttribute("aria-expanded", "true");
|
||
|
||
const read = stepButton(/Read\(Cargo\.toml:1-40\)/);
|
||
expect(read).toHaveAttribute("aria-expanded", "false");
|
||
read.focus();
|
||
await user.keyboard(" ");
|
||
expect(stepButton(/Read\(Cargo\.toml:1-40\)/)).toHaveAttribute(
|
||
"aria-expanded",
|
||
"true",
|
||
);
|
||
expect(
|
||
screen.getByRole("region", { name: /Read\(Cargo\.toml:1-40\)/ }),
|
||
).toHaveTextContent("tokio = ");
|
||
});
|
||
|
||
it("显示注入的上下文(默认隐藏)", async () => {
|
||
renderReader("codex", fixtures.codex);
|
||
expect(screen.queryByText(/上下文 · /)).not.toBeInTheDocument();
|
||
await userEvent.click(screen.getByRole("button", { name: "显示选项" }));
|
||
await userEvent.click(
|
||
await screen.findByRole("menuitemcheckbox", { name: "显示注入的上下文" }),
|
||
);
|
||
expect((await screen.findAllByText(/上下文 · /)).length).toBeGreaterThan(0);
|
||
});
|
||
|
||
it("流式加载中先显示已到的轮次与进度", () => {
|
||
const client = new QueryClient();
|
||
const partial = fixtures.claude.slice(0, 4);
|
||
render(
|
||
<QueryClientProvider client={client}>
|
||
<SessionReader
|
||
session={meta("claude")}
|
||
appName="Claude Code"
|
||
transcript={{
|
||
...transcriptOf(partial),
|
||
isStreaming: true,
|
||
progress: { loaded: 4, total: fixtures.claude.length },
|
||
}}
|
||
listQuery=""
|
||
launchTerminal={null}
|
||
hasPrev={false}
|
||
hasNext={false}
|
||
onPrev={vi.fn()}
|
||
onNext={vi.fn()}
|
||
onBack={vi.fn()}
|
||
onLaunch={vi.fn()}
|
||
onCopy={onCopy}
|
||
onOpenTerminalSettings={vi.fn()}
|
||
onReload={vi.fn()}
|
||
onDelete={vi.fn()}
|
||
/>
|
||
</QueryClientProvider>,
|
||
);
|
||
expect(
|
||
screen.getByText(`已加载 4 / ${fixtures.claude.length}`),
|
||
).toBeInTheDocument();
|
||
expect(
|
||
within(conversation()).getByText(/cargo build 一直报 tokio_util 找不到/),
|
||
).toBeInTheDocument();
|
||
});
|
||
});
|