map: tickets 06+07 resolved; 05 partial (live spawn pending gitea token)

This commit is contained in:
Raphael Westphal
2026-08-18 15:33:02 +02:00
parent 1a267bf489
commit f8bfaeaa21
18 changed files with 2623 additions and 1858 deletions
+171 -126
View File
@@ -2,167 +2,212 @@ 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, mockFetchJson, seedSettings, stubReload } from "./test/setup";
import {
FakeWebSocket,
mockFetchJson,
seedSettings,
stubReload,
} from "./test/setup";
import type { EventFrame } from "./protocol";
const NOW = new Date("2024-05-01T12:00:00Z").getTime();
describe("relativeTime", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(NOW);
});
afterEach(() => {
vi.useRealTimers();
});
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");
});
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("");
});
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();
});
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"]);
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(() => result.current.push("second"));
expect(result.current.toasts).toHaveLength(2);
act(() => {
vi.advanceTimersByTime(4000);
});
expect(result.current.toasts).toHaveLength(0);
});
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" };
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("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 = [{ id: "s1", name: "one", online: true }];
const fetchMock = mockFetchJson((url) => {
if (url.includes("/api/sessions")) return url.includes("/events") ? [] : sessions;
return [];
});
const push = vi.fn();
const { result } = renderHook(() => useSessions(push));
it("seeds via REST, updates from session_list and spawn_status frames, refresh works", async () => {
seedSettings();
const sessions = [{ id: "s1", name: "one", online: true }];
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));
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");
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: "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" }]);
act(() =>
sock.serverMessage({
type: "spawn_status",
jobs: [{ repo: "g/p", state: "cloning" }],
}),
);
expect(result.current?.spawnJobs).toEqual([
{ repo: "g/p", state: "cloning" },
]);
await act(async () => {
await result.current?.refresh();
});
expect(fetchMock).toHaveBeenCalled();
});
await act(async () => {
await result.current?.refresh();
});
expect(fetchMock).toHaveBeenCalled();
});
it("refresh failure pushes a toast", 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();
});
it("refresh failure pushes a toast", 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();
});
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());
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" }));
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(() =>
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" }));
});
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("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("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("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();
});
});
describe("settings persistence used by the store", () => {
it("saved settings are visible", () => {
saveSettings({ serverUrl: "http://a", token: "t" });
expect(getSettings()?.serverUrl).toBe("http://a");
});
it("saved settings are visible", () => {
saveSettings({ serverUrl: "http://a", token: "t" });
expect(getSettings()?.serverUrl).toBe("http://a");
});
});