test(FN-1156): add AI session lifecycle and reconnect coverage
- Add ai-session-store unit tests for upsert/get round-trips, active filtering, stale recovery, cleanup, thinking debounce, and update/delete events - Add persistence and resume-history integration tests for planning, subtask, and mission interview sessions across SQLite reload and cancellation flows - Add reconnect and cross-tab lock tests covering SSE Last-Event-ID replay, keep-alive ping touches, optimistic lock conflicts, stale lock expiry, and lock release on tab close - Expand useBackgroundSessions hook tests for SSE-driven updates/deletes, project-scoped fetch/stream behavior, counts, dismiss, and refresh state handling
This commit is contained in:
@@ -1,12 +1,17 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
/**
|
||||
* 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(),
|
||||
@@ -16,51 +21,23 @@ vi.mock("../../api", () => ({
|
||||
const mockFetchAiSessions = vi.mocked(apiModule.fetchAiSessions);
|
||||
const mockDeleteAiSession = vi.mocked(apiModule.deleteAiSession);
|
||||
|
||||
class MockEventSource {
|
||||
static instances: MockEventSource[] = [];
|
||||
|
||||
readonly url: string;
|
||||
private listeners = new Map<string, Set<(event: MessageEvent) => void>>();
|
||||
|
||||
constructor(url: string) {
|
||||
this.url = url;
|
||||
MockEventSource.instances.push(this);
|
||||
}
|
||||
|
||||
addEventListener(type: string, listener: (event: MessageEvent) => void): void {
|
||||
const set = this.listeners.get(type) ?? new Set<(event: MessageEvent) => void>();
|
||||
set.add(listener);
|
||||
this.listeners.set(type, set);
|
||||
}
|
||||
|
||||
removeEventListener(type: string, listener: (event: MessageEvent) => void): void {
|
||||
this.listeners.get(type)?.delete(listener);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.listeners.clear();
|
||||
}
|
||||
|
||||
emit(type: string, payload: unknown): void {
|
||||
const event = { data: JSON.stringify(payload) } as MessageEvent;
|
||||
for (const listener of this.listeners.get(type) ?? []) {
|
||||
listener(event);
|
||||
}
|
||||
}
|
||||
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", () => {
|
||||
const originalEventSource = globalThis.EventSource;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
__resetAiSessionSyncStoreForTests();
|
||||
__destroyAiSessionSyncStoreForTests();
|
||||
|
||||
MockEventSource.instances = [];
|
||||
(globalThis as unknown as { EventSource: typeof EventSource }).EventSource =
|
||||
MockEventSource as unknown as typeof EventSource;
|
||||
|
||||
mockFetchAiSessions.mockResolvedValue([]);
|
||||
mockDeleteAiSession.mockResolvedValue(undefined);
|
||||
});
|
||||
@@ -68,71 +45,176 @@ describe("useBackgroundSessions", () => {
|
||||
afterEach(() => {
|
||||
__resetAiSessionSyncStoreForTests();
|
||||
__destroyAiSessionSyncStoreForTests();
|
||||
(globalThis as unknown as { EventSource: typeof EventSource }).EventSource = originalEventSource;
|
||||
});
|
||||
|
||||
it("merges cross-tab session updates into the local list", async () => {
|
||||
const background = renderHook(() => useBackgroundSessions("proj-1"));
|
||||
const sync = renderHook(() => useAiSessionSync());
|
||||
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("proj-1");
|
||||
});
|
||||
|
||||
act(() => {
|
||||
sync.result.current.broadcastUpdate({
|
||||
sessionId: "sess-cross-tab",
|
||||
status: "awaiting_input",
|
||||
needsInput: true,
|
||||
type: "planning",
|
||||
title: "Cross-tab planning",
|
||||
projectId: "proj-1",
|
||||
owningTabId: "tab-other",
|
||||
timestamp: 500,
|
||||
});
|
||||
expect(mockFetchAiSessions).toHaveBeenCalledWith(undefined);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(background.result.current.sessions).toHaveLength(1);
|
||||
expect(background.result.current.sessions[0]).toMatchObject({
|
||||
id: "sess-cross-tab",
|
||||
status: "awaiting_input",
|
||||
type: "planning",
|
||||
});
|
||||
expect(result.current.sessions.map((session) => session.id).sort()).toEqual([
|
||||
"s-awaiting",
|
||||
"s-complete",
|
||||
"s-error",
|
||||
"s-generating",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it("broadcasts SSE updates through the sync store", async () => {
|
||||
const background = renderHook(() => useBackgroundSessions("proj-1"));
|
||||
const sync = renderHook(() => useAiSessionSync());
|
||||
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];
|
||||
const eventSource = MockEventSource.instances[0]!;
|
||||
|
||||
act(() => {
|
||||
eventSource.emit("ai_session:updated", {
|
||||
id: "sess-sse",
|
||||
type: "subtask",
|
||||
status: "generating",
|
||||
title: "SSE session",
|
||||
projectId: "proj-1",
|
||||
lockedByTab: "tab-remote",
|
||||
updatedAt: "2026-04-08T00:00:00.000Z",
|
||||
});
|
||||
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(background.result.current.sessions[0]?.id).toBe("sess-sse");
|
||||
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(() => {
|
||||
const synced = sync.result.current.sessions.get("sess-sse");
|
||||
expect(synced?.status).toBe("generating");
|
||||
expect(synced?.type).toBe("subtask");
|
||||
expect(synced?.owningTabId).toBe("tab-remote");
|
||||
expect(result.current.sessions.find((session) => session.id === "sse-session")?.status).toBe(
|
||||
"awaiting_input",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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"]);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.dismissSession("dismiss-me");
|
||||
});
|
||||
|
||||
expect(mockDeleteAiSession).toHaveBeenCalledWith("dismiss-me");
|
||||
await waitFor(() => {
|
||||
expect(result.current.sessions).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
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).sort()).toEqual([
|
||||
"count-error-plan",
|
||||
"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("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)}`);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user