fix(review round 1): daemon PAT-header auth, job pruning, session-split batches, streaming tars, seed cleanup, ping/pong, byte caps, branch switch, slug --, ctx cancel, Init:true, pagination, latest/before events; web newest-window history + load-older, offline busy gate, staleness guards, memo store, auth probe, scroll key, focus-visible, draft restore, poll dedup; 106+181 tests, coverage 96.2%/95.1%+

This commit is contained in:
Raphael Westphal
2026-08-18 18:49:52 +02:00
parent 64e45e1a82
commit 6aac763563
25 changed files with 2564 additions and 959 deletions
+189 -4
View File
@@ -4,11 +4,12 @@ import { getSettings, saveSettings } from "./settings";
import { classNames, relativeTime, useSessions, useToasts } from "./store";
import {
FakeWebSocket,
jsonResponse,
mockFetchJson,
seedSettings,
stubReload,
} from "./test/setup";
import type { EventFrame } from "./protocol";
import type { EventFrame, SessionListItem } from "./protocol";
const NOW = new Date("2024-05-01T12:00:00Z").getTime();
@@ -74,7 +75,13 @@ describe("useSessions", () => {
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 sessions: SessionListItem[] = [
{
id: "s1",
name: "one",
online: true,
},
] as SessionListItem[];
const fetchMock = mockFetchJson((url) => {
if (url.includes("/api/sessions"))
return url.includes("/events") ? [] : sessions;
@@ -108,13 +115,17 @@ describe("useSessions", () => {
{ repo: "g/p", state: "cloning" },
]);
// refresh resolves to the fetched rows (S7) and re-seeds the list
let out: SessionListItem[] | undefined;
await act(async () => {
await result.current?.refresh();
out = await result.current?.refresh();
});
expect(out).toEqual(sessions);
expect(result.current?.sessions).toEqual(sessions);
expect(fetchMock).toHaveBeenCalled();
});
it("refresh failure pushes a toast", async () => {
it("refresh failure pushes a toast and resolves to an empty list", async () => {
seedSettings();
mockFetchJson(() => {
throw new Error("network down");
@@ -125,6 +136,11 @@ describe("useSessions", () => {
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 () => {
@@ -192,6 +208,130 @@ describe("useSessions", () => {
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(() => []);
@@ -203,6 +343,51 @@ describe("useSessions", () => {
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", () => {