304 lines
9.6 KiB
TypeScript
304 lines
9.6 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
|
import { sessionId, taskId, type Message } from "@/lib/types/http";
|
|
import type { UseShellCommandOutputResult } from "@/hooks/domains/session/use-shell-command-output";
|
|
import { ToolExecuteMessage } from "./tool-execute-message";
|
|
|
|
const COMMAND_TEST_ID = "tool-execute-command";
|
|
|
|
const useShellOutputMock = vi.hoisted(() => vi.fn());
|
|
const runningMessageID = "message-running";
|
|
const outputUpdatedAt = "2026-07-16T12:00:02Z";
|
|
|
|
vi.mock("@/hooks/domains/session/use-shell-command-output", () => ({
|
|
useShellCommandOutput: useShellOutputMock,
|
|
}));
|
|
|
|
afterEach(cleanup);
|
|
|
|
beforeEach(() => {
|
|
useShellOutputMock.mockReset();
|
|
useShellOutputMock.mockReturnValue(hookResult());
|
|
});
|
|
|
|
type ShellOutputSummary = {
|
|
exit_code?: number;
|
|
has_output?: boolean;
|
|
stdout_bytes?: number;
|
|
stderr_bytes?: number;
|
|
truncated?: boolean;
|
|
stdout?: string;
|
|
};
|
|
|
|
function executeMessage(
|
|
status: "pending" | "running" | "in_progress" | "complete" | "error" | "cancelled",
|
|
output: ShellOutputSummary = {},
|
|
command = "printf normalized-command",
|
|
): Message {
|
|
return {
|
|
id: `message-${status}`,
|
|
session_id: sessionId("session-1"),
|
|
task_id: taskId("task-1"),
|
|
author_type: "agent",
|
|
content: "fallback message content",
|
|
type: "tool_execute",
|
|
created_at: "2026-07-16T12:00:00Z",
|
|
metadata: {
|
|
status,
|
|
normalized: {
|
|
shell_exec: {
|
|
command,
|
|
work_dir: "/workspace/with/a/long/path",
|
|
output,
|
|
},
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
function retainedOutputSummary(overrides: ShellOutputSummary = {}): ShellOutputSummary {
|
|
return { has_output: true, stdout_bytes: 1, ...overrides };
|
|
}
|
|
|
|
function hookResult(
|
|
overrides: Partial<UseShellCommandOutputResult> = {},
|
|
): UseShellCommandOutputResult {
|
|
return {
|
|
snapshot: null,
|
|
isLoading: false,
|
|
error: null,
|
|
retry: vi.fn(),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function openOutput() {
|
|
fireEvent.click(screen.getByRole("button", { name: /show command output/i }));
|
|
}
|
|
|
|
describe("ToolExecuteMessage command row", () => {
|
|
it("hides the output disclosure when the projected summary is empty", () => {
|
|
render(
|
|
<ToolExecuteMessage
|
|
comment={executeMessage("complete", {
|
|
exit_code: 0,
|
|
has_output: false,
|
|
stdout_bytes: 0,
|
|
stderr_bytes: 0,
|
|
})}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.getByTestId(COMMAND_TEST_ID)).toBeTruthy();
|
|
expect(screen.queryByRole("button", { name: /show command output/i })).toBeNull();
|
|
expect(useShellOutputMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("keeps the normalized command and working directory visible while output is collapsed", () => {
|
|
render(
|
|
<ToolExecuteMessage
|
|
comment={executeMessage(
|
|
"running",
|
|
retainedOutputSummary({ stdout_bytes: 2048, stderr_bytes: 0 }),
|
|
)}
|
|
/>,
|
|
);
|
|
|
|
const command = screen.getByTestId(COMMAND_TEST_ID);
|
|
expect(command.textContent).toBe("printf normalized-command");
|
|
expect(command.className).toContain("whitespace-pre-wrap");
|
|
expect(screen.getByText("/workspace/with/a/long/path")).toBeTruthy();
|
|
expect(
|
|
screen.getByRole("button", { name: /show command output/i }).getAttribute("aria-expanded"),
|
|
).toBe("false");
|
|
expect(screen.queryByTestId("tool-execute-output")).toBeNull();
|
|
expect(useShellOutputMock).toHaveBeenLastCalledWith(
|
|
expect.objectContaining({ isOpen: false, sessionId: "session-1" }),
|
|
);
|
|
});
|
|
|
|
it("falls back to message content when the normalized command is absent", () => {
|
|
render(<ToolExecuteMessage comment={executeMessage("complete", {}, "")} />);
|
|
|
|
expect(screen.getByTestId(COMMAND_TEST_ID).textContent).toBe("fallback message content");
|
|
});
|
|
|
|
it("renders loading and empty snapshot states only after opening", () => {
|
|
useShellOutputMock.mockReturnValue(hookResult({ isLoading: true }));
|
|
const outputSummary = retainedOutputSummary();
|
|
const view = render(<ToolExecuteMessage comment={executeMessage("running", outputSummary)} />);
|
|
|
|
expect(screen.queryByText("Loading command output...")).toBeNull();
|
|
openOutput();
|
|
expect(screen.getByText("Loading command output...")).toBeTruthy();
|
|
|
|
useShellOutputMock.mockReturnValue(
|
|
hookResult({
|
|
snapshot: {
|
|
message_id: runningMessageID,
|
|
status: "running",
|
|
updated_at: "2026-07-16T12:00:01Z",
|
|
output: {},
|
|
},
|
|
}),
|
|
);
|
|
view.rerender(<ToolExecuteMessage comment={executeMessage("running", outputSummary)} />);
|
|
expect(screen.getByText("No command output yet.")).toBeTruthy();
|
|
expect(screen.queryByText(/Exit code/)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("ToolExecuteMessage output states", () => {
|
|
it("renders stdout, stderr, truncation, and a known nonzero exit from the snapshot", () => {
|
|
useShellOutputMock.mockReturnValue(
|
|
hookResult({
|
|
snapshot: {
|
|
message_id: "message-complete",
|
|
status: "failed",
|
|
updated_at: outputUpdatedAt,
|
|
output: {
|
|
stdout: "standard output",
|
|
stderr: "standard error",
|
|
truncated: true,
|
|
exit_code: 7,
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
render(
|
|
<ToolExecuteMessage
|
|
comment={executeMessage("complete", retainedOutputSummary({ exit_code: 7 }))}
|
|
/>,
|
|
);
|
|
|
|
openOutput();
|
|
expect(screen.getByText("standard output")).toBeTruthy();
|
|
expect(screen.getByText("standard error")).toBeTruthy();
|
|
expect(screen.getByText("Output truncated")).toBeTruthy();
|
|
expect(screen.getByText("Exit code 7").className).toContain("text-red");
|
|
});
|
|
|
|
it("renders unknown terminal exit neutrally and never reads a transcript body from summary metadata", () => {
|
|
useShellOutputMock.mockReturnValue(
|
|
hookResult({
|
|
snapshot: {
|
|
message_id: "message-cancelled",
|
|
status: "cancelled",
|
|
updated_at: outputUpdatedAt,
|
|
output: { stdout: "snapshot transcript" },
|
|
},
|
|
}),
|
|
);
|
|
render(
|
|
<ToolExecuteMessage
|
|
comment={executeMessage(
|
|
"cancelled",
|
|
retainedOutputSummary({ stdout_bytes: 999, stdout: "forbidden summary transcript" }),
|
|
)}
|
|
/>,
|
|
);
|
|
|
|
openOutput();
|
|
expect(screen.getByText("snapshot transcript")).toBeTruthy();
|
|
expect(screen.queryByText("forbidden summary transcript")).toBeNull();
|
|
expect(screen.getByText("Exit code unavailable").className).toContain("text-muted-foreground");
|
|
});
|
|
|
|
it("keeps the latest snapshot visible on an error and retries on command", () => {
|
|
const retry = vi.fn();
|
|
useShellOutputMock.mockReturnValue(
|
|
hookResult({
|
|
snapshot: {
|
|
message_id: runningMessageID,
|
|
status: "running",
|
|
updated_at: outputUpdatedAt,
|
|
output: { stdout: "retained transcript" },
|
|
},
|
|
error: new Error("network unavailable"),
|
|
retry,
|
|
}),
|
|
);
|
|
render(<ToolExecuteMessage comment={executeMessage("running", retainedOutputSummary())} />);
|
|
|
|
openOutput();
|
|
expect(screen.getByText("retained transcript")).toBeTruthy();
|
|
expect(screen.getByText("Command output unavailable.")).toBeTruthy();
|
|
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
|
|
expect(retry).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("shows only the unavailable state when an empty snapshot is followed by an error", () => {
|
|
useShellOutputMock.mockReturnValue(
|
|
hookResult({
|
|
snapshot: {
|
|
message_id: runningMessageID,
|
|
status: "running",
|
|
updated_at: outputUpdatedAt,
|
|
output: {},
|
|
},
|
|
error: new Error("network unavailable"),
|
|
}),
|
|
);
|
|
render(<ToolExecuteMessage comment={executeMessage("running", retainedOutputSummary())} />);
|
|
|
|
openOutput();
|
|
expect(screen.getByText("Command output unavailable.")).toBeTruthy();
|
|
expect(screen.queryByText("No command output yet.")).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("ToolExecuteMessage cancelled result", () => {
|
|
it("renders a cancelled zero exit neutrally", () => {
|
|
useShellOutputMock.mockReturnValue(
|
|
hookResult({
|
|
snapshot: {
|
|
message_id: "message-cancelled",
|
|
status: "cancelled",
|
|
updated_at: outputUpdatedAt,
|
|
output: { exit_code: 0 },
|
|
},
|
|
}),
|
|
);
|
|
render(
|
|
<ToolExecuteMessage
|
|
comment={executeMessage("cancelled", retainedOutputSummary({ exit_code: 0 }))}
|
|
/>,
|
|
);
|
|
|
|
openOutput();
|
|
expect(screen.getByText("Exit code 0").className).toContain("text-muted-foreground");
|
|
});
|
|
});
|
|
|
|
it("replaces an expanded cached transcript with the removal notice while retaining command and status", () => {
|
|
const original = executeMessage("complete", retainedOutputSummary({ exit_code: 0 }));
|
|
useShellOutputMock.mockReturnValue(
|
|
hookResult({
|
|
snapshot: {
|
|
message_id: original.id,
|
|
status: "complete",
|
|
updated_at: outputUpdatedAt,
|
|
output: { stdout: "removed secret output" },
|
|
},
|
|
}),
|
|
);
|
|
const view = render(<ToolExecuteMessage comment={original} />);
|
|
openOutput();
|
|
expect(screen.getByText("removed secret output")).toBeTruthy();
|
|
view.rerender(
|
|
<ToolExecuteMessage
|
|
comment={{
|
|
...original,
|
|
metadata: {
|
|
...original.metadata,
|
|
payload_retention: { version: 1, removed_at: outputUpdatedAt },
|
|
},
|
|
}}
|
|
/>,
|
|
);
|
|
expect(screen.queryByText("removed secret output")).toBeNull();
|
|
expect(screen.getByText(/Tool details removed on/)).toBeTruthy();
|
|
expect(screen.getByTestId(COMMAND_TEST_ID).textContent).toBe("printf normalized-command");
|
|
expect(screen.getByLabelText("Command succeeded")).toBeTruthy();
|
|
});
|