mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-05-20 16:42:25 +00:00
374 lines
11 KiB
TypeScript
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();
|
|
}
|
|
});
|
|
});
|