Modal resize + size persistence - Extract `useModalResizePersist` hook (ResizeObserver + localStorage), apply to Files, Git Manager, GitHub Import, Workflow Steps, Automations (ScheduledTasks), and Settings modals. Each gets `resize: both`, sane min/max constraints, and a unique storage key. - Bump default heights so the modals feel less cramped (Git: 92vh, Workflow/Automation: 80vh, Settings: 80vh / 1100px). Scrollbar theme - Add a global `*::-webkit-scrollbar*` + `scrollbar-color` rule in styles.css so chat, document, system stats, file browser, usage indicator, etc. inherit the theme. Existing per-component overrides (.board, .column-body, .settings-sidebar, planning modal) still win. Document view - Collapse "Show hidden" toggle and search input onto the same row as the Project Files / Task Documents segmented control. Stack again below 768px. Mailbox / Todos - Match Todos header treatment to the Mailbox header (typography, padding, border). - Add top spacing above Mailbox Inbox/Outbox/Agents tab bar so the vertical gaps balance. Settings - Wider, resizable, persisted Settings modal. - Project Models description and Authentication panel get proper horizontal padding. - Reorder project sidebar so "General" is first. - Plugins page: clean margins, integrate refresh button, exclude bundled runtimes from the "Installed Plugins" list (they were appearing twice — once erroring, once in their own section). - New "Updates" panel with auto-check toggle + "Check now" button (frequency control noted as needing a backend schema field). Background sessions - Fix stale "AI N" / planning-icon badge: `handleDeleted` in `useBackgroundSessions` now writes a tombstone, advances the timestamp guard, and broadcasts completion so the cross-tab sync store stops resurrecting the deleted session on the next merge tick. Added regression test. - Re-fetch list on SSE reconnect so terminal events fired during a network blip don't get permanently lost. System stats - Refresh button uses correct single class (was getting both `btn` and `btn-icon`, which conflicted on padding/border). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
679 lines
22 KiB
TypeScript
679 lines
22 KiB
TypeScript
/**
|
|
* Covers background AI session hook behavior: fetch lifecycle, SSE updates,
|
|
* dismissal, counters/filters, refresh, and project scoping.
|
|
*/
|
|
|
|
import { act, renderHook, waitFor } from "@testing-library/react";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { useBackgroundSessions } from "../useBackgroundSessions";
|
|
import {
|
|
__destroyAiSessionSyncStoreForTests,
|
|
__resetAiSessionSyncStoreForTests,
|
|
useAiSessionSync,
|
|
} from "../useAiSessionSync";
|
|
import * as apiModule from "../../api";
|
|
import { MockEventSource } from "../../../vitest.setup";
|
|
|
|
vi.mock("../../api", () => ({
|
|
fetchAiSessions: vi.fn(),
|
|
deleteAiSession: vi.fn(),
|
|
cancelPlanning: vi.fn(),
|
|
cancelSubtaskBreakdown: vi.fn(),
|
|
cancelMissionInterview: vi.fn(),
|
|
}));
|
|
|
|
const mockFetchAiSessions = vi.mocked(apiModule.fetchAiSessions);
|
|
const mockDeleteAiSession = vi.mocked(apiModule.deleteAiSession);
|
|
const mockCancelPlanning = vi.mocked(apiModule.cancelPlanning);
|
|
const mockCancelSubtaskBreakdown = vi.mocked(apiModule.cancelSubtaskBreakdown);
|
|
const mockCancelMissionInterview = vi.mocked(apiModule.cancelMissionInterview);
|
|
|
|
function makeSession(overrides: Partial<apiModule.AiSessionSummary> & Pick<apiModule.AiSessionSummary, "id">): apiModule.AiSessionSummary {
|
|
return {
|
|
id: overrides.id,
|
|
type: overrides.type ?? "planning",
|
|
status: overrides.status ?? "generating",
|
|
title: overrides.title ?? overrides.id,
|
|
projectId: overrides.projectId ?? null,
|
|
lockedByTab: overrides.lockedByTab ?? null,
|
|
updatedAt: overrides.updatedAt ?? "2026-04-08T00:00:00.000Z",
|
|
};
|
|
}
|
|
|
|
describe("useBackgroundSessions", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
__resetAiSessionSyncStoreForTests();
|
|
__destroyAiSessionSyncStoreForTests();
|
|
mockFetchAiSessions.mockResolvedValue([]);
|
|
mockDeleteAiSession.mockResolvedValue(undefined);
|
|
mockCancelPlanning.mockResolvedValue(undefined);
|
|
mockCancelSubtaskBreakdown.mockResolvedValue(undefined);
|
|
mockCancelMissionInterview.mockResolvedValue(undefined);
|
|
});
|
|
|
|
afterEach(() => {
|
|
__resetAiSessionSyncStoreForTests();
|
|
__destroyAiSessionSyncStoreForTests();
|
|
});
|
|
|
|
it("fetches and filters initial sessions", async () => {
|
|
mockFetchAiSessions.mockResolvedValueOnce([
|
|
makeSession({ id: "s-generating", status: "generating" }),
|
|
makeSession({ id: "s-awaiting", status: "awaiting_input" }),
|
|
makeSession({ id: "s-complete", status: "complete" }),
|
|
makeSession({ id: "s-error", status: "error" }),
|
|
makeSession({ id: "s-ignored", status: "paused" as any }),
|
|
]);
|
|
|
|
const { result } = renderHook(() => useBackgroundSessions());
|
|
|
|
await waitFor(() => {
|
|
expect(mockFetchAiSessions).toHaveBeenCalledWith(undefined);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.sessions.map((session) => session.id).sort()).toEqual([
|
|
"s-awaiting",
|
|
"s-generating",
|
|
]);
|
|
});
|
|
});
|
|
|
|
it("logs a warning when fetching background sessions fails", async () => {
|
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
const networkError = new Error("Network error");
|
|
mockFetchAiSessions.mockRejectedValueOnce(networkError);
|
|
|
|
const { result } = renderHook(() => useBackgroundSessions());
|
|
|
|
await waitFor(() => {
|
|
expect(warnSpy).toHaveBeenCalledWith(
|
|
"[useBackgroundSessions] Failed to fetch AI sessions:",
|
|
networkError,
|
|
);
|
|
});
|
|
|
|
expect(result.current.sessions).toEqual([]);
|
|
warnSpy.mockRestore();
|
|
});
|
|
|
|
it("applies SSE-driven session updates reactively", async () => {
|
|
const { result } = renderHook(() => useBackgroundSessions());
|
|
|
|
await waitFor(() => {
|
|
expect(MockEventSource.instances.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
const eventSource = MockEventSource.instances[0]!;
|
|
|
|
act(() => {
|
|
eventSource._emit(
|
|
"ai_session:updated",
|
|
makeSession({
|
|
id: "sse-session",
|
|
type: "mission_interview",
|
|
status: "generating",
|
|
title: "Mission stream",
|
|
updatedAt: "2026-04-08T00:00:01.000Z",
|
|
}),
|
|
);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.sessions.find((session) => session.id === "sse-session")?.status).toBe(
|
|
"generating",
|
|
);
|
|
});
|
|
|
|
act(() => {
|
|
eventSource._emit(
|
|
"ai_session:updated",
|
|
makeSession({
|
|
id: "sse-session",
|
|
type: "mission_interview",
|
|
status: "awaiting_input",
|
|
title: "Mission stream",
|
|
updatedAt: "2026-04-08T00:00:02.000Z",
|
|
}),
|
|
);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.sessions.find((session) => session.id === "sse-session")?.status).toBe(
|
|
"awaiting_input",
|
|
);
|
|
});
|
|
});
|
|
|
|
it("removes session when SSE delivers complete status", async () => {
|
|
const { result } = renderHook(() => useBackgroundSessions());
|
|
|
|
await waitFor(() => {
|
|
expect(MockEventSource.instances.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
const eventSource = MockEventSource.instances[0]!;
|
|
|
|
act(() => {
|
|
eventSource._emit(
|
|
"ai_session:updated",
|
|
makeSession({
|
|
id: "terminal-complete",
|
|
type: "planning",
|
|
status: "generating",
|
|
updatedAt: "2026-04-08T00:00:01.000Z",
|
|
}),
|
|
);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.generating).toBe(1);
|
|
expect(result.current.sessions.map((session) => session.id)).toEqual(["terminal-complete"]);
|
|
expect(result.current.planningSessions.map((session) => session.id)).toEqual(["terminal-complete"]);
|
|
});
|
|
|
|
act(() => {
|
|
eventSource._emit(
|
|
"ai_session:updated",
|
|
makeSession({
|
|
id: "terminal-complete",
|
|
type: "planning",
|
|
status: "complete",
|
|
updatedAt: "2026-04-08T00:00:02.000Z",
|
|
}),
|
|
);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.sessions).toEqual([]);
|
|
expect(result.current.planningSessions).toEqual([]);
|
|
expect(result.current.generating).toBe(0);
|
|
});
|
|
});
|
|
|
|
it("removes session when SSE delivers error status", async () => {
|
|
const { result } = renderHook(() => useBackgroundSessions());
|
|
|
|
await waitFor(() => {
|
|
expect(MockEventSource.instances.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
const eventSource = MockEventSource.instances[0]!;
|
|
|
|
act(() => {
|
|
eventSource._emit(
|
|
"ai_session:updated",
|
|
makeSession({
|
|
id: "terminal-error",
|
|
type: "planning",
|
|
status: "generating",
|
|
updatedAt: "2026-04-08T00:00:01.000Z",
|
|
}),
|
|
);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.sessions.map((session) => session.id)).toEqual(["terminal-error"]);
|
|
expect(result.current.planningSessions.map((session) => session.id)).toEqual(["terminal-error"]);
|
|
});
|
|
|
|
act(() => {
|
|
eventSource._emit(
|
|
"ai_session:updated",
|
|
makeSession({
|
|
id: "terminal-error",
|
|
type: "planning",
|
|
status: "error",
|
|
updatedAt: "2026-04-08T00:00:02.000Z",
|
|
}),
|
|
);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.sessions).toEqual([]);
|
|
expect(result.current.planningSessions).toEqual([]);
|
|
expect(result.current.generating).toBe(0);
|
|
});
|
|
});
|
|
|
|
it("removes sessions when ai_session:deleted SSE event arrives", async () => {
|
|
mockFetchAiSessions.mockResolvedValueOnce([
|
|
makeSession({ id: "delete-me", status: "awaiting_input" }),
|
|
]);
|
|
|
|
const { result } = renderHook(() => useBackgroundSessions());
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.sessions).toHaveLength(1);
|
|
});
|
|
|
|
const eventSource = MockEventSource.instances[0]!;
|
|
act(() => {
|
|
eventSource._emit("ai_session:deleted", "delete-me");
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.sessions).toEqual([]);
|
|
});
|
|
});
|
|
|
|
it("dismissSession calls API and updates local state", async () => {
|
|
mockFetchAiSessions.mockResolvedValueOnce([
|
|
makeSession({ id: "dismiss-me", status: "awaiting_input" }),
|
|
]);
|
|
|
|
const { result } = renderHook(() => useBackgroundSessions());
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.sessions.map((session) => session.id)).toEqual(["dismiss-me"]);
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.dismissSession("dismiss-me");
|
|
});
|
|
|
|
expect(mockDeleteAiSession).toHaveBeenCalledWith("dismiss-me");
|
|
await waitFor(() => {
|
|
expect(result.current.sessions).toEqual([]);
|
|
});
|
|
});
|
|
|
|
it("dismissSession calls cancelPlanning for planning sessions", async () => {
|
|
mockFetchAiSessions.mockResolvedValueOnce([
|
|
makeSession({ id: "planning-session", status: "generating", type: "planning" }),
|
|
]);
|
|
|
|
const { result } = renderHook(() => useBackgroundSessions());
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.sessions.map((session) => session.id)).toEqual(["planning-session"]);
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.dismissSession("planning-session");
|
|
});
|
|
|
|
expect(mockCancelPlanning).toHaveBeenCalledWith("planning-session", undefined, expect.any(String));
|
|
expect(mockDeleteAiSession).toHaveBeenCalledWith("planning-session");
|
|
});
|
|
|
|
it("force-dismisses a planning session even when cancellation is lock-conflicted", async () => {
|
|
mockFetchAiSessions.mockResolvedValueOnce([
|
|
makeSession({ id: "planning-locked", status: "generating", type: "planning" }),
|
|
]);
|
|
mockCancelPlanning.mockRejectedValueOnce(new Error("locked by another tab"));
|
|
|
|
const { result } = renderHook(() => useBackgroundSessions());
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.sessions.map((session) => session.id)).toEqual(["planning-locked"]);
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.dismissSession("planning-locked");
|
|
});
|
|
|
|
expect(mockDeleteAiSession).toHaveBeenCalledWith("planning-locked");
|
|
expect(result.current.sessions).toEqual([]);
|
|
});
|
|
|
|
it("keeps a dismissed planning session hidden when stale sync update arrives", async () => {
|
|
mockFetchAiSessions.mockResolvedValueOnce([
|
|
makeSession({ id: "dismiss-sync", status: "generating", type: "planning" }),
|
|
]);
|
|
|
|
const { result } = renderHook(() => useBackgroundSessions());
|
|
const { result: syncResult } = renderHook(() => useAiSessionSync());
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.sessions.map((session) => session.id)).toEqual(["dismiss-sync"]);
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.dismissSession("dismiss-sync");
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.sessions).toEqual([]);
|
|
});
|
|
|
|
act(() => {
|
|
syncResult.current.broadcastUpdate({
|
|
sessionId: "dismiss-sync",
|
|
status: "generating",
|
|
needsInput: false,
|
|
type: "planning",
|
|
title: "Dismiss Sync",
|
|
updatedAt: "1970-01-01T00:00:01.000Z",
|
|
timestamp: 1,
|
|
});
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.sessions).toEqual([]);
|
|
expect(result.current.planningSessions).toEqual([]);
|
|
expect(result.current.generating).toBe(0);
|
|
expect(result.current.needsInput).toBe(0);
|
|
});
|
|
});
|
|
|
|
it("keeps a dismissed planning session hidden when stale SSE update arrives", async () => {
|
|
mockFetchAiSessions.mockResolvedValueOnce([
|
|
makeSession({ id: "dismiss-sse", status: "generating", type: "planning" }),
|
|
]);
|
|
|
|
const { result } = renderHook(() => useBackgroundSessions());
|
|
|
|
await waitFor(() => {
|
|
expect(MockEventSource.instances.length).toBeGreaterThan(0);
|
|
expect(result.current.sessions.map((session) => session.id)).toEqual(["dismiss-sse"]);
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.dismissSession("dismiss-sse");
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.sessions).toEqual([]);
|
|
});
|
|
|
|
const eventSource = MockEventSource.instances[0]!;
|
|
act(() => {
|
|
eventSource._emit(
|
|
"ai_session:updated",
|
|
makeSession({
|
|
id: "dismiss-sse",
|
|
type: "planning",
|
|
status: "generating",
|
|
title: "Dismiss SSE",
|
|
updatedAt: "1970-01-01T00:00:01.000Z",
|
|
}),
|
|
);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.sessions).toEqual([]);
|
|
expect(result.current.planningSessions).toEqual([]);
|
|
expect(result.current.generating).toBe(0);
|
|
expect(result.current.needsInput).toBe(0);
|
|
});
|
|
});
|
|
|
|
it("allows a newer authoritative SSE update to restore a dismissed session", async () => {
|
|
mockFetchAiSessions.mockResolvedValueOnce([
|
|
makeSession({ id: "dismiss-restore", status: "generating", type: "planning" }),
|
|
]);
|
|
|
|
const { result } = renderHook(() => useBackgroundSessions());
|
|
|
|
await waitFor(() => {
|
|
expect(MockEventSource.instances.length).toBeGreaterThan(0);
|
|
expect(result.current.sessions.map((session) => session.id)).toEqual(["dismiss-restore"]);
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.dismissSession("dismiss-restore");
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.sessions).toEqual([]);
|
|
});
|
|
|
|
const eventSource = MockEventSource.instances[0]!;
|
|
act(() => {
|
|
eventSource._emit(
|
|
"ai_session:updated",
|
|
makeSession({
|
|
id: "dismiss-restore",
|
|
type: "planning",
|
|
status: "awaiting_input",
|
|
title: "Dismiss Restore",
|
|
updatedAt: new Date(Date.now() + 60_000).toISOString(),
|
|
}),
|
|
);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.sessions.map((session) => session.id)).toEqual(["dismiss-restore"]);
|
|
expect(result.current.planningSessions.map((session) => session.id)).toEqual(["dismiss-restore"]);
|
|
expect(result.current.generating).toBe(0);
|
|
expect(result.current.needsInput).toBe(1);
|
|
});
|
|
});
|
|
|
|
it("refresh keeps dismissed sessions hidden when server returns stale data", async () => {
|
|
const staleSession = makeSession({
|
|
id: "dismiss-refresh",
|
|
status: "generating",
|
|
type: "planning",
|
|
updatedAt: "2026-04-08T00:00:01.000Z",
|
|
});
|
|
|
|
mockFetchAiSessions.mockResolvedValueOnce([staleSession]);
|
|
|
|
const { result } = renderHook(() => useBackgroundSessions());
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.sessions.map((session) => session.id)).toEqual(["dismiss-refresh"]);
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.dismissSession("dismiss-refresh");
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.sessions).toEqual([]);
|
|
});
|
|
|
|
mockFetchAiSessions.mockResolvedValueOnce([staleSession]);
|
|
|
|
act(() => {
|
|
result.current.refresh();
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.sessions).toEqual([]);
|
|
expect(result.current.planningSessions).toEqual([]);
|
|
expect(result.current.generating).toBe(0);
|
|
expect(result.current.needsInput).toBe(0);
|
|
});
|
|
});
|
|
|
|
it("does not resurrect a session after ai_session:deleted when a stale sync update arrives", async () => {
|
|
// Regression: handleDeleted cleared sessionTimestampsRef without setting a
|
|
// dismissed tombstone or calling broadcastCompleted. The merge effect then
|
|
// saw knownTimestamp=0 for the deleted session, accepted the still-live
|
|
// syncedSessions entry (non-terminal status), and re-added it — causing the
|
|
// stale badge count even when the DB was empty.
|
|
mockFetchAiSessions.mockResolvedValueOnce([
|
|
makeSession({ id: "delete-resurface", status: "generating", type: "planning" }),
|
|
]);
|
|
|
|
const { result } = renderHook(() => useBackgroundSessions());
|
|
const { result: syncResult } = renderHook(() => useAiSessionSync());
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.sessions.map((session) => session.id)).toEqual(["delete-resurface"]);
|
|
});
|
|
|
|
// Simulate a sibling tab broadcasting the session as active in the sync store.
|
|
act(() => {
|
|
syncResult.current.broadcastUpdate({
|
|
sessionId: "delete-resurface",
|
|
status: "generating",
|
|
needsInput: false,
|
|
type: "planning",
|
|
title: "Deleted Resurface",
|
|
updatedAt: "2026-04-08T00:00:01.000Z",
|
|
timestamp: Date.parse("2026-04-08T00:00:01.000Z"),
|
|
});
|
|
});
|
|
|
|
// Session is in sync store with non-terminal status at this point.
|
|
await waitFor(() => {
|
|
expect(result.current.sessions.map((session) => session.id)).toEqual(["delete-resurface"]);
|
|
});
|
|
|
|
// Server fires ai_session:deleted — the session is gone from the DB.
|
|
const eventSource = MockEventSource.instances[0]!;
|
|
act(() => {
|
|
eventSource._emit("ai_session:deleted", "delete-resurface");
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.sessions).toEqual([]);
|
|
expect(result.current.planningSessions).toEqual([]);
|
|
});
|
|
|
|
// Now a stale sync update arrives (same timestamp as before, non-terminal).
|
|
// Without the fix this re-materialized the session in the UI.
|
|
act(() => {
|
|
syncResult.current.broadcastUpdate({
|
|
sessionId: "delete-resurface",
|
|
status: "generating",
|
|
needsInput: false,
|
|
type: "planning",
|
|
title: "Deleted Resurface",
|
|
updatedAt: "2026-04-08T00:00:01.000Z",
|
|
timestamp: Date.parse("2026-04-08T00:00:01.000Z"),
|
|
});
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.sessions).toEqual([]);
|
|
expect(result.current.planningSessions).toEqual([]);
|
|
expect(result.current.generating).toBe(0);
|
|
expect(result.current.needsInput).toBe(0);
|
|
});
|
|
});
|
|
|
|
it("dismissSession calls cancelSubtaskBreakdown for subtask sessions", async () => {
|
|
mockFetchAiSessions.mockResolvedValueOnce([
|
|
makeSession({ id: "subtask-session", status: "generating", type: "subtask" }),
|
|
]);
|
|
|
|
const { result } = renderHook(() => useBackgroundSessions());
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.sessions.map((session) => session.id)).toEqual(["subtask-session"]);
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.dismissSession("subtask-session");
|
|
});
|
|
|
|
expect(mockCancelSubtaskBreakdown).toHaveBeenCalledWith("subtask-session", undefined, expect.any(String));
|
|
expect(mockDeleteAiSession).toHaveBeenCalledWith("subtask-session");
|
|
});
|
|
|
|
it("dismissSession calls cancelMissionInterview for mission_interview sessions", async () => {
|
|
mockFetchAiSessions.mockResolvedValueOnce([
|
|
makeSession({ id: "interview-session", status: "generating", type: "mission_interview" }),
|
|
]);
|
|
|
|
const { result } = renderHook(() => useBackgroundSessions());
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.sessions.map((session) => session.id)).toEqual(["interview-session"]);
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.dismissSession("interview-session");
|
|
});
|
|
|
|
expect(mockCancelMissionInterview).toHaveBeenCalledWith("interview-session", undefined, expect.any(String));
|
|
expect(mockDeleteAiSession).toHaveBeenCalledWith("interview-session");
|
|
});
|
|
|
|
it("returns accurate generating/needsInput counts and planningSessions filter", async () => {
|
|
mockFetchAiSessions.mockResolvedValueOnce([
|
|
makeSession({ id: "count-generating", status: "generating", type: "planning" }),
|
|
makeSession({ id: "count-awaiting", status: "awaiting_input", type: "subtask" }),
|
|
makeSession({ id: "count-error-plan", status: "error", type: "planning" }),
|
|
makeSession({ id: "count-complete", status: "complete", type: "mission_interview" }),
|
|
]);
|
|
|
|
const { result } = renderHook(() => useBackgroundSessions());
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.generating).toBe(1);
|
|
expect(result.current.needsInput).toBe(1);
|
|
expect(result.current.planningSessions.map((session) => session.id)).toEqual([
|
|
"count-generating",
|
|
]);
|
|
expect(result.current.sessions.map((session) => session.id).sort()).toEqual([
|
|
"count-awaiting",
|
|
"count-generating",
|
|
]);
|
|
});
|
|
});
|
|
|
|
it("refresh triggers a new fetch and updates state", async () => {
|
|
mockFetchAiSessions
|
|
.mockResolvedValueOnce([makeSession({ id: "first-session", status: "generating" })])
|
|
.mockResolvedValueOnce([makeSession({ id: "second-session", status: "awaiting_input" })]);
|
|
|
|
const { result } = renderHook(() => useBackgroundSessions());
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.sessions.map((session) => session.id)).toEqual(["first-session"]);
|
|
});
|
|
|
|
act(() => {
|
|
result.current.refresh();
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(mockFetchAiSessions).toHaveBeenCalledTimes(2);
|
|
expect(result.current.sessions.map((session) => session.id)).toEqual(["second-session"]);
|
|
});
|
|
});
|
|
|
|
it("logs a warning when refresh fetch fails and preserves existing sessions", async () => {
|
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
const refreshError = new Error("Server error");
|
|
|
|
mockFetchAiSessions
|
|
.mockResolvedValueOnce([makeSession({ id: "existing-session", status: "generating" })])
|
|
.mockRejectedValueOnce(refreshError);
|
|
|
|
const { result } = renderHook(() => useBackgroundSessions());
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.sessions.map((session) => session.id)).toEqual(["existing-session"]);
|
|
});
|
|
|
|
act(() => {
|
|
result.current.refresh();
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(mockFetchAiSessions).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
expect(warnSpy).toHaveBeenCalledWith(
|
|
"[useBackgroundSessions] Failed to fetch AI sessions:",
|
|
refreshError,
|
|
);
|
|
expect(result.current.sessions.map((session) => session.id)).toEqual(["existing-session"]);
|
|
|
|
warnSpy.mockRestore();
|
|
});
|
|
|
|
it("passes projectId to API fetch and uses project-scoped SSE URL", async () => {
|
|
const projectId = "proj-123";
|
|
renderHook(() => useBackgroundSessions(projectId));
|
|
|
|
await waitFor(() => {
|
|
expect(mockFetchAiSessions).toHaveBeenCalledWith(projectId);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(MockEventSource.instances.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
expect(MockEventSource.instances[0]?.url).toContain(`/api/events?projectId=${encodeURIComponent(projectId)}`);
|
|
});
|
|
});
|