934 lines
30 KiB
TypeScript
934 lines
30 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
import { renderHook, waitFor, act } from "@testing-library/react";
|
|
import { useTerminalSessions } from "../useTerminalSessions";
|
|
import { scopedKey } from "../../utils/projectStorage";
|
|
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,
|
|
});
|
|
|
|
const TEST_PROJECT_ID = "proj-123";
|
|
const TERMINAL_TABS_KEY = scopedKey("kb-terminal-tabs", TEST_PROJECT_ID);
|
|
|
|
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(TEST_PROJECT_ID));
|
|
|
|
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(TEST_PROJECT_ID));
|
|
|
|
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(TEST_PROJECT_ID));
|
|
|
|
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(TEST_PROJECT_ID));
|
|
|
|
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(TEST_PROJECT_ID));
|
|
|
|
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(TEST_PROJECT_ID));
|
|
|
|
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(TEST_PROJECT_ID));
|
|
|
|
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, TEST_PROJECT_ID);
|
|
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(TEST_PROJECT_ID));
|
|
|
|
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(TEST_PROJECT_ID));
|
|
|
|
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(TEST_PROJECT_ID));
|
|
|
|
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(TEST_PROJECT_ID));
|
|
|
|
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(TEST_PROJECT_ID));
|
|
|
|
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", TEST_PROJECT_ID);
|
|
|
|
// Tab should have new session
|
|
expect(result.current.activeTab?.sessionId).toBe("session-new");
|
|
});
|
|
});
|
|
|
|
describe("replacing active tab session (invalid session recovery)", () => {
|
|
it("swaps sessionId on active tab without killing old session", async () => {
|
|
localStorageMock.getItem.mockReturnValue(null);
|
|
mockListTerminalSessions.mockResolvedValue([]);
|
|
|
|
mockCreateTerminalSession
|
|
.mockResolvedValueOnce({ sessionId: "session-1", shell: "/bin/bash", cwd: "/project" })
|
|
.mockResolvedValueOnce({ sessionId: "session-replacement", shell: "/bin/bash", cwd: "/project" });
|
|
|
|
const { result } = renderHook(() => useTerminalSessions(TEST_PROJECT_ID));
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.tabs.length).toBe(1);
|
|
expect(result.current.activeTab?.sessionId).toBe("session-1");
|
|
});
|
|
|
|
const tabId = result.current.activeTab!.id;
|
|
|
|
await act(async () => {
|
|
await result.current.replaceActiveTabSession();
|
|
});
|
|
|
|
// Should NOT kill the old session (it's already gone from server)
|
|
expect(mockKillPtyTerminalSession).not.toHaveBeenCalled();
|
|
|
|
// Tab should still exist with same ID but new sessionId
|
|
expect(result.current.tabs.length).toBe(1);
|
|
expect(result.current.activeTab?.id).toBe(tabId);
|
|
expect(result.current.activeTab?.sessionId).toBe("session-replacement");
|
|
});
|
|
|
|
it("sets bootstrapError when replacement session creation fails", async () => {
|
|
localStorageMock.getItem.mockReturnValue(null);
|
|
mockListTerminalSessions.mockResolvedValue([]);
|
|
|
|
mockCreateTerminalSession
|
|
.mockResolvedValueOnce({ sessionId: "session-1", shell: "/bin/bash", cwd: "/project" })
|
|
.mockRejectedValueOnce(new Error("Server unreachable"));
|
|
|
|
const { result } = renderHook(() => useTerminalSessions(TEST_PROJECT_ID));
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.tabs.length).toBe(1);
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.replaceActiveTabSession();
|
|
});
|
|
|
|
// Error should be set so UI can show retry
|
|
await waitFor(() => {
|
|
expect(result.current.bootstrapError).toBe("Server unreachable");
|
|
});
|
|
|
|
// Tab should still exist with the old sessionId
|
|
expect(result.current.activeTab?.sessionId).toBe("session-1");
|
|
});
|
|
|
|
it("does nothing when no active tab exists", async () => {
|
|
// Set up a scenario where tabs are empty and isReady is true but no auto-create happened yet
|
|
localStorageMock.getItem.mockReturnValue(null);
|
|
mockListTerminalSessions.mockResolvedValue([]);
|
|
// Make auto-create hang so no tab is created
|
|
mockCreateTerminalSession.mockReturnValue(new Promise(() => {}));
|
|
|
|
const { result } = renderHook(() => useTerminalSessions(TEST_PROJECT_ID));
|
|
|
|
// Wait for isReady to be true (list completes) but tabs are empty (create pending)
|
|
await waitFor(() => {
|
|
expect(result.current.isReady).toBe(true);
|
|
});
|
|
|
|
// replaceActiveTabSession should be a no-op with no active tab
|
|
await act(async () => {
|
|
await result.current.replaceActiveTabSession();
|
|
});
|
|
|
|
// No additional createTerminalSession calls beyond the pending auto-create
|
|
expect(mockCreateTerminalSession).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("clears bootstrapError on successful replacement after failure", async () => {
|
|
localStorageMock.getItem.mockReturnValue(null);
|
|
mockListTerminalSessions.mockResolvedValue([]);
|
|
|
|
mockCreateTerminalSession
|
|
.mockResolvedValueOnce({ sessionId: "session-1", shell: "/bin/bash", cwd: "/project" })
|
|
.mockRejectedValueOnce(new Error("Temporary failure"))
|
|
.mockResolvedValueOnce({ sessionId: "session-recovered", shell: "/bin/bash", cwd: "/project" });
|
|
|
|
const { result } = renderHook(() => useTerminalSessions(TEST_PROJECT_ID));
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.tabs.length).toBe(1);
|
|
});
|
|
|
|
// First replacement fails
|
|
await act(async () => {
|
|
await result.current.replaceActiveTabSession();
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.bootstrapError).toBe("Temporary failure");
|
|
});
|
|
|
|
// Second replacement succeeds
|
|
await act(async () => {
|
|
await result.current.replaceActiveTabSession();
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.bootstrapError).toBeNull();
|
|
});
|
|
expect(result.current.activeTab?.sessionId).toBe("session-recovered");
|
|
});
|
|
});
|
|
|
|
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(TEST_PROJECT_ID));
|
|
|
|
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];
|
|
expect(lastCall[0]).toBe(TERMINAL_TABS_KEY);
|
|
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(TEST_PROJECT_ID));
|
|
|
|
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(TEST_PROJECT_ID));
|
|
|
|
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(TEST_PROJECT_ID));
|
|
|
|
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);
|
|
});
|
|
});
|
|
|
|
describe("bootstrap failure and retry", () => {
|
|
it("sets bootstrapError when createTerminalSession fails during auto-create", async () => {
|
|
localStorageMock.getItem.mockReturnValue(null);
|
|
mockListTerminalSessions.mockResolvedValue([]);
|
|
mockCreateTerminalSession.mockRejectedValue(new Error("Server unreachable"));
|
|
|
|
const { result } = renderHook(() => useTerminalSessions(TEST_PROJECT_ID));
|
|
|
|
// Should become ready (validation passed)
|
|
await waitFor(() => {
|
|
expect(result.current.isReady).toBe(true);
|
|
});
|
|
|
|
// Should have a bootstrap error (auto-create failed)
|
|
await waitFor(() => {
|
|
expect(result.current.bootstrapError).toBe("Server unreachable");
|
|
});
|
|
|
|
// No tabs should be created
|
|
expect(result.current.tabs.length).toBe(0);
|
|
expect(result.current.activeTab).toBeNull();
|
|
});
|
|
|
|
it("sets bootstrapError with fallback message for non-Error throws", async () => {
|
|
localStorageMock.getItem.mockReturnValue(null);
|
|
mockListTerminalSessions.mockResolvedValue([]);
|
|
mockCreateTerminalSession.mockRejectedValue("string error");
|
|
|
|
const { result } = renderHook(() => useTerminalSessions(TEST_PROJECT_ID));
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isReady).toBe(true);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.bootstrapError).toBe("string error");
|
|
});
|
|
});
|
|
|
|
it("clears bootstrapError and creates tab on retryBootstrap after failure", async () => {
|
|
localStorageMock.getItem.mockReturnValue(null);
|
|
mockListTerminalSessions.mockResolvedValue([]);
|
|
|
|
// First attempt fails
|
|
mockCreateTerminalSession.mockRejectedValueOnce(new Error("Connection refused"));
|
|
|
|
const { result } = renderHook(() => useTerminalSessions(TEST_PROJECT_ID));
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.bootstrapError).toBe("Connection refused");
|
|
});
|
|
|
|
expect(result.current.tabs.length).toBe(0);
|
|
|
|
// Retry succeeds
|
|
mockCreateTerminalSession.mockResolvedValueOnce({
|
|
sessionId: "session-retry",
|
|
shell: "/bin/bash",
|
|
cwd: "/project",
|
|
});
|
|
|
|
await act(async () => {
|
|
result.current.retryBootstrap();
|
|
});
|
|
|
|
// Error should be cleared
|
|
await waitFor(() => {
|
|
expect(result.current.bootstrapError).toBeNull();
|
|
});
|
|
|
|
// Tab should be created
|
|
await waitFor(() => {
|
|
expect(result.current.tabs.length).toBe(1);
|
|
expect(result.current.activeTab?.sessionId).toBe("session-retry");
|
|
});
|
|
});
|
|
|
|
it("retryBootstrap does not create duplicate tabs", async () => {
|
|
localStorageMock.getItem.mockReturnValue(null);
|
|
mockListTerminalSessions.mockResolvedValue([]);
|
|
mockCreateTerminalSession.mockResolvedValue({
|
|
sessionId: "session-1",
|
|
shell: "/bin/bash",
|
|
cwd: "/project",
|
|
});
|
|
|
|
const { result } = renderHook(() => useTerminalSessions(TEST_PROJECT_ID));
|
|
|
|
// Wait for initial tab creation
|
|
await waitFor(() => {
|
|
expect(result.current.tabs.length).toBe(1);
|
|
});
|
|
|
|
expect(result.current.bootstrapError).toBeNull();
|
|
|
|
// Call retryBootstrap when there's already a tab (no error state)
|
|
act(() => {
|
|
result.current.retryBootstrap();
|
|
});
|
|
|
|
// Should still have exactly one tab (effect checks tabs.length === 0)
|
|
await waitFor(() => {
|
|
expect(result.current.tabs.length).toBe(1);
|
|
});
|
|
});
|
|
|
|
it("bootstrapError remains null when createTerminalSession succeeds", async () => {
|
|
localStorageMock.getItem.mockReturnValue(null);
|
|
mockListTerminalSessions.mockResolvedValue([]);
|
|
mockCreateTerminalSession.mockResolvedValue({
|
|
sessionId: "session-1",
|
|
shell: "/bin/bash",
|
|
cwd: "/project",
|
|
});
|
|
|
|
const { result } = renderHook(() => useTerminalSessions(TEST_PROJECT_ID));
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.tabs.length).toBe(1);
|
|
});
|
|
|
|
expect(result.current.bootstrapError).toBeNull();
|
|
});
|
|
|
|
it("sets bootstrapError when session creation fails after restoring stale tabs", async () => {
|
|
// Stored tabs that are stale (don't exist on server)
|
|
const storedTabs = [
|
|
{
|
|
id: "tab-1",
|
|
sessionId: "session-stale",
|
|
title: "bash",
|
|
isActive: true,
|
|
createdAt: Date.now(),
|
|
},
|
|
];
|
|
localStorageMock.getItem.mockReturnValue(JSON.stringify(storedTabs));
|
|
|
|
// Server has no sessions
|
|
mockListTerminalSessions.mockResolvedValue([]);
|
|
// Auto-create fails
|
|
mockCreateTerminalSession.mockRejectedValue(new Error("Internal server error"));
|
|
|
|
const { result } = renderHook(() => useTerminalSessions(TEST_PROJECT_ID));
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isReady).toBe(true);
|
|
});
|
|
|
|
// Stale tabs should be removed, auto-create should fail
|
|
await waitFor(() => {
|
|
expect(result.current.bootstrapError).toBe("Internal server error");
|
|
});
|
|
|
|
expect(result.current.tabs.length).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("bounded bootstrap timeouts", () => {
|
|
it("sets bootstrapError when createTerminalSession hangs beyond timeout", async () => {
|
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
|
localStorageMock.getItem.mockReturnValue(null);
|
|
mockListTerminalSessions.mockResolvedValue([]);
|
|
|
|
// createTerminalSession never resolves
|
|
mockCreateTerminalSession.mockReturnValue(new Promise(() => {}));
|
|
|
|
const { result } = renderHook(() => useTerminalSessions(TEST_PROJECT_ID));
|
|
|
|
// isReady should become true (list resolved)
|
|
await waitFor(() => {
|
|
expect(result.current.isReady).toBe(true);
|
|
});
|
|
|
|
// Advance past the create timeout (15s)
|
|
await act(async () => {
|
|
vi.advanceTimersByTime(16000);
|
|
});
|
|
|
|
// Should have a bootstrap error from the timed-out create call
|
|
await waitFor(() => {
|
|
expect(result.current.bootstrapError).toBeTruthy();
|
|
expect(result.current.bootstrapError).toContain("timed out");
|
|
});
|
|
|
|
expect(result.current.tabs.length).toBe(0);
|
|
expect(result.current.activeTab).toBeNull();
|
|
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("retryBootstrap recovers from a timed-out create call", async () => {
|
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
|
localStorageMock.getItem.mockReturnValue(null);
|
|
mockListTerminalSessions.mockResolvedValue([]);
|
|
|
|
// First create call hangs forever
|
|
mockCreateTerminalSession.mockReturnValue(new Promise(() => {}));
|
|
|
|
const { result } = renderHook(() => useTerminalSessions(TEST_PROJECT_ID));
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isReady).toBe(true);
|
|
});
|
|
|
|
// Advance past create timeout to trigger error
|
|
await act(async () => {
|
|
vi.advanceTimersByTime(16000);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.bootstrapError).toBeTruthy();
|
|
});
|
|
|
|
// Now make retry succeed
|
|
mockCreateTerminalSession.mockResolvedValue({
|
|
sessionId: "session-after-timeout",
|
|
shell: "/bin/bash",
|
|
cwd: "/project",
|
|
});
|
|
|
|
await act(async () => {
|
|
result.current.retryBootstrap();
|
|
});
|
|
|
|
// Advance to let the auto-create effect fire and complete
|
|
await act(async () => {
|
|
vi.advanceTimersByTime(1000);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.bootstrapError).toBeNull();
|
|
expect(result.current.tabs.length).toBe(1);
|
|
expect(result.current.activeTab?.sessionId).toBe("session-after-timeout");
|
|
});
|
|
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("ignores late resolution from a prior generation after retry succeeds", async () => {
|
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
|
localStorageMock.getItem.mockReturnValue(null);
|
|
mockListTerminalSessions.mockResolvedValue([]);
|
|
|
|
// First create call: will resolve very late
|
|
let resolveFirst: (val: any) => void;
|
|
const firstPromise = new Promise<any>((resolve) => {
|
|
resolveFirst = resolve;
|
|
});
|
|
mockCreateTerminalSession.mockReturnValueOnce(firstPromise);
|
|
|
|
const { result } = renderHook(() => useTerminalSessions(TEST_PROJECT_ID));
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.isReady).toBe(true);
|
|
});
|
|
|
|
// Advance past create timeout to trigger error
|
|
await act(async () => {
|
|
vi.advanceTimersByTime(16000);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.bootstrapError).toBeTruthy();
|
|
});
|
|
|
|
// Now set up retry to succeed immediately
|
|
mockCreateTerminalSession.mockResolvedValueOnce({
|
|
sessionId: "session-gen2",
|
|
shell: "/bin/bash",
|
|
cwd: "/project",
|
|
});
|
|
|
|
await act(async () => {
|
|
result.current.retryBootstrap();
|
|
});
|
|
|
|
await act(async () => {
|
|
vi.advanceTimersByTime(1000);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.tabs.length).toBe(1);
|
|
expect(result.current.activeTab?.sessionId).toBe("session-gen2");
|
|
});
|
|
|
|
// Now the first (stale) call resolves late — this must NOT overwrite the tab
|
|
await act(async () => {
|
|
resolveFirst!({
|
|
sessionId: "session-gen1-stale",
|
|
shell: "/bin/bash",
|
|
cwd: "/project",
|
|
});
|
|
});
|
|
|
|
// The tab must still be from gen2, not gen1
|
|
expect(result.current.tabs[0].sessionId).toBe("session-gen2");
|
|
expect(result.current.tabs.length).toBe(1);
|
|
|
|
vi.useRealTimers();
|
|
});
|
|
});
|
|
});
|