Files
fusion/packages/dashboard/app/hooks/__tests__/useBadgeWebSocket.test.ts
gsxdsm 5647e500cf feat(KB-332): rename task prefix from KB to FN and branches from kb/ to fusion/
- Change default task prefix from KB to FN across all packages

- Rename branch naming pattern from kb/{id} to fusion/{id}

- Update all test assertions and fixtures to use new prefixes

- Add changeset for the breaking change

- Resolve merge conflicts in TaskCard.test.tsx touch gesture tests
2026-03-31 19:29:47 -07:00

271 lines
8.1 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, renderHook } from "@testing-library/react";
import { __resetBadgeWebSocketStoreForTests, useBadgeWebSocket } from "../useBadgeWebSocket";
class MockWebSocket {
static CONNECTING = 0;
static OPEN = 1;
static CLOSING = 2;
static CLOSED = 3;
static instances: MockWebSocket[] = [];
url: string;
readyState = MockWebSocket.CONNECTING;
sent: string[] = [];
onopen: ((event: Event) => void) | null = null;
onmessage: ((event: MessageEvent) => void) | null = null;
onclose: ((event: CloseEvent) => void) | null = null;
onerror: ((event: Event) => void) | null = null;
close = vi.fn(() => {
this.readyState = MockWebSocket.CLOSED;
this.onclose?.({ code: 1000 } as CloseEvent);
});
send = vi.fn((payload: string) => {
this.sent.push(payload);
});
constructor(url: string) {
this.url = url;
MockWebSocket.instances.push(this);
}
emitOpen(): void {
this.readyState = MockWebSocket.OPEN;
this.onopen?.(new Event("open"));
}
emitMessage(payload: unknown): void {
this.onmessage?.({ data: JSON.stringify(payload) } as MessageEvent);
}
emitClose(code: number = 1006): void {
this.readyState = MockWebSocket.CLOSED;
this.onclose?.({ code } as CloseEvent);
}
}
describe("useBadgeWebSocket", () => {
const originalWebSocket = globalThis.WebSocket;
beforeEach(() => {
vi.useFakeTimers();
MockWebSocket.instances = [];
__resetBadgeWebSocketStoreForTests();
(globalThis as unknown as { WebSocket: typeof WebSocket }).WebSocket = MockWebSocket as unknown as typeof WebSocket;
});
afterEach(() => {
__resetBadgeWebSocketStoreForTests();
vi.useRealTimers();
(globalThis as unknown as { WebSocket: typeof WebSocket }).WebSocket = originalWebSocket;
});
it("connects when the first badge subscription is added", async () => {
const { result } = renderHook(() => useBadgeWebSocket());
act(() => {
result.current.subscribeToBadge("FN-063");
});
expect(MockWebSocket.instances).toHaveLength(1);
expect(MockWebSocket.instances[0].url).toContain("/api/ws");
act(() => {
MockWebSocket.instances[0].emitOpen();
});
expect(result.current.isConnected).toBe(true);
expect(MockWebSocket.instances[0].sent).toContain(JSON.stringify({ type: "subscribe", taskId: "FN-063" }));
});
it("stores badge update snapshots from the server", async () => {
const { result } = renderHook(() => useBadgeWebSocket());
act(() => {
result.current.subscribeToBadge("FN-063");
MockWebSocket.instances[0].emitOpen();
});
act(() => {
MockWebSocket.instances[0].emitMessage({
type: "badge:updated",
taskId: "FN-063",
prInfo: null,
issueInfo: {
url: "https://github.com/owner/repo/issues/2",
number: 2,
state: "closed",
title: "Tracked issue",
stateReason: "completed",
},
timestamp: "2026-03-30T12:00:00.000Z",
});
});
const update = result.current.badgeUpdates.get("FN-063");
expect(update).toMatchObject({
prInfo: null,
issueInfo: {
number: 2,
stateReason: "completed",
},
});
});
it("preserves existing badge state for partial update payloads", () => {
const { result } = renderHook(() => useBadgeWebSocket());
act(() => {
result.current.subscribeToBadge("FN-063");
MockWebSocket.instances[0].emitOpen();
MockWebSocket.instances[0].emitMessage({
type: "badge:updated",
taskId: "FN-063",
prInfo: {
url: "https://github.com/owner/repo/pull/1",
number: 1,
status: "open",
title: "Tracked PR",
headBranch: "feature/test",
baseBranch: "main",
commentCount: 0,
},
timestamp: "2026-03-30T12:00:00.000Z",
});
MockWebSocket.instances[0].emitMessage({
type: "badge:updated",
taskId: "FN-063",
issueInfo: {
url: "https://github.com/owner/repo/issues/2",
number: 2,
state: "open",
title: "Tracked issue",
},
timestamp: "2026-03-30T12:01:00.000Z",
});
});
expect(result.current.badgeUpdates.get("FN-063")).toMatchObject({
prInfo: { number: 1 },
issueInfo: { number: 2 },
});
});
it("preserves cached badge state and reconnects with exponential backoff after an unexpected close", async () => {
const { result } = renderHook(() => useBadgeWebSocket());
act(() => {
result.current.subscribeToBadge("FN-063");
MockWebSocket.instances[0].emitOpen();
MockWebSocket.instances[0].emitMessage({
type: "badge:updated",
taskId: "FN-063",
prInfo: {
url: "https://github.com/owner/repo/pull/1",
number: 1,
status: "open",
title: "Tracked PR",
headBranch: "feature/test",
baseBranch: "main",
commentCount: 0,
},
timestamp: "2026-03-30T12:00:00.000Z",
});
});
expect(result.current.badgeUpdates.has("FN-063")).toBe(true);
act(() => {
MockWebSocket.instances[0].emitClose(1006);
});
expect(result.current.isConnected).toBe(false);
expect(result.current.badgeUpdates.has("FN-063")).toBe(true);
act(() => {
vi.advanceTimersByTime(1_000);
});
expect(MockWebSocket.instances).toHaveLength(2);
act(() => {
MockWebSocket.instances[1].emitOpen();
});
expect(result.current.isConnected).toBe(true);
expect(MockWebSocket.instances[1].sent).toContain(JSON.stringify({ type: "subscribe", taskId: "FN-063" }));
});
it("sends unsubscribe, clears cached state, and closes the socket when the final subscription is removed", () => {
const { result } = renderHook(() => useBadgeWebSocket());
act(() => {
result.current.subscribeToBadge("FN-063");
MockWebSocket.instances[0].emitOpen();
MockWebSocket.instances[0].emitMessage({
type: "badge:updated",
taskId: "FN-063",
prInfo: {
url: "https://github.com/owner/repo/pull/1",
number: 1,
status: "open",
title: "Tracked PR",
headBranch: "feature/test",
baseBranch: "main",
commentCount: 0,
},
timestamp: "2026-03-30T12:00:00.000Z",
});
});
act(() => {
result.current.unsubscribeFromBadge("FN-063");
});
expect(MockWebSocket.instances[0].sent).toContain(JSON.stringify({ type: "unsubscribe", taskId: "FN-063" }));
expect(MockWebSocket.instances[0].close).toHaveBeenCalled();
expect(result.current.badgeUpdates.has("FN-063")).toBe(false);
});
it("shares a single websocket and ref-counted subscription across hook instances", () => {
const first = renderHook(() => useBadgeWebSocket());
const second = renderHook(() => useBadgeWebSocket());
act(() => {
first.result.current.subscribeToBadge("FN-063");
second.result.current.subscribeToBadge("FN-063");
MockWebSocket.instances[0].emitOpen();
});
expect(MockWebSocket.instances).toHaveLength(1);
expect(
MockWebSocket.instances[0].sent.filter((payload) => payload === JSON.stringify({ type: "subscribe", taskId: "FN-063" })),
).toHaveLength(1);
act(() => {
first.result.current.unsubscribeFromBadge("FN-063");
});
expect(MockWebSocket.instances[0].sent).not.toContain(JSON.stringify({ type: "unsubscribe", taskId: "FN-063" }));
act(() => {
second.result.current.unsubscribeFromBadge("FN-063");
});
expect(MockWebSocket.instances[0].sent).toContain(JSON.stringify({ type: "unsubscribe", taskId: "FN-063" }));
});
it("unsubscribes owned task subscriptions on unmount", () => {
const { result, unmount } = renderHook(() => useBadgeWebSocket());
act(() => {
result.current.subscribeToBadge("FN-063");
MockWebSocket.instances[0].emitOpen();
});
unmount();
expect(MockWebSocket.instances[0].sent).toContain(JSON.stringify({ type: "unsubscribe", taskId: "FN-063" }));
});
});