import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useState, type ReactNode } from "react";
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { useSearchParams } from "react-router-dom";
import { App } from "../src/App";
import { useRegionSelection } from "../src/hooks/useRegion";
import type { PacketDetail } from "../src/types/api";
vi.mock("../src/api/ws-manager", () => ({ WsManager: class {
connect() {} disconnect() {} updateSubscription() {}
} }));
vi.mock("../src/api/client", () => ({ getRegions: async () => [], getRegion: async () => ({ iatas: [] }), getBrokers: async () => [], getScopes: async () => [] }));
vi.mock("../src/components/SplashScreen", () => ({ SplashScreen: () => null }));
vi.mock("../src/components/AppShell", () => ({ AppShell: ({ children, onTabChange }: { children: ReactNode; onTabChange: (tab: string) => void }) => {
const { setSelection } = useRegionSelection();
return <>{children}>;
} }));
vi.mock("../src/features/routes/RouteTable", () => ({ RouteTable: ({ onViewObserver, onAnalyzePacket, onViewNode }: { onViewObserver: (id: string) => void; onAnalyzePacket: (hash: string, id: number) => void; onViewNode: (id: string) => void }) => {
const [filter, setFilter] = useState(""); const [params] = useSearchParams();
return
;
} }));
vi.mock("../src/features/observers/ObserverSidebar", () => ({ ObserverSidebar: () => Observer directory
}));
vi.mock("../src/features/observers/useObserverDirectory", () => ({ useObserverDirectory: () => ({ observers: [], observerTypes: [], isPending: false, isError: false, unsupported: false, hasNextPage: false, isFetchingNextPage: false, maxObservationCount: null, resetKey: "test", retry: () => {}, refresh: () => {}, loadMore: () => {} }) }));
vi.mock("../src/features/stats/MeshTab", () => ({ MeshTab: ({ onSelectObserver }: { onSelectObserver: (id: string) => void }) => {
const [value, setValue] = useState("");
return <> setValue(e.target.value)} />>;
} }));
vi.mock("../src/features/stats/ObserverTab", () => ({ ObserverTab: ({ selectedObserverId, range, actions }: { selectedObserverId: string; range: string; actions?: ReactNode }) => <>Dashboard {selectedObserverId} {range}
{actions}> }));
// Exercise the real analytics shell without loading charts unrelated to its observer action.
vi.mock("../src/features/stats/TrafficTab", () => ({ TrafficTab: () => null }));
vi.mock("../src/features/stats/SignalTab", () => ({ SignalTab: () => null }));
vi.mock("../src/features/stats/PathsTab", () => ({ PathsTab: () => null }));
vi.mock("../src/features/stats/ScopesTab", () => ({ ScopesTab: () => null }));
vi.mock("../src/features/stats/TalkersTab", () => ({ TalkersTab: () => null }));
vi.mock("../src/features/stats/ClockDriftTab", () => ({ ClockDriftTab: () => null }));
vi.mock("../src/features/stats/CompareObserversTab", () => ({ CompareObserversTab: () => null }));
vi.mock("../src/features/stats/NeighbourGraphTab", () => ({ NeighbourGraphTab: () => null }));
vi.mock("../src/features/observers/ObserverAdverts", () => ({ ObserverAdverts: ({ onAnalyzePacket }: { onAnalyzePacket: (hash: string, id: number) => void }) => }));
vi.mock("../src/features/nodes/NodeDetailPanel", () => ({ NodeDetailPanel: ({ nodeId, onClose, onViewObserver }: { nodeId: string; onClose: () => void; onViewObserver: (id: string) => void }) => <>Node {nodeId}
> }));
vi.mock("../src/features/packets/usePacketDetail", () => ({ usePacketDetail: (hash: string | null) => ({ data: hash ? { packetHash: hash, observations: [], header: { payloadType: 1 } } as unknown as PacketDetail : undefined, isLoading: false }) }));
vi.mock("../src/features/packets/PacketAnalyzerDrawer", () => ({ PacketAnalyzerDrawer: ({ detail, onClose, onViewObserver, onViewNode, onViewPath, selectedObservationId, onSelectObservation }: { detail: PacketDetail; onClose: () => void; onViewObserver: (id: string) => void; onViewNode: (id: string) => void; onViewPath: () => void; selectedObservationId: number | null; onSelectObservation: (id: number) => void }) => <>Packet {detail?.packetHash} report {selectedObservationId}
> }));
vi.mock("../src/features/map/PacketPathMap", () => ({ PacketPathMap: () => Path map
}));
beforeEach(() => {
window.history.replaceState({}, "", "/?tab=Routes&route=full-route&routeIata=YOW");
vi.stubGlobal("localStorage", { getItem: () => null, setItem: () => {}, removeItem: () => {} });
});
afterEach(() => vi.unstubAllGlobals());
const click = (name: string) => { const button = screen.getByRole("button", { name, exact: true }); button.focus(); fireEvent.click(button); };
const escape = () => fireEvent.keyDown(screen.getByRole("dialog"), { key: "Escape" });
const browserBack = async () => { await act(async () => { window.history.back(); }); await waitFor(() => expect(window.location.search).toContain("tab=Routes")); };
describe("observer investigation windows", () => {
const dashboard = async (id = "o1") => {
expect(await screen.findByRole("heading", { level: 1 })).toHaveTextContent(`Dashboard ${id}`);
const params = new URLSearchParams(window.location.search);
expect(params.get("tab")).toBe("Observers"); expect(params.get("observer")).toBe(id);
};
it("opens the dashboard straight from the route panel", async () => {
render(); click("Route observer");
await dashboard();
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
it("moves a stacked packet into the side drawer beside the dashboard, keeping its report", async () => {
render(); click("Route packet"); click("Select report 8"); click("Packet observer");
await dashboard();
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
expect(screen.getByRole("heading", { name: /^Packet / })).toHaveTextContent("Packet aa");
const params = new URLSearchParams(window.location.search);
expect(params.get("hash")).toBe("aa"); expect(params.get("analyze")).toBe("1"); expect(params.get("observation")).toBe("8");
});
it("keeps the side drawer open when its observer is inspected", async () => {
window.history.replaceState({}, "", "/?tab=Routes&hash=aa&analyze=1");
render(); click("Packet observer");
await dashboard();
expect(screen.getByRole("heading", { name: /^Packet / })).toHaveTextContent("Packet aa");
});
it("closes stacked node windows when going to the dashboard", async () => {
render(); click("Route packet"); click("Packet node"); click("Node observer");
await dashboard();
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
it("opens an advert from the dashboard in the side drawer", async () => {
window.history.replaceState({}, "", "/?tab=Observers&observer=o1");
render(); click("Advert packet");
expect(await screen.findByRole("heading", { name: /^Packet / })).toHaveTextContent("Packet bb");
expect(new URLSearchParams(window.location.search).get("observation")).toBe("3");
});
it("keeps the node and packet stack when Escape is pressed", () => {
render(); click("Route packet"); click("Packet node");
escape(); expect(within(screen.getByRole("dialog")).getByRole("heading")).toHaveTextContent("Packet aa");
click("Packet map"); escape();
expect(within(screen.getByRole("dialog")).getByRole("heading")).toHaveTextContent("Packet aa");
});
it("returns to the previous tab with browser Back", async () => {
render(); click("Route observer");
await screen.findByRole("heading", { level: 1 });
await browserBack(); expect(screen.getByTestId("route-origin")).toBeInTheDocument();
});
it("keeps the packet drawer and stacked windows across tabs, but not into Analytics", async () => {
await import("../src/features/stats/StatsOverview");
window.history.replaceState({}, "", "/?tab=Routes&hash=aa&analyze=1");
render();
expect(screen.getByRole("heading", { name: /Packet aa/ })).toBeInTheDocument();
click("Packet node");
click("Observer tab");
expect(await screen.findByRole("heading", { level: 2, name: /Packet aa/ })).toBeInTheDocument();
expect(within(screen.getByRole("dialog")).getByRole("heading")).toHaveTextContent("Node n1");
click("Analytics tab");
await screen.findByRole("button", { name: "Leaderboard observer" });
expect(screen.queryByRole("heading", { name: /Packet aa/ })).not.toBeInTheDocument();
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
click("Route tab");
expect(await screen.findByRole("heading", { name: /Packet aa/ })).toBeInTheDocument();
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
it("opens the Observers dashboard from the leaderboard", async () => {
await import("../src/features/stats/StatsOverview");
window.history.replaceState({}, "", "/?tab=Analytics&statsTab=mesh&range=24h");
render();
await screen.findByRole("button", { name: "Leaderboard observer" });
click("Leaderboard observer");
expect(await screen.findByRole("heading", { level: 1 })).toHaveTextContent("Dashboard o1 24h");
expect(window.location.search).toContain("tab=Observers");
expect(window.location.search).toContain("observer=o1");
expect(window.location.search).not.toContain("statsTab");
});
it("redirects legacy Analytics observer links to the Observers dashboard", async () => {
window.history.replaceState({}, "", "/?tab=Analytics&statsTab=observer&observerId=o2&range=7d");
render();
expect(await screen.findByRole("heading", { level: 1 })).toHaveTextContent("Dashboard o2 7d");
expect(window.location.search).toContain("tab=Observers");
expect(window.location.search).not.toContain("statsTab");
});
it("closes the stacked windows when the region changes", async () => {
render(); click("Route packet"); click("Packet node");
click("Change region");
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
});
it("clears comparison params from both the observer dashboard and analytics compare tabs on tab change", () => {
window.history.replaceState({}, "", "/?tab=Observers&observer=o1&compareWith=o2&compareUntil=1700000000000&compareA=x&compareB=y&compareSince=1");
render();
click("Route tab");
expect(window.location.search).toContain("tab=Routes");
for (const key of ["observer", "compareWith", "compareUntil", "compareA", "compareB", "compareSince"]) {
expect(window.location.search).not.toContain(key);
}
});
it("keeps analytics compare params when re-clicking the already-active Analytics tab", async () => {
await import("../src/features/stats/StatsOverview");
window.history.replaceState({}, "", "/?tab=Analytics&statsTab=compare&compareA=a&compareB=b&compareSince=1&compareUntil=2");
render();
click("Analytics tab");
for (const pair of ["compareA=a", "compareB=b", "compareSince=1", "compareUntil=2"]) {
expect(window.location.search).toContain(pair);
}
});
it("keeps the observer dashboard's compare params when re-clicking the already-active Observer tab", () => {
window.history.replaceState({}, "", "/?tab=Observers&observer=o1&compareWith=o2&compareUntil=2");
render();
click("Observer tab");
for (const pair of ["observer=o1", "compareWith=o2", "compareUntil=2"]) {
expect(window.location.search).toContain(pair);
}
});
});
describe("observer investigation on mobile", () => {
beforeEach(() => vi.stubGlobal("matchMedia", (query: string) => ({ matches: /max-width/.test(query), media: query, addEventListener: () => {}, removeEventListener: () => {}, addListener: () => {}, removeListener: () => {} })));
it("closes an inspected observer back to the packet it came from", async () => {
render(); click("Route packet"); click("Select report 8"); click("Packet observer");
expect(await screen.findByRole("heading", { level: 1 })).toHaveTextContent("Dashboard o1");
expect(screen.queryByRole("heading", { name: /^Packet / })).not.toBeInTheDocument();
click("Back to observers");
await waitFor(() => expect(new URLSearchParams(window.location.search).get("tab")).toBe("Routes"));
expect(await screen.findByRole("heading", { name: /^Packet / })).toHaveTextContent("Packet aa");
expect(new URLSearchParams(window.location.search).get("observation")).toBe("8");
});
it("closes a dashboard opened from the list back to the list", async () => {
window.history.replaceState({}, "", "/?tab=Observers&observer=o1");
render();
await screen.findByRole("heading", { level: 1 });
click("Back to observers");
await waitFor(() => expect(screen.queryByRole("heading", { level: 1 })).not.toBeInTheDocument());
expect(new URLSearchParams(window.location.search).get("tab")).toBe("Observers");
});
});