import { act, render, renderHook, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import type { ReactNode } from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { CommandProvider } from "@/context/command-provider"; import { SortProvider } from "@/context/sort-provider"; import { StatusProvider } from "@/context/status-provider"; import { TooltipProvider } from "@/context/tooltip-provider"; import { WebSocketProvider } from "@/context/websocket-provider"; import { useCommand } from "@/hooks/use-command"; import { useSort } from "@/hooks/use-sort"; import { useStatus } from "@/hooks/use-status"; import { useTooltip } from "@/hooks/use-tooltip"; import { useWebSocketContext } from "@/hooks/use-websocket-context"; import { createServer } from "@/test/fixtures"; class FakeWebSocket { static readonly CONNECTING = 0; static readonly OPEN = 1; static readonly CLOSING = 2; static readonly CLOSED = 3; static instances: FakeWebSocket[] = []; readonly url: string; readyState = FakeWebSocket.CONNECTING; onopen: ((event: Event) => void) | null = null; onclose: ((event: CloseEvent) => void) | null = null; onmessage: ((event: MessageEvent) => void) | null = null; onerror: ((event: Event) => void) | null = null; constructor(url: string) { this.url = url; FakeWebSocket.instances.push(this); } open() { this.readyState = FakeWebSocket.OPEN; this.onopen?.(new Event("open")); } message(data: string) { this.onmessage?.(new MessageEvent("message", { data })); } close() { this.readyState = FakeWebSocket.CLOSED; this.onclose?.(new CloseEvent("close")); } } function CommandProbe() { const { closeCommand, isOpen, openCommand, toggleCommand } = useCommand(); return (

{isOpen ? "open" : "closed"}

); } function SortProbe() { const { setSortOrder, setSortType, sortOrder, sortType } = useSort(); return (

{`${sortType}:${sortOrder}`}

); } function StatusProbe() { const { setStatus, status } = useStatus(); return (

{status}

); } function TooltipProbe() { const { setTooltipData, tooltipData } = useTooltip(); return (

{tooltipData?.country ?? "empty"}

); } function WebSocketProbe() { const { connected, lastData, messageHistory, needReconnect, setNeedReconnect, } = useWebSocketContext(); return (

{connected ? "connected" : "disconnected"}

{lastData?.servers[0]?.name ?? "none"}

{messageHistory.length}

{messageHistory.reduce((total, item) => total + item.servers.length, 0)}

{needReconnect ? "needs-reconnect" : "stable"}

); } describe("state providers", () => { it("manages command palette open state", async () => { const user = userEvent.setup(); render( , ); expect(screen.getByTestId("command-state")).toHaveTextContent("closed"); await user.click(screen.getByRole("button", { name: "open" })); expect(screen.getByTestId("command-state")).toHaveTextContent("open"); await user.click(screen.getByRole("button", { name: "toggle" })); expect(screen.getByTestId("command-state")).toHaveTextContent("closed"); await user.click(screen.getByRole("button", { name: "open" })); await user.click(screen.getByRole("button", { name: "close" })); expect(screen.getByTestId("command-state")).toHaveTextContent("closed"); }); it("uses forced sort globals when valid and still allows local updates", async () => { window.ForceSortType = "mem"; window.ForceSortOrder = "asc"; const user = userEvent.setup(); render( , ); expect(screen.getByText("mem:asc")).toBeInTheDocument(); await user.click(screen.getByRole("button", { name: "cpu" })); expect(screen.getByText("cpu:asc")).toBeInTheDocument(); }); it("falls back to default sort values for invalid forced globals", () => { window.ForceSortType = "invalid"; window.ForceSortOrder = "up"; render( , ); expect(screen.getByText("default:desc")).toBeInTheDocument(); }); it("manages server status filters", async () => { const user = userEvent.setup(); render( , ); expect(screen.getByTestId("status-state")).toHaveTextContent("all"); await user.click(screen.getByRole("button", { name: "online" })); expect(screen.getByTestId("status-state")).toHaveTextContent("online"); }); it("stores map tooltip data", async () => { const user = userEvent.setup(); render( , ); expect(screen.getByText("empty")).toBeInTheDocument(); await user.click(screen.getByRole("button", { name: "show" })); expect(screen.getByText("China")).toBeInTheDocument(); }); }); describe("context hooks", () => { it("throws helpful errors when strict hooks miss their providers", () => { expect(() => renderHook(() => useCommand())).toThrow( "useCommand must be used within a CommandProvider", ); expect(() => renderHook(() => useSort())).toThrow( "useStatus must be used within a SortProvider", ); expect(() => renderHook(() => useStatus())).toThrow( "useStatus must be used within a StatusProvider", ); expect(() => renderHook(() => useTooltip())).toThrow( "useTooltip must be used within a TooltipProvider", ); }); }); describe("WebSocketProvider", () => { beforeEach(() => { FakeWebSocket.instances = []; vi.stubGlobal("WebSocket", FakeWebSocket); }); function renderWebSocketProvider(children: ReactNode) { return render( {children}, ); } function websocketPayload(serverName: string) { return JSON.stringify({ now: Date.parse("2025-01-01T00:00:20.000Z"), servers: [createServer({ name: serverName })], }); } it("connects with a ws URL and records incoming messages", () => { renderWebSocketProvider(); const socket = FakeWebSocket.instances[0]; expect(socket.url).toBe("wss://localhost/api/v1/ws/server"); act(() => { socket.open(); }); expect(screen.getByText("connected")).toBeInTheDocument(); act(() => { socket.message(websocketPayload("first")); socket.message(websocketPayload("second")); }); expect(screen.getByText("second")).toBeInTheDocument(); expect(screen.getByTestId("message-count")).toHaveTextContent("2"); }); it("normalizes missing and non-array server collections", () => { renderWebSocketProvider(); const socket = FakeWebSocket.instances[0]; const now = Date.parse("2025-01-01T00:00:20.000Z"); act(() => { socket.open(); socket.message(JSON.stringify({ now })); socket.message(JSON.stringify({ now, servers: null })); socket.message(JSON.stringify({ now, servers: { invalid: true } })); }); expect(screen.getByText("none")).toBeInTheDocument(); expect(screen.getByTestId("message-count")).toHaveTextContent("3"); expect(screen.getByTestId("history-server-count")).toHaveTextContent("0"); }); it("keeps only the latest thirty websocket messages", () => { renderWebSocketProvider(); const socket = FakeWebSocket.instances[0]; act(() => { socket.open(); for (let index = 0; index < 31; index += 1) { socket.message(websocketPayload(`message-${index}`)); } }); expect(screen.getByText("message-30")).toBeInTheDocument(); expect(screen.getByTestId("message-count")).toHaveTextContent("30"); }); it("ignores malformed websocket messages without disconnecting", () => { const consoleError = vi .spyOn(console, "error") .mockImplementation(() => undefined); renderWebSocketProvider(); const socket = FakeWebSocket.instances[0]; act(() => { socket.open(); socket.message("not-json"); }); expect(screen.getByText("connected")).toBeInTheDocument(); expect(screen.getByText("none")).toBeInTheDocument(); expect(screen.getByTestId("message-count")).toHaveTextContent("0"); expect(consoleError).toHaveBeenCalledWith( "Failed to parse WebSocket message:", expect.any(SyntaxError), ); }); it("ignores websocket messages without a valid response shape", () => { const consoleError = vi .spyOn(console, "error") .mockImplementation(() => undefined); renderWebSocketProvider(); const socket = FakeWebSocket.instances[0]; act(() => { socket.open(); socket.message("null"); socket.message(JSON.stringify({ servers: [] })); }); expect(screen.getByTestId("message-count")).toHaveTextContent("0"); expect(consoleError).toHaveBeenCalledTimes(2); expect(consoleError).toHaveBeenCalledWith( "Failed to parse WebSocket message:", expect.any(TypeError), ); }); it("exposes manual reconnect state separately from socket state", async () => { const user = userEvent.setup(); renderWebSocketProvider(); expect(screen.getByText("stable")).toBeInTheDocument(); await user.click(screen.getByRole("button", { name: "mark" })); expect(screen.getByText("needs-reconnect")).toBeInTheDocument(); }); });