- 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
271 lines
8.1 KiB
TypeScript
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" }));
|
|
});
|
|
});
|