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:
+189
-4
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user