2026-09-01 13:02:22 -07:00
|
|
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
|
|
|
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
|
|
|
|
import SendToAgentButton from "./SendToAgentButton";
|
|
|
|
|
import type { Project, TerminalSession } from "../../lib/types";
|
|
|
|
|
|
|
|
|
|
const sendInput = vi.fn(async () => {});
|
|
|
|
|
let sessions: TerminalSession[] = [];
|
|
|
|
|
|
|
|
|
|
vi.mock("../../hooks/useTerminal", () => ({
|
|
|
|
|
useTerminal: () => ({ sessions, sendInput }),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
const setActiveTabKey = vi.fn();
|
|
|
|
|
const pushToast = vi.fn();
|
|
|
|
|
let projects: Project[] = [];
|
|
|
|
|
|
|
|
|
|
vi.mock("../../store/appState", () => ({
|
|
|
|
|
useAppState: Object.assign(
|
|
|
|
|
(selector: (s: unknown) => unknown) =>
|
|
|
|
|
selector({ projects, setActiveTabKey, pushToast }),
|
|
|
|
|
{ getState: () => ({ projects, setActiveTabKey, pushToast }) },
|
|
|
|
|
),
|
|
|
|
|
terminalTabKey: (id: string) => `term:${id}`,
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
const session = (over: Partial<TerminalSession> = {}): TerminalSession => ({
|
|
|
|
|
id: "s1",
|
|
|
|
|
projectId: "p1",
|
|
|
|
|
projectName: "api",
|
|
|
|
|
sessionType: "claude",
|
|
|
|
|
sessionName: null,
|
|
|
|
|
...over,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
vi.clearAllMocks();
|
|
|
|
|
sessions = [];
|
|
|
|
|
projects = [{ id: "p1", name: "api", renamed_session_names: {} } as unknown as Project];
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("SendToAgentButton", () => {
|
2026-09-02 12:10:00 -07:00
|
|
|
// Unavailable, not `disabled`: the reason a note cannot be sent is the whole
|
|
|
|
|
// content of these states, and native `disabled` announces it to nobody.
|
|
|
|
|
it("says why it cannot send when the project has no running session", () => {
|
2026-09-01 13:02:22 -07:00
|
|
|
render(<SendToAgentButton projectId="p1" body="hello" />);
|
2026-09-02 12:10:00 -07:00
|
|
|
const button = screen.getByRole("button", { name: /send to agent/i });
|
|
|
|
|
expect(button).toHaveAttribute("aria-disabled", "true");
|
|
|
|
|
expect(button).toHaveAccessibleDescription(
|
|
|
|
|
"No running Claude session for this project",
|
|
|
|
|
);
|
2026-09-01 13:02:22 -07:00
|
|
|
});
|
|
|
|
|
|
2026-09-02 12:10:00 -07:00
|
|
|
it("says why it cannot send an empty note", () => {
|
|
|
|
|
sessions = [session()];
|
|
|
|
|
render(<SendToAgentButton projectId="p1" body=" " />);
|
|
|
|
|
expect(
|
|
|
|
|
screen.getByRole("button", { name: /send to agent/i }),
|
|
|
|
|
).toHaveAccessibleDescription("Nothing to send — this note is empty");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("is unavailable when the only session belongs to another project", () => {
|
2026-09-01 13:02:22 -07:00
|
|
|
sessions = [session({ projectId: "other" })];
|
|
|
|
|
render(<SendToAgentButton projectId="p1" body="hello" />);
|
2026-09-02 12:10:00 -07:00
|
|
|
expect(
|
|
|
|
|
screen.getByRole("button", { name: /send to agent/i }),
|
|
|
|
|
).toHaveAttribute("aria-disabled", "true");
|
2026-09-01 13:02:22 -07:00
|
|
|
});
|
|
|
|
|
|
2026-09-02 12:10:00 -07:00
|
|
|
it("is unavailable when the only session is a bash tab", () => {
|
2026-09-01 13:02:22 -07:00
|
|
|
// `bash -l`'s readline has no binding for ESC+CR and just bells, so a
|
|
|
|
|
// shell is never a target.
|
|
|
|
|
sessions = [session({ sessionType: "bash" })];
|
|
|
|
|
render(<SendToAgentButton projectId="p1" body="hello" />);
|
2026-09-02 12:10:00 -07:00
|
|
|
expect(
|
|
|
|
|
screen.getByRole("button", { name: /send to agent/i }),
|
|
|
|
|
).toHaveAttribute("aria-disabled", "true");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// `aria-disabled` is advisory — it blocks nothing on its own. Without the
|
|
|
|
|
// guard this swap would turn a greyed-out button into a live one.
|
|
|
|
|
it("sends nothing when activated while unavailable", () => {
|
|
|
|
|
render(<SendToAgentButton projectId="p1" body="hello" />);
|
|
|
|
|
const button = screen.getByRole("button", { name: /send to agent/i });
|
|
|
|
|
|
|
|
|
|
fireEvent.click(button);
|
|
|
|
|
fireEvent.keyDown(button, { key: "Enter" });
|
|
|
|
|
fireEvent.keyDown(button, { key: " " });
|
|
|
|
|
|
|
|
|
|
expect(sendInput).not.toHaveBeenCalled();
|
|
|
|
|
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
|
2026-09-01 13:02:22 -07:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("sends straight to the one session, with newlines converted and no terminator", async () => {
|
|
|
|
|
sessions = [session()];
|
|
|
|
|
render(<SendToAgentButton projectId="p1" body={"one\ntwo"} />);
|
|
|
|
|
|
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: /send to agent/i }));
|
|
|
|
|
|
|
|
|
|
await waitFor(() => expect(sendInput).toHaveBeenCalledWith("s1", "one\x1b\rtwo"));
|
|
|
|
|
expect(sendInput.mock.calls[0][1].endsWith("\r")).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("focuses the terminal it sent to, so the user watches it land", async () => {
|
|
|
|
|
sessions = [session()];
|
|
|
|
|
render(<SendToAgentButton projectId="p1" body="hi" />);
|
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: /send to agent/i }));
|
|
|
|
|
await waitFor(() => expect(setActiveTabKey).toHaveBeenCalledWith("term:s1"));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("offers a menu of display names when several sessions are open", async () => {
|
|
|
|
|
sessions = [session(), session({ id: "s2", sessionName: "review" })];
|
|
|
|
|
projects = [
|
|
|
|
|
{ id: "p1", name: "api", renamed_session_names: { s1: "release" } } as unknown as Project,
|
|
|
|
|
];
|
|
|
|
|
render(<SendToAgentButton projectId="p1" body="hi" />);
|
|
|
|
|
|
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: /send to agent/i }));
|
|
|
|
|
expect(sendInput).not.toHaveBeenCalled();
|
|
|
|
|
|
|
|
|
|
fireEvent.click(await screen.findByRole("menuitem", { name: "api: release" }));
|
|
|
|
|
await waitFor(() => expect(sendInput).toHaveBeenCalledWith("s1", "hi"));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("reports a failed send rather than looking like it worked", async () => {
|
|
|
|
|
sessions = [session()];
|
|
|
|
|
sendInput.mockRejectedValueOnce(new Error("session closed"));
|
|
|
|
|
render(<SendToAgentButton projectId="p1" body="hi" />);
|
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: /send to agent/i }));
|
|
|
|
|
await waitFor(() => expect(pushToast).toHaveBeenCalled());
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("does nothing for an empty note", () => {
|
|
|
|
|
sessions = [session()];
|
|
|
|
|
render(<SendToAgentButton projectId="p1" body=" " />);
|
2026-09-02 12:10:00 -07:00
|
|
|
const button = screen.getByRole("button", { name: /send to agent/i });
|
|
|
|
|
expect(button).toHaveAttribute("aria-disabled", "true");
|
|
|
|
|
|
|
|
|
|
fireEvent.click(button);
|
|
|
|
|
fireEvent.keyDown(button, { key: "Enter" });
|
|
|
|
|
expect(sendInput).not.toHaveBeenCalled();
|
2026-09-01 13:02:22 -07:00
|
|
|
});
|
2026-09-02 12:10:00 -07:00
|
|
|
|
|
|
|
|
it("opens the session menu upward when it sits at the foot of the dock", async () => {
|
|
|
|
|
sessions = [session({ id: "s1" }), session({ id: "s2" })];
|
|
|
|
|
render(<SendToAgentButton projectId="p1" body="hello" dropUp />);
|
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: /send to agent/i }));
|
|
|
|
|
|
|
|
|
|
// Anchored to the button's top edge, not below it: the dock clips its own
|
|
|
|
|
// overflow, so a downward menu at the bottom edge is invisible.
|
|
|
|
|
await waitFor(() => expect(screen.getByRole("menu")).toHaveClass("bottom-full"));
|
|
|
|
|
});
|
|
|
|
|
});
|