- Add useTerminalSessions hook for managing multiple terminal sessions - Update TerminalModal to support multiple tabs with persistent sessions - Fix memory leak in useTerminalSessions (proper cleanup on unmount) - Fix type leak (avoid exposing internal session state) - Add comprehensive test coverage for useTerminalSessions hook - Update TerminalModal tests for multi-tab behavior - Remove obsolete test files and highlightDiff utility
510 lines
15 KiB
TypeScript
510 lines
15 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
import { renderHook, waitFor, act } from "@testing-library/react";
|
|
import { useTerminalSessions } from "../useTerminalSessions";
|
|
import * as apiModule from "../../api";
|
|
|
|
// Mock API
|
|
vi.mock("../../api", () => ({
|
|
createTerminalSession: vi.fn(),
|
|
killPtyTerminalSession: vi.fn(),
|
|
listTerminalSessions: vi.fn(),
|
|
}));
|
|
|
|
const mockCreateTerminalSession = vi.mocked(apiModule.createTerminalSession);
|
|
const mockKillPtyTerminalSession = vi.mocked(apiModule.killPtyTerminalSession);
|
|
const mockListTerminalSessions = vi.mocked(apiModule.listTerminalSessions);
|
|
|
|
// Mock localStorage
|
|
const localStorageMock = {
|
|
getItem: vi.fn(),
|
|
setItem: vi.fn(),
|
|
removeItem: vi.fn(),
|
|
clear: vi.fn(),
|
|
};
|
|
|
|
Object.defineProperty(window, "localStorage", {
|
|
value: localStorageMock,
|
|
});
|
|
|
|
describe("useTerminalSessions", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
localStorageMock.getItem.mockReturnValue(null);
|
|
localStorageMock.setItem.mockImplementation(() => {});
|
|
|
|
// Default mock implementations
|
|
mockCreateTerminalSession.mockResolvedValue({
|
|
sessionId: "session-1",
|
|
shell: "/bin/bash",
|
|
cwd: "/project",
|
|
});
|
|
mockKillPtyTerminalSession.mockResolvedValue({ killed: true });
|
|
mockListTerminalSessions.mockResolvedValue([]);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe("initial tab creation", () => {
|
|
it("auto-creates first tab when no tabs exist in localStorage", async () => {
|
|
localStorageMock.getItem.mockReturnValue(null);
|
|
mockListTerminalSessions.mockResolvedValue([]);
|
|
|
|
const { result } = renderHook(() => useTerminalSessions());
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isReady).toBe(true);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.tabs.length).toBe(1);
|
|
expect(result.current.activeTab).not.toBeNull();
|
|
});
|
|
|
|
expect(mockCreateTerminalSession).toHaveBeenCalled();
|
|
});
|
|
|
|
it("restores tabs from localStorage on mount", async () => {
|
|
const storedTabs = [
|
|
{
|
|
id: "tab-1",
|
|
sessionId: "session-1",
|
|
title: "bash",
|
|
isActive: true,
|
|
createdAt: Date.now(),
|
|
},
|
|
];
|
|
localStorageMock.getItem.mockReturnValue(JSON.stringify(storedTabs));
|
|
|
|
// Session is still valid on server
|
|
mockListTerminalSessions.mockResolvedValue([{ id: "session-1", shell: "/bin/bash", cwd: "/project" }]);
|
|
|
|
const { result } = renderHook(() => useTerminalSessions());
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isReady).toBe(true);
|
|
});
|
|
|
|
expect(result.current.tabs.length).toBe(1);
|
|
expect(result.current.tabs[0].sessionId).toBe("session-1");
|
|
expect(result.current.activeTab?.id).toBe("tab-1");
|
|
|
|
// Should not create a new session if restoring existing ones
|
|
expect(mockCreateTerminalSession).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("filters out stale sessions that no longer exist on server", async () => {
|
|
const storedTabs = [
|
|
{
|
|
id: "tab-1",
|
|
sessionId: "session-stale",
|
|
title: "bash",
|
|
isActive: true,
|
|
createdAt: Date.now(),
|
|
},
|
|
{
|
|
id: "tab-2",
|
|
sessionId: "session-valid",
|
|
title: "zsh",
|
|
isActive: false,
|
|
createdAt: Date.now(),
|
|
},
|
|
];
|
|
localStorageMock.getItem.mockReturnValue(JSON.stringify(storedTabs));
|
|
|
|
// Only session-valid still exists on server
|
|
mockListTerminalSessions.mockResolvedValue([
|
|
{ id: "session-valid", shell: "/bin/zsh", cwd: "/project" }
|
|
]);
|
|
|
|
const { result } = renderHook(() => useTerminalSessions());
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isReady).toBe(true);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.tabs.length).toBe(1);
|
|
});
|
|
|
|
expect(result.current.tabs[0].sessionId).toBe("session-valid");
|
|
expect(result.current.activeTab?.id).toBe("tab-2");
|
|
});
|
|
|
|
it("creates new tab if all stored sessions are stale", async () => {
|
|
const storedTabs = [
|
|
{
|
|
id: "tab-1",
|
|
sessionId: "session-stale",
|
|
title: "bash",
|
|
isActive: true,
|
|
createdAt: Date.now(),
|
|
},
|
|
];
|
|
localStorageMock.getItem.mockReturnValue(JSON.stringify(storedTabs));
|
|
|
|
// No sessions exist on server
|
|
mockListTerminalSessions.mockResolvedValue([]);
|
|
|
|
const { result } = renderHook(() => useTerminalSessions());
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isReady).toBe(true);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.tabs.length).toBe(1);
|
|
});
|
|
|
|
// Should have created a new session
|
|
expect(mockCreateTerminalSession).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("creating additional tabs", () => {
|
|
it("creates new tab with fresh session when createTab is called", async () => {
|
|
localStorageMock.getItem.mockReturnValue(null);
|
|
mockListTerminalSessions.mockResolvedValue([]);
|
|
|
|
mockCreateTerminalSession
|
|
.mockResolvedValueOnce({
|
|
sessionId: "session-1",
|
|
shell: "/bin/bash",
|
|
cwd: "/project",
|
|
})
|
|
.mockResolvedValueOnce({
|
|
sessionId: "session-2",
|
|
shell: "/bin/bash",
|
|
cwd: "/project",
|
|
});
|
|
|
|
const { result } = renderHook(() => useTerminalSessions());
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isReady).toBe(true);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.tabs.length).toBe(1);
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.createTab();
|
|
});
|
|
|
|
expect(result.current.tabs.length).toBe(2);
|
|
expect(result.current.activeTab?.sessionId).toBe("session-2");
|
|
|
|
// First tab should be deactivated
|
|
expect(result.current.tabs[0].isActive).toBe(false);
|
|
expect(result.current.tabs[1].isActive).toBe(true);
|
|
});
|
|
|
|
it("names tabs with incrementing numbers", async () => {
|
|
localStorageMock.getItem.mockReturnValue(null);
|
|
mockListTerminalSessions.mockResolvedValue([]);
|
|
|
|
mockCreateTerminalSession
|
|
.mockResolvedValueOnce({ sessionId: "session-1", shell: "/bin/bash", cwd: "/project" })
|
|
.mockResolvedValueOnce({ sessionId: "session-2", shell: "/bin/bash", cwd: "/project" })
|
|
.mockResolvedValueOnce({ sessionId: "session-3", shell: "/bin/bash", cwd: "/project" });
|
|
|
|
const { result } = renderHook(() => useTerminalSessions());
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.tabs.length).toBe(1);
|
|
expect(result.current.tabs[0].title).toBe("Terminal 1");
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.createTab();
|
|
});
|
|
|
|
expect(result.current.tabs[1].title).toBe("Terminal 2");
|
|
|
|
await act(async () => {
|
|
await result.current.createTab();
|
|
});
|
|
|
|
expect(result.current.tabs[2].title).toBe("Terminal 3");
|
|
});
|
|
});
|
|
|
|
describe("closing tabs", () => {
|
|
it("closes tab and kills server session", async () => {
|
|
localStorageMock.getItem.mockReturnValue(null);
|
|
mockListTerminalSessions.mockResolvedValue([]);
|
|
|
|
mockCreateTerminalSession.mockResolvedValue({ sessionId: "session-1", shell: "/bin/bash", cwd: "/project" });
|
|
|
|
const { result } = renderHook(() => useTerminalSessions());
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.tabs.length).toBe(1);
|
|
});
|
|
|
|
const tabId = result.current.tabs[0].id;
|
|
const sessionId = result.current.tabs[0].sessionId;
|
|
|
|
act(() => {
|
|
result.current.closeTab(tabId);
|
|
});
|
|
|
|
expect(mockKillPtyTerminalSession).toHaveBeenCalledWith(sessionId);
|
|
expect(result.current.tabs.length).toBe(0);
|
|
});
|
|
|
|
it("closing active tab switches to next tab", async () => {
|
|
const storedTabs = [
|
|
{
|
|
id: "tab-1",
|
|
sessionId: "session-1",
|
|
title: "Terminal 1",
|
|
isActive: true,
|
|
createdAt: Date.now(),
|
|
},
|
|
{
|
|
id: "tab-2",
|
|
sessionId: "session-2",
|
|
title: "Terminal 2",
|
|
isActive: false,
|
|
createdAt: Date.now(),
|
|
},
|
|
];
|
|
localStorageMock.getItem.mockReturnValue(JSON.stringify(storedTabs));
|
|
mockListTerminalSessions.mockResolvedValue([
|
|
{ id: "session-1", shell: "/bin/bash", cwd: "/project" },
|
|
{ id: "session-2", shell: "/bin/bash", cwd: "/project" },
|
|
]);
|
|
|
|
const { result } = renderHook(() => useTerminalSessions());
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isReady).toBe(true);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.tabs.length).toBe(2);
|
|
});
|
|
|
|
act(() => {
|
|
result.current.closeTab("tab-1");
|
|
});
|
|
|
|
expect(result.current.tabs.length).toBe(1);
|
|
expect(result.current.activeTab?.id).toBe("tab-2");
|
|
});
|
|
|
|
it("closing last tab triggers auto-creation of new tab", async () => {
|
|
const storedTabs = [
|
|
{
|
|
id: "tab-1",
|
|
sessionId: "session-1",
|
|
title: "Terminal 1",
|
|
isActive: true,
|
|
createdAt: Date.now(),
|
|
},
|
|
];
|
|
localStorageMock.getItem.mockReturnValue(JSON.stringify(storedTabs));
|
|
mockListTerminalSessions.mockResolvedValue([{ id: "session-1", shell: "/bin/bash", cwd: "/project" }]);
|
|
|
|
const { result } = renderHook(() => useTerminalSessions());
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isReady).toBe(true);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.tabs.length).toBe(1);
|
|
});
|
|
|
|
// Tab was restored from localStorage (no createTerminalSession call yet)
|
|
expect(mockCreateTerminalSession).not.toHaveBeenCalled();
|
|
|
|
act(() => {
|
|
result.current.closeTab("tab-1");
|
|
});
|
|
|
|
// Tab count goes to 0 momentarily
|
|
expect(result.current.tabs.length).toBe(0);
|
|
|
|
// New tab should be auto-created
|
|
await waitFor(() => {
|
|
expect(result.current.tabs.length).toBe(1);
|
|
});
|
|
|
|
// createTerminalSession was called once during auto-create
|
|
expect(mockCreateTerminalSession).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
describe("switching active tab", () => {
|
|
it("updates isActive when switching tabs", async () => {
|
|
const storedTabs = [
|
|
{
|
|
id: "tab-1",
|
|
sessionId: "session-1",
|
|
title: "Terminal 1",
|
|
isActive: true,
|
|
createdAt: Date.now(),
|
|
},
|
|
{
|
|
id: "tab-2",
|
|
sessionId: "session-2",
|
|
title: "Terminal 2",
|
|
isActive: false,
|
|
createdAt: Date.now(),
|
|
},
|
|
];
|
|
localStorageMock.getItem.mockReturnValue(JSON.stringify(storedTabs));
|
|
mockListTerminalSessions.mockResolvedValue([
|
|
{ id: "session-1", shell: "/bin/bash", cwd: "/project" },
|
|
{ id: "session-2", shell: "/bin/bash", cwd: "/project" },
|
|
]);
|
|
|
|
const { result } = renderHook(() => useTerminalSessions());
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isReady).toBe(true);
|
|
});
|
|
|
|
expect(result.current.activeTab?.id).toBe("tab-1");
|
|
|
|
act(() => {
|
|
result.current.setActiveTab("tab-2");
|
|
});
|
|
|
|
expect(result.current.activeTab?.id).toBe("tab-2");
|
|
expect(result.current.tabs.find((t) => t.id === "tab-1")?.isActive).toBe(false);
|
|
expect(result.current.tabs.find((t) => t.id === "tab-2")?.isActive).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("updating tab titles", () => {
|
|
it("updates tab title when updateTabTitle is called", async () => {
|
|
localStorageMock.getItem.mockReturnValue(null);
|
|
mockListTerminalSessions.mockResolvedValue([]);
|
|
|
|
const { result } = renderHook(() => useTerminalSessions());
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.tabs.length).toBe(1);
|
|
});
|
|
|
|
act(() => {
|
|
result.current.updateTabTitle(result.current.tabs[0].id, "zsh");
|
|
});
|
|
|
|
expect(result.current.tabs[0].title).toBe("zsh");
|
|
});
|
|
});
|
|
|
|
describe("restarting active tab", () => {
|
|
it("creates new session for active tab", async () => {
|
|
localStorageMock.getItem.mockReturnValue(null);
|
|
mockListTerminalSessions.mockResolvedValue([]);
|
|
|
|
mockCreateTerminalSession
|
|
.mockResolvedValueOnce({ sessionId: "session-1", shell: "/bin/bash", cwd: "/project" })
|
|
.mockResolvedValueOnce({ sessionId: "session-new", shell: "/bin/bash", cwd: "/project" });
|
|
|
|
const { result } = renderHook(() => useTerminalSessions());
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.tabs.length).toBe(1);
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.restartActiveTab();
|
|
});
|
|
|
|
// Old session should be killed
|
|
expect(mockKillPtyTerminalSession).toHaveBeenCalledWith("session-1");
|
|
|
|
// Tab should have new session
|
|
expect(result.current.activeTab?.sessionId).toBe("session-new");
|
|
});
|
|
});
|
|
|
|
describe("localStorage persistence", () => {
|
|
it("persists tabs to localStorage", async () => {
|
|
localStorageMock.getItem.mockReturnValue(null);
|
|
mockListTerminalSessions.mockResolvedValue([]);
|
|
mockCreateTerminalSession.mockResolvedValue({ sessionId: "session-1", shell: "/bin/bash", cwd: "/project" });
|
|
|
|
renderHook(() => useTerminalSessions());
|
|
|
|
await waitFor(() => {
|
|
expect(localStorageMock.setItem).toHaveBeenCalled();
|
|
});
|
|
|
|
// Verify the stored data contains the tabs
|
|
const setItemCalls = localStorageMock.setItem.mock.calls;
|
|
expect(setItemCalls.length).toBeGreaterThan(0);
|
|
|
|
const lastCall = setItemCalls[setItemCalls.length - 1];
|
|
const storedTabs = JSON.parse(lastCall[1]);
|
|
expect(storedTabs).toBeInstanceOf(Array);
|
|
});
|
|
});
|
|
|
|
describe("error handling", () => {
|
|
it("handles localStorage errors gracefully", async () => {
|
|
localStorageMock.getItem.mockImplementation(() => {
|
|
throw new Error("localStorage error");
|
|
});
|
|
mockListTerminalSessions.mockResolvedValue([]);
|
|
|
|
// Should not throw
|
|
const { result } = renderHook(() => useTerminalSessions());
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isReady).toBe(true);
|
|
});
|
|
|
|
// Should still create a tab
|
|
await waitFor(() => {
|
|
expect(result.current.tabs.length).toBe(1);
|
|
});
|
|
});
|
|
|
|
it("handles server listing failure gracefully", async () => {
|
|
localStorageMock.getItem.mockReturnValue(null);
|
|
mockListTerminalSessions.mockRejectedValue(new Error("Server error"));
|
|
|
|
const { result } = renderHook(() => useTerminalSessions());
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isReady).toBe(true);
|
|
});
|
|
|
|
// Should still create a tab
|
|
await waitFor(() => {
|
|
expect(result.current.tabs.length).toBe(1);
|
|
});
|
|
});
|
|
|
|
it("non-blocking session kill - tab removed even if kill fails", async () => {
|
|
localStorageMock.getItem.mockReturnValue(null);
|
|
mockListTerminalSessions.mockResolvedValue([]);
|
|
mockKillPtyTerminalSession.mockRejectedValue(new Error("Kill failed"));
|
|
|
|
const { result } = renderHook(() => useTerminalSessions());
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.tabs.length).toBe(1);
|
|
});
|
|
|
|
const tabId = result.current.tabs[0].id;
|
|
|
|
act(() => {
|
|
result.current.closeTab(tabId);
|
|
});
|
|
|
|
// Tab should still be removed
|
|
expect(result.current.tabs.length).toBe(0);
|
|
});
|
|
});
|
|
});
|