Files
beacon-web/tests/App.observerInvestigation.test.tsx

189 lines
14 KiB
TypeScript

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/VersionGate", () => ({ VersionGate: () => null }));
vi.mock("../src/components/AppShell", () => ({ AppShell: ({ children, onTabChange }: { children: ReactNode; onTabChange: (tab: string) => void }) => {
const { setSelection } = useRegionSelection();
return <><button onClick={() => onTabChange("Observers")}>Observer tab</button><button onClick={() => onTabChange("Routes")}>Route tab</button><button onClick={() => onTabChange("Analytics")}>Analytics tab</button><button onClick={() => setSelection({ regions: [], iatas: ["YVR"] })}>Change region</button>{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 <div data-testid="route-origin"><input aria-label="Route filter" value={filter} onChange={e => setFilter(e.target.value)} /><output data-testid="origin-url">{params.toString()}</output><div data-testid="route-scroll" style={{ height: 80, overflow: "auto" }}><div style={{ height: 1000 }}>Routes</div></div><button onClick={() => onViewObserver("o1")}>Route observer</button><button onClick={() => onAnalyzePacket("aa", 7)}>Route packet</button><button onClick={() => onViewNode("n1")}>Route node</button></div>;
} }));
vi.mock("../src/features/observers/ObserverSidebar", () => ({ ObserverSidebar: () => <p>Observer directory</p> }));
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 <><input aria-label="Analytics local state" value={value} onChange={e => setValue(e.target.value)} /><button onClick={() => onSelectObserver("o1")}>Leaderboard observer</button></>;
} }));
vi.mock("../src/features/stats/ObserverTab", () => ({ ObserverTab: ({ selectedObserverId, range, actions }: { selectedObserverId: string; range: string; actions?: ReactNode }) => <><h1>Dashboard {selectedObserverId} {range}</h1>{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 }) => <button onClick={() => onAnalyzePacket("bb", 3)}>Advert packet</button> }));
vi.mock("../src/features/nodes/NodeDetailPanel", () => ({ NodeDetailPanel: ({ nodeId, onClose, onViewObserver }: { nodeId: string; onClose: () => void; onViewObserver: (id: string) => void }) => <><h2>Node {nodeId}</h2><button onClick={onClose}>Close node</button><button onClick={() => onViewObserver("o1")}>Node observer</button></> }));
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 }) => <><h2>Packet {detail?.packetHash} report {selectedObservationId}</h2><button onClick={onClose}>Close packet</button><button onClick={() => onViewObserver("o1")}>Packet observer</button><button onClick={() => onViewNode("n1")}>Packet node</button><button onClick={onViewPath}>Packet map</button><button onClick={() => onSelectObservation(8)}>Select report 8</button></> }));
vi.mock("../src/features/map/PacketPathMap", () => ({ PacketPathMap: () => <div>Path map</div> }));
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(<App />); 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(<App />); 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(<App />); 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(<App />); 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(<App />); 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(<App />); 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(<App />); 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(<App />);
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(<App />);
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(<App />);
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(<App />); 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(<App />);
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(<App />);
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(<App />);
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(<App />); 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(<App />);
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");
});
});