Files
lvmh/web/src/store.test.tsx
T

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");
});
});