438 lines
12 KiB
TypeScript
438 lines
12 KiB
TypeScript
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<void> => {
|
|
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");
|
|
});
|
|
});
|