399 lines
11 KiB
TypeScript
399 lines
11 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, 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("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));
|
|
expect(result.current).toBeNull();
|
|
});
|
|
|
|
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,
|
|
},
|
|
] 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));
|
|
|
|
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));
|
|
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));
|
|
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));
|
|
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));
|
|
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));
|
|
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));
|
|
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));
|
|
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));
|
|
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));
|
|
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,
|
|
} 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");
|
|
});
|
|
});
|