Files
2026-08-20 17:24:06 +01:00

234 lines
7.2 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { i18n } from "@/lib/i18n";
const mockDestroyUserShell = vi.fn();
const mockRenameUserShell = vi.fn();
const mockMarkTerminalPanelTerminateClose = vi.fn();
const mockClose = vi.fn();
const mockSetTitle = vi.fn();
const mockRemoveUserShell = vi.fn();
const mockToastError = vi.fn();
const CLOSE_TERMINAL_BUTTON_NAME = "Close Terminal";
const CONFIRM_CLOSE_BUTTON_NAME = "Close terminal";
const storeState = {
tasks: { activeTaskId: "task-1" },
userShells: {
byEnvironmentId: {
"env-1": [
{
terminalId: "shell-1",
kind: "ordinary",
seq: 1,
customName: null,
closable: true,
},
],
},
},
removeUserShell: mockRemoveUserShell,
};
vi.mock("@/components/state-provider", () => ({
useAppStore: (selector: (state: typeof storeState) => unknown) => selector(storeState),
}));
vi.mock("@/lib/api/domains/user-shell-api", () => ({
destroyUserShell: (...args: unknown[]) => mockDestroyUserShell(...args),
renameUserShell: (...args: unknown[]) => mockRenameUserShell(...args),
}));
vi.mock("@/lib/toast/sonner", () => ({
toast: { error: (...args: unknown[]) => mockToastError(...args) },
}));
vi.mock("./dockview-layout-setup", () => ({
markTerminalPanelTerminateClose: (...args: unknown[]) =>
mockMarkTerminalPanelTerminateClose(...args),
}));
vi.mock("dockview-react", () => ({
DockviewDefaultTab: ({
closeActionOverride,
hideClose,
}: {
closeActionOverride?: () => void;
hideClose?: boolean;
}) =>
hideClose ? null : (
<button type="button" className="dv-default-tab-action" onClick={closeActionOverride}>
close
</button>
),
}));
vi.mock("@kandev/ui/context-menu", () => ({
ContextMenu: ({ children }: { children: React.ReactNode }) => <>{children}</>,
ContextMenuTrigger: ({
children,
...props
}: {
children: React.ReactNode;
[key: string]: unknown;
}) => (
<div tabIndex={-1} {...props}>
{children}
</div>
),
ContextMenuContent: ({
children,
onCloseAutoFocus,
}: {
children: React.ReactNode;
onCloseAutoFocus?: (event: { preventDefault: () => void }) => void;
}) => (
<div
onClick={() => {
const preventDefault = vi.fn();
onCloseAutoFocus?.({ preventDefault });
if (!preventDefault.mock.calls.length) {
document.querySelector<HTMLElement>('[data-testid="terminal-tab-shell-1"]')?.focus();
}
}}
>
{children}
</div>
),
ContextMenuItem: ({
children,
onClick,
className,
}: {
children: React.ReactNode;
onClick?: () => void;
className?: string;
}) => (
<button type="button" onClick={onClick} className={className}>
{children}
</button>
),
ContextMenuSeparator: () => null,
}));
import { TerminalTab } from "./terminal-tab";
function makeProps() {
return {
api: {
id: "panel-1",
title: "Terminal",
setTitle: mockSetTitle,
close: mockClose,
},
params: {
terminalId: "shell-1",
taskID: "task-1",
environmentId: "env-1",
},
} as unknown as React.ComponentProps<typeof TerminalTab>;
}
describe("TerminalTab", () => {
beforeEach(() => {
vi.clearAllMocks();
mockDestroyUserShell.mockImplementation(() => new Promise<void>(() => {}));
});
afterEach(async () => {
cleanup();
await i18n.changeLanguage("en");
});
it("uses the localized ordinary-terminal title", async () => {
await i18n.changeLanguage("pseudo");
render(<TerminalTab {...makeProps()} />);
await waitFor(() => {
expect(mockSetTitle).toHaveBeenCalledWith(i18n.t("task:panelTerminal"));
});
});
it("asks for localized confirmation before closing an idle terminal", async () => {
render(<TerminalTab {...makeProps()} />);
fireEvent.click(screen.getByRole("button", { name: CLOSE_TERMINAL_BUTTON_NAME }));
expect(screen.getByRole("dialog", { name: "Close terminal?" })).toBeTruthy();
expect(mockDestroyUserShell).not.toHaveBeenCalled();
expect(mockClose).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: CONFIRM_CLOSE_BUTTON_NAME }));
await waitFor(() => {
expect(mockDestroyUserShell).toHaveBeenCalledWith("env-1", "shell-1", "task-1");
expect(mockClose).toHaveBeenCalledTimes(1);
});
expect(screen.queryByRole("status", { name: "Closing terminal" })).toBeNull();
expect(screen.getByTestId("terminal-tab-shell-1").getAttribute("aria-busy")).toBeNull();
});
it("routes context-menu terminate through the same localized confirmation", async () => {
render(<TerminalTab {...makeProps()} />);
fireEvent.click(screen.getByRole("button", { name: "Terminate" }));
expect(screen.getByRole("dialog", { name: "Close terminal?" })).toBeTruthy();
expect(screen.queryByRole("alertdialog")).toBeNull();
expect(mockDestroyUserShell).not.toHaveBeenCalled();
expect(mockClose).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: CONFIRM_CLOSE_BUTTON_NAME }));
expect(screen.queryByRole("dialog", { name: "Close terminal?" })).toBeNull();
expect(screen.queryByRole("status", { name: "Closing terminal" })).toBeNull();
await waitFor(() => {
expect(mockClose).toHaveBeenCalledTimes(1);
expect(mockDestroyUserShell).toHaveBeenCalledTimes(1);
});
});
it("focuses the inline rename input after the context menu closes", async () => {
render(<TerminalTab {...makeProps()} />);
fireEvent.click(screen.getByRole("button", { name: /^Rename/ }));
await waitFor(() => {
expect(document.activeElement).toBe(screen.getByTestId("terminal-tab-rename-input"));
});
});
it("keeps the panel dismissed and reports an error if destroy fails", async () => {
mockDestroyUserShell.mockRejectedValueOnce(new Error("network down"));
render(<TerminalTab {...makeProps()} />);
fireEvent.click(screen.getByRole("button", { name: CLOSE_TERMINAL_BUTTON_NAME }));
fireEvent.click(screen.getByRole("button", { name: CONFIRM_CLOSE_BUTTON_NAME }));
await waitFor(() => expect(mockClose).toHaveBeenCalledTimes(1));
expect(screen.queryByRole("status", { name: "Closing terminal" })).toBeNull();
await waitFor(() => {
expect(mockToastError).toHaveBeenCalledTimes(1);
});
expect(mockToastError).toHaveBeenCalledWith("Failed to close terminal");
});
it("ignores a second confirmed terminate while close is in progress", async () => {
render(<TerminalTab {...makeProps()} />);
fireEvent.click(screen.getByRole("button", { name: CLOSE_TERMINAL_BUTTON_NAME }));
fireEvent.click(screen.getByRole("button", { name: CONFIRM_CLOSE_BUTTON_NAME }));
await waitFor(() => expect(mockDestroyUserShell).toHaveBeenCalledTimes(1));
fireEvent.click(screen.getByRole("button", { name: "Terminate" }));
fireEvent.click(screen.getByRole("button", { name: CONFIRM_CLOSE_BUTTON_NAME }));
await new Promise<void>((resolve) => queueMicrotask(resolve));
expect(mockDestroyUserShell).toHaveBeenCalledTimes(1);
expect(mockDestroyUserShell).toHaveBeenCalledWith("env-1", "shell-1", "task-1");
});
});