nanobot/webui/src/tests/agent-activity-cluster.test.tsx
2026-05-18 22:01:33 +08:00

374 lines
11 KiB
TypeScript

import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { AgentActivityCluster } from "@/components/thread/AgentActivityCluster";
import type { UIMessage } from "@/lib/types";
function activityMessages(extraReasoning = "", extraTool?: UIMessage): UIMessage[] {
const rows: UIMessage[] = [
{
id: "r1",
role: "assistant",
content: "",
reasoning: `thinking${extraReasoning}`,
reasoningStreaming: true,
isStreaming: true,
createdAt: 1,
},
{
id: "t1",
role: "tool",
kind: "trace",
content: "search()",
traces: ["search()"],
createdAt: 2,
},
];
if (extraTool) rows.push(extraTool);
return rows;
}
function installAnimationFrameQueue() {
const originalRequest = window.requestAnimationFrame;
const originalCancel = window.cancelAnimationFrame;
const callbacks = new Map<number, FrameRequestCallback>();
let nextId = 1;
window.requestAnimationFrame = ((callback: FrameRequestCallback) => {
const id = nextId;
nextId += 1;
callbacks.set(id, callback);
return id;
}) as typeof window.requestAnimationFrame;
window.cancelAnimationFrame = ((id: number) => {
callbacks.delete(id);
}) as typeof window.cancelAnimationFrame;
return {
flush() {
const pending = Array.from(callbacks.entries());
callbacks.clear();
for (const [, callback] of pending) callback(0);
},
restore() {
window.requestAnimationFrame = originalRequest;
window.cancelAnimationFrame = originalCancel;
},
};
}
function setScrollGeometry(
element: HTMLElement,
geometry: { scrollHeight: number; clientHeight: number; scrollTop?: number },
) {
Object.defineProperties(element, {
scrollHeight: { configurable: true, value: geometry.scrollHeight },
clientHeight: { configurable: true, value: geometry.clientHeight },
scrollTop: {
configurable: true,
value: geometry.scrollTop ?? element.scrollTop,
writable: true,
},
});
}
function installReducedMotion() {
const original = window.matchMedia;
Object.defineProperty(window, "matchMedia", {
configurable: true,
value: () => ({
matches: true,
media: "(prefers-reduced-motion: reduce)",
addEventListener: () => {},
removeEventListener: () => {},
}),
});
return () => {
Object.defineProperty(window, "matchMedia", {
configurable: true,
value: original,
});
};
}
describe("AgentActivityCluster", () => {
it("jumps to the latest activity when opened", () => {
const raf = installAnimationFrameQueue();
try {
render(
<AgentActivityCluster
messages={activityMessages()}
isTurnStreaming
hasBodyBelow={false}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /working/i }));
const scrollport = screen.getByTestId("agent-activity-scroll");
setScrollGeometry(scrollport, {
scrollHeight: 1000,
clientHeight: 120,
scrollTop: 0,
});
act(() => {
raf.flush();
});
expect(scrollport.scrollTop).toBe(880);
} finally {
raf.restore();
}
});
it("follows new reasoning and tool activity while the user is at the bottom", () => {
const raf = installAnimationFrameQueue();
try {
const { rerender } = render(
<AgentActivityCluster
messages={activityMessages()}
isTurnStreaming
hasBodyBelow={false}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /working/i }));
const scrollport = screen.getByTestId("agent-activity-scroll");
setScrollGeometry(scrollport, {
scrollHeight: 1000,
clientHeight: 120,
scrollTop: 0,
});
act(() => {
raf.flush();
});
rerender(
<AgentActivityCluster
messages={activityMessages(" with more detail", {
id: "t2",
role: "tool",
kind: "trace",
content: "open_browser()",
traces: ["open_browser()"],
createdAt: 3,
})}
isTurnStreaming
hasBodyBelow={false}
/>,
);
setScrollGeometry(scrollport, {
scrollHeight: 1500,
clientHeight: 120,
scrollTop: scrollport.scrollTop,
});
act(() => {
raf.flush();
});
expect(scrollport.scrollTop).toBe(1380);
} finally {
raf.restore();
}
});
it("does not pull the user down after they scroll up inside the activity pane", () => {
const raf = installAnimationFrameQueue();
try {
const { rerender } = render(
<AgentActivityCluster
messages={activityMessages()}
isTurnStreaming
hasBodyBelow={false}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /working/i }));
const scrollport = screen.getByTestId("agent-activity-scroll");
setScrollGeometry(scrollport, {
scrollHeight: 1000,
clientHeight: 120,
scrollTop: 0,
});
act(() => {
raf.flush();
});
scrollport.scrollTop = 100;
fireEvent.scroll(scrollport);
rerender(
<AgentActivityCluster
messages={activityMessages(" still streaming")}
isTurnStreaming
hasBodyBelow={false}
/>,
);
setScrollGeometry(scrollport, {
scrollHeight: 1500,
clientHeight: 120,
scrollTop: scrollport.scrollTop,
});
act(() => {
raf.flush();
});
expect(scrollport.scrollTop).toBe(100);
} finally {
raf.restore();
}
});
it("renders file edit totals and a compact expanded file list", async () => {
const restoreMotion = installReducedMotion();
try {
render(
<AgentActivityCluster
messages={activityMessages("", {
id: "t2",
role: "tool",
kind: "trace",
content: "edit_file()",
traces: ["edit_file()"],
fileEdits: [{
call_id: "call-edit",
tool: "edit_file",
path: "src/app.tsx",
absolute_path: "/Users/renxubin/project/src/app.tsx",
phase: "end",
added: 12,
deleted: 3,
approximate: false,
status: "done",
}],
createdAt: 3,
})}
isTurnStreaming={false}
hasBodyBelow={false}
/>,
);
expect(screen.getByRole("button", { name: /edited app\.tsx/i })).toBeInTheDocument();
expect(screen.getByTestId("activity-header-file-reference")).toHaveTextContent("app.tsx");
expect(screen.getByTestId("activity-header-file-reference")).toHaveAttribute(
"aria-label",
"/Users/renxubin/project/src/app.tsx",
);
fireEvent.click(screen.getByRole("button", { name: /edited app\.tsx/i }));
expect(screen.queryByText("Edited files")).not.toBeInTheDocument();
const fileRef = screen.getByTestId("activity-file-reference");
expect(fileRef).toHaveTextContent("src/app.tsx");
expect(fileRef).toHaveAttribute("aria-label", "/Users/renxubin/project/src/app.tsx");
await waitFor(() => {
expect(screen.getAllByText("+12").length).toBeGreaterThan(0);
expect(screen.getAllByText("-3").length).toBeGreaterThan(0);
});
} finally {
restoreMotion();
}
});
it("renders pending file edit placeholders before the path is known", () => {
render(
<AgentActivityCluster
messages={activityMessages("", {
id: "t2",
role: "tool",
kind: "trace",
content: "",
traces: [],
fileEdits: [{
call_id: "call-edit",
tool: "edit_file",
path: "",
phase: "start",
added: 0,
deleted: 0,
approximate: true,
status: "editing",
pending: true,
}],
createdAt: 3,
})}
isTurnStreaming
hasBodyBelow={false}
/>,
);
expect(screen.getByRole("button", { name: /preparing edit/i })).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /preparing edit/i }));
expect(screen.getByText("Preparing file edit…")).toBeInTheDocument();
});
it("merges repeated edits for the same path and lets successful edits win over failures", async () => {
const restoreMotion = installReducedMotion();
try {
render(
<AgentActivityCluster
messages={activityMessages("", {
id: "t2",
role: "tool",
kind: "trace",
content: "edit_file()",
traces: ["edit_file()"],
fileEdits: [
{
call_id: "call-edit-1",
tool: "edit_file",
path: "minecraft-fps/index.html",
phase: "end",
added: 2,
deleted: 1,
approximate: false,
status: "done",
},
{
call_id: "call-edit-2",
tool: "edit_file",
path: "minecraft-fps/index.html",
phase: "error",
added: 0,
deleted: 0,
approximate: false,
status: "error",
error: "patch failed",
},
{
call_id: "call-edit-3",
tool: "edit_file",
path: "minecraft-fps/index.html",
phase: "end",
added: 6,
deleted: 6,
approximate: false,
status: "done",
},
],
createdAt: 3,
})}
isTurnStreaming={false}
hasBodyBelow={false}
/>,
);
expect(screen.getByRole("button", { name: /edited index\.html/i })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /failed index\.html/i })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /edited index\.html/i }));
const fileRefs = screen.getAllByTestId("activity-file-reference");
expect(fileRefs).toHaveLength(1);
expect(fileRefs[0]).toHaveTextContent("minecraft-fps/index.html");
expect(screen.queryByText("Failed")).not.toBeInTheDocument();
await waitFor(() => {
expect(screen.getAllByText("+8").length).toBeGreaterThan(0);
expect(screen.getAllByText("-7").length).toBeGreaterThan(0);
});
} finally {
restoreMotion();
}
});
});