e7e525df49
- useTimelineDrag: onProjectBarMouseDown and single-alloc drag path now reset multiSelectRef + multiSelectState before starting a new drag, so the FloatingActionBar is dismissed immediately when an unrelated drag begins - FloatingActionBar.test.tsx: 4 regression tests for the null-render guard (count=0) and all three label variants - useTimelineSSE.test.ts: 2 new tests — tab hides during pending reconnect timer (clears timer, resyncs on next open) and first-ever connection fails before any open (retry open still resyncs correctly) - assistant-tools-user-admin-inventory-read.test.ts: add isActive to expected findMany select shape (already in production, test was stale) Co-Authored-By: claude-flow <ruv@ruv.net>
298 lines
8.6 KiB
TypeScript
298 lines
8.6 KiB
TypeScript
import { SSE_EVENT_TYPES } from "@capakraken/shared";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { getTimelineSseResyncKeys } from "./timelineSsePolicy.js";
|
|
|
|
const invalidateQueries = vi.fn();
|
|
const effectCleanups: Array<() => void> = [];
|
|
|
|
vi.mock("@tanstack/react-query", () => ({
|
|
useQueryClient: () => ({
|
|
invalidateQueries,
|
|
}),
|
|
}));
|
|
|
|
vi.mock("react", async () => {
|
|
const actual = await vi.importActual<typeof import("react")>("react");
|
|
return {
|
|
...actual,
|
|
useEffect: (callback: () => void | (() => void)) => {
|
|
const cleanup = callback();
|
|
if (typeof cleanup === "function") {
|
|
effectCleanups.push(cleanup);
|
|
}
|
|
},
|
|
useRef: <T,>(value: T) => ({ current: value }),
|
|
};
|
|
});
|
|
|
|
import { useTimelineSSE } from "./useTimelineSSE.js";
|
|
|
|
class MockEventSource {
|
|
static instances: MockEventSource[] = [];
|
|
|
|
onopen: (() => void) | null = null;
|
|
onmessage: ((event: MessageEvent) => void) | null = null;
|
|
onerror: (() => void) | null = null;
|
|
close = vi.fn();
|
|
|
|
constructor(public readonly url: string) {
|
|
MockEventSource.instances.push(this);
|
|
}
|
|
|
|
emitOpen() {
|
|
this.onopen?.();
|
|
}
|
|
|
|
emitMessage(data: string) {
|
|
this.onmessage?.({ data } as MessageEvent);
|
|
}
|
|
|
|
emitError() {
|
|
this.onerror?.();
|
|
}
|
|
}
|
|
|
|
type VisibilityHandler = () => void;
|
|
|
|
class MockVisibilityDocument {
|
|
visibilityState: "visible" | "hidden" = "visible";
|
|
private readonly listeners = new Set<VisibilityHandler>();
|
|
|
|
addEventListener(type: string, listener: VisibilityHandler) {
|
|
if (type === "visibilitychange") {
|
|
this.listeners.add(listener);
|
|
}
|
|
}
|
|
|
|
removeEventListener(type: string, listener: VisibilityHandler) {
|
|
if (type === "visibilitychange") {
|
|
this.listeners.delete(listener);
|
|
}
|
|
}
|
|
|
|
setVisibility(state: "visible" | "hidden") {
|
|
this.visibilityState = state;
|
|
for (const listener of this.listeners) {
|
|
listener();
|
|
}
|
|
}
|
|
}
|
|
|
|
describe("useTimelineSSE", () => {
|
|
let mockDocument: MockVisibilityDocument;
|
|
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
vi.clearAllMocks();
|
|
effectCleanups.length = 0;
|
|
MockEventSource.instances.length = 0;
|
|
mockDocument = new MockVisibilityDocument();
|
|
vi.stubGlobal("EventSource", MockEventSource);
|
|
vi.stubGlobal("document", mockDocument);
|
|
});
|
|
|
|
afterEach(() => {
|
|
while (effectCleanups.length > 0) {
|
|
effectCleanups.pop()?.();
|
|
}
|
|
vi.unstubAllGlobals();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("ignores malformed SSE payloads without invalidating queries", () => {
|
|
useTimelineSSE();
|
|
|
|
expect(MockEventSource.instances).toHaveLength(1);
|
|
|
|
MockEventSource.instances[0]?.emitMessage("{not-json");
|
|
|
|
expect(invalidateQueries).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not invalidate queries for ping messages", () => {
|
|
useTimelineSSE();
|
|
|
|
expect(MockEventSource.instances).toHaveLength(1);
|
|
|
|
MockEventSource.instances[0]?.emitOpen();
|
|
|
|
MockEventSource.instances[0]?.emitMessage(JSON.stringify({ type: SSE_EVENT_TYPES.PING }));
|
|
|
|
expect(invalidateQueries).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not resync on the initial successful SSE connection", () => {
|
|
useTimelineSSE();
|
|
|
|
expect(MockEventSource.instances).toHaveLength(1);
|
|
|
|
MockEventSource.instances[0]?.emitOpen();
|
|
|
|
expect(invalidateQueries).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("resets reconnect backoff after ping and reconnects only once per pending timer", () => {
|
|
useTimelineSSE();
|
|
|
|
const firstConnection = MockEventSource.instances[0];
|
|
expect(firstConnection).toBeDefined();
|
|
|
|
firstConnection?.emitError();
|
|
firstConnection?.emitError();
|
|
expect(firstConnection?.close).toHaveBeenCalledTimes(1);
|
|
|
|
vi.advanceTimersByTime(1999);
|
|
expect(MockEventSource.instances).toHaveLength(1);
|
|
|
|
vi.advanceTimersByTime(1);
|
|
expect(MockEventSource.instances).toHaveLength(2);
|
|
|
|
const secondConnection = MockEventSource.instances[1];
|
|
secondConnection?.emitMessage(JSON.stringify({ type: SSE_EVENT_TYPES.PING }));
|
|
secondConnection?.emitError();
|
|
|
|
vi.advanceTimersByTime(1999);
|
|
expect(MockEventSource.instances).toHaveLength(2);
|
|
|
|
vi.advanceTimersByTime(1);
|
|
expect(MockEventSource.instances).toHaveLength(3);
|
|
|
|
expect(invalidateQueries).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("resyncs timeline queries exactly once after a successful reconnect", () => {
|
|
useTimelineSSE();
|
|
|
|
const firstConnection = MockEventSource.instances[0];
|
|
expect(firstConnection).toBeDefined();
|
|
|
|
firstConnection?.emitOpen();
|
|
firstConnection?.emitError();
|
|
|
|
vi.advanceTimersByTime(2_000);
|
|
|
|
expect(MockEventSource.instances).toHaveLength(2);
|
|
|
|
const secondConnection = MockEventSource.instances[1];
|
|
expect(secondConnection).toBeDefined();
|
|
|
|
secondConnection?.emitOpen();
|
|
secondConnection?.emitOpen();
|
|
|
|
expect(invalidateQueries).toHaveBeenCalledTimes(getTimelineSseResyncKeys().length);
|
|
expect(invalidateQueries.mock.calls).toEqual(
|
|
getTimelineSseResyncKeys().map((queryKey) => [{ queryKey }]),
|
|
);
|
|
});
|
|
|
|
it("clears a pending reconnect when the hook is disposed", () => {
|
|
useTimelineSSE();
|
|
|
|
const firstConnection = MockEventSource.instances[0];
|
|
expect(firstConnection).toBeDefined();
|
|
|
|
firstConnection?.emitError();
|
|
|
|
while (effectCleanups.length > 0) {
|
|
effectCleanups.pop()?.();
|
|
}
|
|
|
|
vi.advanceTimersByTime(30_000);
|
|
|
|
expect(MockEventSource.instances).toHaveLength(1);
|
|
expect(firstConnection?.close).toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not connect while the page is hidden on mount, then connects when visible", () => {
|
|
mockDocument.visibilityState = "hidden";
|
|
|
|
useTimelineSSE();
|
|
|
|
expect(MockEventSource.instances).toHaveLength(0);
|
|
|
|
mockDocument.setVisibility("visible");
|
|
|
|
expect(MockEventSource.instances).toHaveLength(1);
|
|
expect(invalidateQueries).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("closes the active connection while hidden and resyncs once when visible again", () => {
|
|
useTimelineSSE();
|
|
|
|
const firstConnection = MockEventSource.instances[0];
|
|
expect(firstConnection).toBeDefined();
|
|
|
|
firstConnection?.emitOpen();
|
|
mockDocument.setVisibility("hidden");
|
|
|
|
expect(firstConnection?.close).toHaveBeenCalledTimes(1);
|
|
expect(MockEventSource.instances).toHaveLength(1);
|
|
|
|
mockDocument.setVisibility("visible");
|
|
|
|
expect(MockEventSource.instances).toHaveLength(2);
|
|
|
|
const secondConnection = MockEventSource.instances[1];
|
|
secondConnection?.emitOpen();
|
|
|
|
expect(invalidateQueries).toHaveBeenCalledTimes(getTimelineSseResyncKeys().length);
|
|
expect(invalidateQueries.mock.calls).toEqual(
|
|
getTimelineSseResyncKeys().map((queryKey) => [{ queryKey }]),
|
|
);
|
|
});
|
|
|
|
it("hides during pending reconnect timer — clears timer and resyncs on next open", () => {
|
|
useTimelineSSE();
|
|
|
|
const firstConnection = MockEventSource.instances[0];
|
|
expect(firstConnection).toBeDefined();
|
|
|
|
// Establish connection, then break it — schedules a 2s reconnect timer
|
|
firstConnection?.emitOpen();
|
|
firstConnection?.emitError(); // shouldResyncOnOpen = true, timer scheduled
|
|
|
|
// Tab hides while timer is still pending (no active `es`)
|
|
mockDocument.setVisibility("hidden"); // clears timer, shouldResyncOnOpen stays true
|
|
|
|
// No timer should fire — still only one EventSource
|
|
vi.advanceTimersByTime(5_000);
|
|
expect(MockEventSource.instances).toHaveLength(1);
|
|
|
|
// Tab becomes visible — should connect fresh
|
|
mockDocument.setVisibility("visible");
|
|
expect(MockEventSource.instances).toHaveLength(2);
|
|
|
|
// Successful open on the new connection → resync
|
|
const secondConnection = MockEventSource.instances[1];
|
|
secondConnection?.emitOpen();
|
|
|
|
expect(invalidateQueries).toHaveBeenCalledTimes(getTimelineSseResyncKeys().length);
|
|
expect(invalidateQueries.mock.calls).toEqual(
|
|
getTimelineSseResyncKeys().map((queryKey) => [{ queryKey }]),
|
|
);
|
|
});
|
|
|
|
it("resyncs after the first-ever connection attempt fails before any open", () => {
|
|
useTimelineSSE();
|
|
|
|
const firstConnection = MockEventSource.instances[0];
|
|
expect(firstConnection).toBeDefined();
|
|
|
|
// Error fires without any preceding open — shouldResyncOnOpen becomes true
|
|
firstConnection?.emitError();
|
|
|
|
// Advance past the 2s backoff
|
|
vi.advanceTimersByTime(2_000);
|
|
expect(MockEventSource.instances).toHaveLength(2);
|
|
|
|
// Retry succeeds
|
|
const secondConnection = MockEventSource.instances[1];
|
|
secondConnection?.emitOpen();
|
|
|
|
expect(invalidateQueries).toHaveBeenCalledTimes(getTimelineSseResyncKeys().length);
|
|
expect(invalidateQueries.mock.calls).toEqual(
|
|
getTimelineSseResyncKeys().map((queryKey) => [{ queryKey }]),
|
|
);
|
|
});
|
|
});
|