import { act, renderHook, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { getSettings, saveSettings } from "./settings"; import { classNames, formatTokens, relativeTime, useSessions, useToasts, } from "./store"; import { FakeWebSocket, jsonResponse, mockFetchJson, seedSettings, stubReload, } from "./test/setup"; import type { EventFrame, SessionListItem } from "./protocol"; const NOW = new Date("2024-05-01T12:00:00Z").getTime(); describe("relativeTime", () => { beforeEach(() => { vi.useFakeTimers(); vi.setSystemTime(NOW); }); afterEach(() => { vi.useRealTimers(); }); it("formats all buckets", () => { expect(relativeTime(null)).toBe("never"); expect(relativeTime(NOW - 10_000)).toBe("just now"); expect(relativeTime(NOW + 30_000)).toBe("just now"); expect(relativeTime(NOW - 5 * 60_000)).toBe("5m ago"); expect(relativeTime(NOW - 3 * 3_600_000)).toBe("3h ago"); expect(relativeTime(NOW - 2 * 86_400_000)).toBe("2d ago"); }); }); describe("classNames", () => { it("joins truthy parts", () => { expect(classNames("a", false, "b", null, undefined, "c")).toBe("a b c"); expect(classNames()).toBe(""); }); }); describe("formatTokens", () => { it("compacts with one decimal under 100, no decimal above", () => { expect(formatTokens(0)).toBe("0"); expect(formatTokens(999)).toBe("999"); expect(formatTokens(1000)).toBe("1K"); expect(formatTokens(1234)).toBe("1.2K"); expect(formatTokens(340000)).toBe("340K"); expect(formatTokens(1250000)).toBe("1.3M"); expect(formatTokens(1200000)).toBe("1.2M"); expect(formatTokens(2500000000)).toBe("2.5B"); }); }); describe("useToasts", () => { beforeEach(() => { vi.useFakeTimers(); }); afterEach(() => { vi.useRealTimers(); }); it("pushes a toast and removes it after TTL", () => { const { result } = renderHook(() => useToasts()); act(() => result.current.push("hello")); expect(result.current.toasts.map((t) => t.text)).toEqual(["hello"]); act(() => result.current.push("second")); expect(result.current.toasts).toHaveLength(2); act(() => { vi.advanceTimersByTime(4000); }); expect(result.current.toasts).toHaveLength(0); }); }); function listEvent(seq: number): EventFrame { return { v: 1, sessionId: "s1", seq, ts: 0, type: "message_end" }; } describe("useSessions", () => { it("returns null when unconfigured", () => { const push = vi.fn(); const { result } = renderHook(() => useSessions(push, true)); expect(result.current).toBeNull(); }); it("first-run: inactive -> active starts the ws manager (A)", async () => { seedSettings(); mockFetchJson(() => []); const push = vi.fn(); const { result, rerender } = renderHook( ({ active }: { active: boolean }) => useSessions(push, active), { initialProps: { active: false } }, ); // gate is up: settings exist but the hook must not dial yet expect(FakeWebSocket.instances.length).toBe(0); expect(result.current).not.toBeNull(); // gate saved: active flips true, the effect re-runs and starts the manager rerender({ active: true }); await waitFor(() => expect(FakeWebSocket.instances.length).toBe(1)); expect(result.current?.state).toBe("connecting"); }); it("seeds via REST, updates from session_list and spawn_status frames, refresh works", async () => { seedSettings(); const sessions: SessionListItem[] = [ { id: "s1", name: "one", online: true, busy: false, }, ] as SessionListItem[]; const fetchMock = mockFetchJson((url) => { if (url.includes("/api/sessions")) return url.includes("/events") ? [] : sessions; return []; }); const push = vi.fn(); const { result } = renderHook(() => useSessions(push, true)); expect(result.current?.state).toBe("connecting"); await waitFor(() => expect(result.current?.sessions).toEqual(sessions)); const sock = FakeWebSocket.last(); act(() => sock.serverOpen()); expect(result.current?.state).toBe("open"); act(() => sock.serverMessage({ type: "session_list", sessions: [{ id: "s2", name: "two", online: false }], }), ); expect(result.current?.sessions.map((s) => s.id)).toEqual(["s2"]); act(() => sock.serverMessage({ type: "spawn_status", jobs: [{ repo: "g/p", state: "cloning" }], }), ); expect(result.current?.spawnJobs).toEqual([ { repo: "g/p", state: "cloning" }, ]); // refresh resolves to the fetched rows (S7) and re-seeds the list let out: SessionListItem[] | undefined; await act(async () => { out = await result.current?.refresh(); }); expect(out).toEqual(sessions); expect(result.current?.sessions).toEqual(sessions); expect(fetchMock).toHaveBeenCalled(); }); it("refresh failure pushes a toast and resolves to an empty list", async () => { seedSettings(); mockFetchJson(() => { throw new Error("network down"); }); const push = vi.fn(); const { result } = renderHook(() => useSessions(push, true)); await waitFor(() => expect(push).toHaveBeenCalledWith("sessions: network down"), ); expect(result.current).not.toBeNull(); let out: SessionListItem[] | undefined; await act(async () => { out = await result.current?.refresh(); }); expect(out).toEqual([]); }); it("subscribe routes events frames for the session and detaches on unsubscribe", async () => { seedSettings(); mockFetchJson(() => []); const push = vi.fn(); const { result } = renderHook(() => useSessions(push, true)); const sock = FakeWebSocket.last(); act(() => sock.serverOpen()); const seen: EventFrame[][] = []; let off: (() => void) | undefined; act(() => { off = result.current?.subscribe("s1", (events) => seen.push(events)); }); expect(sock.sent).toContain( JSON.stringify({ type: "subscribe", sessionId: "s1" }), ); act(() => sock.serverMessage({ type: "events", sessionId: "s1", after: 0, events: [listEvent(1)], }), ); act(() => sock.serverMessage({ type: "events", sessionId: "other", after: 0, events: [listEvent(2)], }), ); expect(seen).toEqual([[listEvent(1)]]); act(() => off?.()); act(() => sock.serverMessage({ type: "events", sessionId: "s1", after: 1, events: [listEvent(3)], }), ); expect(seen).toHaveLength(1); expect(sock.sent).toContain( JSON.stringify({ type: "unsubscribe", sessionId: "s1" }), ); }); it("auth failure clears settings and reloads", async () => { seedSettings(); mockFetchJson(() => []); const loc = stubReload(); const push = vi.fn(); const { result } = renderHook(() => useSessions(push, true)); const sock = FakeWebSocket.last(); act(() => sock.serverOpen()); act(() => sock.serverClose(1008)); expect(loc.reload).toHaveBeenCalled(); expect(getSettings()).toBeNull(); await waitFor(() => expect(result.current).toBeNull()); loc.restore(); }); it("3 failed connects + 401 REST probe clears settings and reloads (S3)", async () => { vi.useFakeTimers(); vi.spyOn(Math, "random").mockReturnValue(1); seedSettings(); mockFetchJson((url) => url.includes("/api/sessions") ? jsonResponse({ error: "unauthorized" }, 401) : [], ); const loc = stubReload(); const push = vi.fn(); renderHook(() => useSessions(push, true)); const failConnect = (): void => { act(() => FakeWebSocket.last().serverClose(1006)); }; failConnect(); act(() => { vi.advanceTimersByTime(500); }); failConnect(); act(() => { vi.advanceTimersByTime(1000); }); failConnect(); // 3rd consecutive connect failure -> probe -> 401 for (let i = 0; i < 8; i += 1) { // eslint-disable-next-line no-await-in-loop await act(async () => { await Promise.resolve(); }); } expect(getSettings()).toBeNull(); expect(loc.reload).toHaveBeenCalled(); loc.restore(); vi.useRealTimers(); }); it("probe failing with a non-401 error never clears settings (S3)", async () => { vi.useFakeTimers(); vi.spyOn(Math, "random").mockReturnValue(1); seedSettings(); mockFetchJson((url) => { if (url.includes("/api/sessions")) throw new Error("daemon unreachable"); return []; }); const loc = stubReload(); const push = vi.fn(); renderHook(() => useSessions(push, true)); const failConnect = (): void => { act(() => FakeWebSocket.last().serverClose(1006)); }; failConnect(); act(() => { vi.advanceTimersByTime(500); }); failConnect(); act(() => { vi.advanceTimersByTime(1000); }); failConnect(); for (let i = 0; i < 8; i += 1) { // eslint-disable-next-line no-await-in-loop await act(async () => { await Promise.resolve(); }); } expect(getSettings()).not.toBeNull(); expect(loc.reload).not.toHaveBeenCalled(); loc.restore(); vi.useRealTimers(); }); it("successful probe resets the failure counter, no clear (S3)", async () => { vi.useFakeTimers(); vi.spyOn(Math, "random").mockReturnValue(1); seedSettings(); let sessionsCalls = 0; mockFetchJson((url) => { if (url.includes("/api/sessions")) { sessionsCalls += 1; return []; } return []; }); const loc = stubReload(); const push = vi.fn(); renderHook(() => useSessions(push, true)); const failConnect = (): void => { act(() => FakeWebSocket.last().serverClose(1006)); }; const flush = async (): Promise => { for (let i = 0; i < 8; i += 1) { // eslint-disable-next-line no-await-in-loop await act(async () => { await Promise.resolve(); }); } }; failConnect(); act(() => { vi.advanceTimersByTime(500); }); failConnect(); act(() => { vi.advanceTimersByTime(1000); }); failConnect(); await flush(); // mount seed (1) + exactly one probe (2); nothing else expect(sessionsCalls).toBe(2); expect(loc.reload).not.toHaveBeenCalled(); expect(getSettings()).not.toBeNull(); // counter reset: two more failures stay under the threshold, no probe failConnect(); act(() => { vi.advanceTimersByTime(2000); }); failConnect(); await flush(); expect(sessionsCalls).toBe(2); loc.restore(); vi.useRealTimers(); }); it("unmount closes the manager", async () => { seedSettings(); mockFetchJson(() => []); const push = vi.fn(); const { unmount } = renderHook(() => useSessions(push, true)); await waitFor(() => expect(FakeWebSocket.instances.length).toBe(1)); const sock = FakeWebSocket.last(); unmount(); expect(sock.closeCode).toBe(4900); expect(sock.onclose).toBeNull(); }); it("returned store object keeps identity when only unrelated state changes (S2)", async () => { seedSettings(); // a stable list reference: only identity behavior is under test here const stable: SessionListItem[] = []; mockFetchJson(() => stable); const push = vi.fn(); const { result, rerender } = renderHook(() => useSessions(push, true)); await waitFor(() => expect(FakeWebSocket.instances.length).toBe(1)); // let the mount-time seed settle for (let i = 0; i < 4; i += 1) { // eslint-disable-next-line no-await-in-loop await act(async () => { await Promise.resolve(); }); } const first = result.current; rerender(); rerender(); expect(result.current).toBe(first); // memoized, not a fresh object }); it("store identity changes when the sessions list changes", async () => { seedSettings(); let list: SessionListItem[] = []; mockFetchJson(() => list); const push = vi.fn(); const { result } = renderHook(() => useSessions(push, true)); await waitFor(() => expect(result.current).not.toBeNull()); const first = result.current; const sock = FakeWebSocket.last(); act(() => sock.serverOpen()); list = [ { id: "s9", name: "x", online: true, busy: false, } as SessionListItem, ]; await act(async () => { await result.current?.refresh(); }); expect(result.current).not.toBe(first); expect(result.current?.sessions.map((s) => s.id)).toEqual(["s9"]); }); }); describe("settings persistence used by the store", () => { it("saved settings are visible", () => { saveSettings({ serverUrl: "http://a", token: "t" }); expect(getSettings()?.serverUrl).toBe("http://a"); }); });