300 lines
9.5 KiB
TypeScript
300 lines
9.5 KiB
TypeScript
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||
|
|
import { render, screen, waitFor, act } from "@testing-library/react";
|
||
|
|
import userEvent from "@testing-library/user-event";
|
||
|
|
|
||
|
|
// ── xterm mocks ───────────────────────────────────────────────────────────────
|
||
|
|
// onData callbacks registered by the component — keyed by call order
|
||
|
|
const onDataHandlers: Array<(data: string) => void> = [];
|
||
|
|
|
||
|
|
const mockTerminalInstance = {
|
||
|
|
open: vi.fn(),
|
||
|
|
write: vi.fn(),
|
||
|
|
writeln: vi.fn(),
|
||
|
|
dispose: vi.fn(),
|
||
|
|
onData: vi.fn((cb: (data: string) => void) => {
|
||
|
|
onDataHandlers.push(cb);
|
||
|
|
}),
|
||
|
|
loadAddon: vi.fn(),
|
||
|
|
options: {} as Record<string, unknown>,
|
||
|
|
};
|
||
|
|
|
||
|
|
// Must use function (not arrow) so `new` works
|
||
|
|
vi.mock("xterm", () => ({
|
||
|
|
Terminal: vi.fn(function () {
|
||
|
|
return mockTerminalInstance;
|
||
|
|
}),
|
||
|
|
}));
|
||
|
|
|
||
|
|
const mockFitAddon = { fit: vi.fn(), dispose: vi.fn() };
|
||
|
|
vi.mock("xterm-addon-fit", () => ({
|
||
|
|
FitAddon: vi.fn(function () {
|
||
|
|
return mockFitAddon;
|
||
|
|
}),
|
||
|
|
}));
|
||
|
|
|
||
|
|
const mockWebLinksAddon = { dispose: vi.fn() };
|
||
|
|
vi.mock("xterm-addon-web-links", () => ({
|
||
|
|
WebLinksAddon: vi.fn(function () {
|
||
|
|
return mockWebLinksAddon;
|
||
|
|
}),
|
||
|
|
}));
|
||
|
|
|
||
|
|
// ── Tauri command mock ────────────────────────────────────────────────────────
|
||
|
|
vi.mock("@/lib/tauriCommands", () => ({
|
||
|
|
execPodCmd: vi.fn(),
|
||
|
|
}));
|
||
|
|
|
||
|
|
import * as tauriCommands from "@/lib/tauriCommands";
|
||
|
|
import { Terminal } from "@/components/Kubernetes/Terminal";
|
||
|
|
|
||
|
|
type MockedFn<T extends (...args: unknown[]) => unknown = (...args: unknown[]) => unknown> =
|
||
|
|
T & ReturnType<typeof vi.fn>;
|
||
|
|
|
||
|
|
const execPodCmdMock = tauriCommands.execPodCmd as MockedFn;
|
||
|
|
|
||
|
|
const defaultProps = {
|
||
|
|
clusterId: "cluster-1",
|
||
|
|
namespace: "default",
|
||
|
|
};
|
||
|
|
|
||
|
|
const withPodProps = {
|
||
|
|
...defaultProps,
|
||
|
|
podName: "nginx-abc",
|
||
|
|
containerName: "nginx",
|
||
|
|
};
|
||
|
|
|
||
|
|
// ── helper: get the onData handler registered for a session ──────────────────
|
||
|
|
function getOnDataCallback(): (data: string) => void {
|
||
|
|
const cb = onDataHandlers[onDataHandlers.length - 1];
|
||
|
|
if (!cb) throw new Error("No onData handler registered — terminal may not have mounted");
|
||
|
|
return cb;
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── tests ─────────────────────────────────────────────────────────────────────
|
||
|
|
describe("Terminal component", () => {
|
||
|
|
beforeEach(() => {
|
||
|
|
vi.clearAllMocks();
|
||
|
|
onDataHandlers.length = 0;
|
||
|
|
// Re-wire onData to push into our handler array after clearAllMocks
|
||
|
|
mockTerminalInstance.onData.mockImplementation((cb: (data: string) => void) => {
|
||
|
|
onDataHandlers.push(cb);
|
||
|
|
});
|
||
|
|
execPodCmdMock.mockResolvedValue({ stdout: "hello\nworld", stderr: "", exit_code: 0 });
|
||
|
|
});
|
||
|
|
|
||
|
|
afterEach(() => {
|
||
|
|
vi.restoreAllMocks();
|
||
|
|
});
|
||
|
|
|
||
|
|
describe("empty state", () => {
|
||
|
|
it("renders without crashing", () => {
|
||
|
|
render(<Terminal {...defaultProps} />);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("shows 'Select a pod to connect' when no pod/container is provided", () => {
|
||
|
|
render(<Terminal {...defaultProps} />);
|
||
|
|
expect(screen.getByText(/select a pod to connect/i)).toBeInTheDocument();
|
||
|
|
});
|
||
|
|
|
||
|
|
it("does not show a tab bar when there are no sessions", () => {
|
||
|
|
render(<Terminal {...defaultProps} />);
|
||
|
|
expect(screen.queryByRole("tab")).not.toBeInTheDocument();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe("session management", () => {
|
||
|
|
it("shows tab bar when a session is auto-created from props", async () => {
|
||
|
|
render(<Terminal {...withPodProps} />);
|
||
|
|
await waitFor(() => {
|
||
|
|
expect(screen.getByRole("tab")).toBeInTheDocument();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
it("tab label contains pod/container name", async () => {
|
||
|
|
render(<Terminal {...withPodProps} />);
|
||
|
|
await waitFor(() => screen.getByRole("tab"));
|
||
|
|
expect(screen.getByRole("tab").textContent).toContain("nginx-abc");
|
||
|
|
});
|
||
|
|
|
||
|
|
it("clicking '+' button adds a new session tab", async () => {
|
||
|
|
render(<Terminal {...withPodProps} />);
|
||
|
|
await waitFor(() => screen.getByRole("tab"));
|
||
|
|
|
||
|
|
expect(screen.getAllByRole("tab")).toHaveLength(1);
|
||
|
|
|
||
|
|
const addButton = screen.getByRole("button", { name: /add session/i });
|
||
|
|
await userEvent.click(addButton);
|
||
|
|
|
||
|
|
await waitFor(() => {
|
||
|
|
expect(screen.getAllByRole("tab")).toHaveLength(2);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
it("clicking the X on a tab removes that session", async () => {
|
||
|
|
render(<Terminal {...withPodProps} />);
|
||
|
|
await waitFor(() => screen.getByRole("tab"));
|
||
|
|
|
||
|
|
const closeBtn = screen.getByRole("button", { name: /close/i });
|
||
|
|
await userEvent.click(closeBtn);
|
||
|
|
|
||
|
|
await waitFor(() => {
|
||
|
|
expect(screen.queryByRole("tab")).not.toBeInTheDocument();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
it("removing the last session goes back to the empty state", async () => {
|
||
|
|
render(<Terminal {...withPodProps} />);
|
||
|
|
await waitFor(() => screen.getByRole("tab"));
|
||
|
|
|
||
|
|
const closeBtn = screen.getByRole("button", { name: /close/i });
|
||
|
|
await userEvent.click(closeBtn);
|
||
|
|
|
||
|
|
await waitFor(() => {
|
||
|
|
expect(screen.getByText(/select a pod to connect/i)).toBeInTheDocument();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe("IPC integration", () => {
|
||
|
|
it("calls execPodCmd with correct arguments when a command is entered", async () => {
|
||
|
|
render(<Terminal {...withPodProps} />);
|
||
|
|
await waitFor(() => screen.getByRole("tab"));
|
||
|
|
|
||
|
|
// onData must have been registered by now
|
||
|
|
expect(mockTerminalInstance.onData).toHaveBeenCalled();
|
||
|
|
const onDataCallback = getOnDataCallback();
|
||
|
|
|
||
|
|
await act(async () => {
|
||
|
|
onDataCallback("l");
|
||
|
|
onDataCallback("s");
|
||
|
|
onDataCallback("\r");
|
||
|
|
});
|
||
|
|
|
||
|
|
await waitFor(() => {
|
||
|
|
expect(execPodCmdMock).toHaveBeenCalledWith(
|
||
|
|
"cluster-1",
|
||
|
|
"default",
|
||
|
|
"nginx-abc",
|
||
|
|
"nginx",
|
||
|
|
"ls",
|
||
|
|
expect.any(String)
|
||
|
|
);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
it("writes command output to the terminal after execution", async () => {
|
||
|
|
execPodCmdMock.mockResolvedValue({ stdout: "file1.txt\nfile2.txt", stderr: "", exit_code: 0 });
|
||
|
|
|
||
|
|
render(<Terminal {...withPodProps} />);
|
||
|
|
await waitFor(() => screen.getByRole("tab"));
|
||
|
|
|
||
|
|
const onDataCallback = getOnDataCallback();
|
||
|
|
|
||
|
|
await act(async () => {
|
||
|
|
onDataCallback("l");
|
||
|
|
onDataCallback("s");
|
||
|
|
onDataCallback("\r");
|
||
|
|
});
|
||
|
|
|
||
|
|
await waitFor(() => {
|
||
|
|
const writeCalls = mockTerminalInstance.write.mock.calls.map((c: unknown[]) => c[0] as string);
|
||
|
|
expect(writeCalls.some((s) => s.includes("file1.txt"))).toBe(true);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
it("handles IPC errors gracefully by writing an error message to the terminal", async () => {
|
||
|
|
execPodCmdMock.mockRejectedValue(new Error("connection refused"));
|
||
|
|
|
||
|
|
render(<Terminal {...withPodProps} />);
|
||
|
|
await waitFor(() => screen.getByRole("tab"));
|
||
|
|
|
||
|
|
const onDataCallback = getOnDataCallback();
|
||
|
|
|
||
|
|
await act(async () => {
|
||
|
|
onDataCallback("e");
|
||
|
|
onDataCallback("c");
|
||
|
|
onDataCallback("h");
|
||
|
|
onDataCallback("o");
|
||
|
|
onDataCallback("\r");
|
||
|
|
});
|
||
|
|
|
||
|
|
await waitFor(() => {
|
||
|
|
const writeCalls = mockTerminalInstance.write.mock.calls.map((c: unknown[]) => c[0] as string);
|
||
|
|
expect(
|
||
|
|
writeCalls.some((s) => s.toLowerCase().includes("error") || s.includes("connection refused"))
|
||
|
|
).toBe(true);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
it("writes stderr output to the terminal when exit_code is non-zero", async () => {
|
||
|
|
execPodCmdMock.mockResolvedValue({ stdout: "", stderr: "command not found", exit_code: 127 });
|
||
|
|
|
||
|
|
render(<Terminal {...withPodProps} />);
|
||
|
|
await waitFor(() => screen.getByRole("tab"));
|
||
|
|
|
||
|
|
const onDataCallback = getOnDataCallback();
|
||
|
|
|
||
|
|
await act(async () => {
|
||
|
|
onDataCallback("b");
|
||
|
|
onDataCallback("a");
|
||
|
|
onDataCallback("d");
|
||
|
|
onDataCallback("\r");
|
||
|
|
});
|
||
|
|
|
||
|
|
await waitFor(() => {
|
||
|
|
const writeCalls = mockTerminalInstance.write.mock.calls.map((c: unknown[]) => c[0] as string);
|
||
|
|
expect(writeCalls.some((s) => s.includes("command not found"))).toBe(true);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe("shell selector", () => {
|
||
|
|
it("renders a shell selector dropdown", async () => {
|
||
|
|
render(<Terminal {...withPodProps} />);
|
||
|
|
await waitFor(() => screen.getByRole("tab"));
|
||
|
|
|
||
|
|
const shellSelector = screen.getByRole("combobox");
|
||
|
|
expect(shellSelector).toBeInTheDocument();
|
||
|
|
});
|
||
|
|
|
||
|
|
it("passes selected shell to execPodCmd", async () => {
|
||
|
|
render(<Terminal {...withPodProps} />);
|
||
|
|
await waitFor(() => screen.getByRole("tab"));
|
||
|
|
|
||
|
|
const shellSelector = screen.getByRole("combobox");
|
||
|
|
await userEvent.selectOptions(shellSelector, "sh");
|
||
|
|
|
||
|
|
const onDataCallback = getOnDataCallback();
|
||
|
|
|
||
|
|
await act(async () => {
|
||
|
|
onDataCallback("p");
|
||
|
|
onDataCallback("w");
|
||
|
|
onDataCallback("d");
|
||
|
|
onDataCallback("\r");
|
||
|
|
});
|
||
|
|
|
||
|
|
await waitFor(() => {
|
||
|
|
expect(execPodCmdMock).toHaveBeenCalledWith(
|
||
|
|
expect.any(String),
|
||
|
|
expect.any(String),
|
||
|
|
expect.any(String),
|
||
|
|
expect.any(String),
|
||
|
|
"pwd",
|
||
|
|
"sh"
|
||
|
|
);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe("cleanup", () => {
|
||
|
|
it("calls terminal.dispose() on unmount", async () => {
|
||
|
|
const { unmount } = render(<Terminal {...withPodProps} />);
|
||
|
|
await waitFor(() => screen.getByRole("tab"));
|
||
|
|
|
||
|
|
unmount();
|
||
|
|
|
||
|
|
expect(mockTerminalInstance.dispose).toHaveBeenCalled();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
});
|