Files
fusion/packages/dashboard/app/hooks/__tests__/useTerminalSessions.test.ts
gsxdsm 0359166b6c feat(FN-1276): scope dashboard persistence by project
- Add a project storage utility with scoped key helpers and key inventories for global vs project state
- Scope dashboard/task view, list preferences, quick-entry drafts, agent/tree state, terminal tabs, usage view, and modal draft persistence to projectId
- Reload persisted UI state when project context changes so per-project settings and drafts do not leak across projects
- Update and expand dashboard tests, including new projectStorage coverage and scoped persistence regression fixes
2026-04-08 10:39:38 -07:00

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);
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");
// 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();
});
});
});