test: add unit tests for various components and utilities

- Created tests for formatBytes function to ensure correct formatting of byte values.
- Implemented tests for InjectContext to validate resource injection and cleanup.
- Added tests for logo-class helpers to verify platform alias mappings.
- Developed tests for nezha-api functions to check API response handling.
- Introduced tests for static geographic data to validate country coordinates and GeoJSON structure.
- Created comprehensive tests for utility functions including date formatting and public note parsing.
- Added tests for Server page to ensure correct rendering and functionality.
- Implemented tests for simple pages including error handling and navigation.
- Set up testing environment with Vitest and configured coverage reporting.
This commit is contained in:
hamster1963
2026-06-15 11:29:48 +08:00
parent 5670811991
commit 5720af9108
38 changed files with 5686 additions and 23 deletions
+141
View File
@@ -0,0 +1,141 @@
import { QueryClientProvider } from "@tanstack/react-query";
import { render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import App from "@/App";
import { createTestQueryClient } from "@/test/utils";
const appMocks = vi.hoisted(() => ({
backgroundImage: undefined as string | undefined,
fetchSetting: vi.fn(),
injectContext: vi.fn(),
setTheme: vi.fn(),
}));
vi.mock("../components/DashCommand", () => ({
DashCommand: () => <div>dash-command</div>,
}));
vi.mock("../components/Footer", () => ({
default: () => <footer>footer</footer>,
}));
vi.mock("../components/Header", () => ({
default: () => <header>header</header>,
RefreshToast: () => <div>refresh-toast</div>,
}));
vi.mock("../pages/Server", () => ({
default: () => <div>server-page</div>,
}));
vi.mock("../pages/ServerDetail", () => ({
default: () => <div>server-detail-page</div>,
}));
vi.mock("../hooks/use-background", () => ({
useBackground: () => ({ backgroundImage: appMocks.backgroundImage }),
}));
vi.mock("../hooks/use-theme", () => ({
useTheme: () => ({ setTheme: appMocks.setTheme }),
}));
vi.mock("../lib/inject", () => ({
InjectContext: appMocks.injectContext,
}));
vi.mock("../lib/nezha-api", () => ({
fetchSetting: appMocks.fetchSetting,
}));
function settingResponse(customCode = "") {
return {
success: true,
data: {
config: {
debug: false,
language: "zh-CN",
site_name: "Nezha",
user_template: "",
admin_template: "",
custom_code: customCode,
},
version: "1.0.0",
},
};
}
function renderApp(route = "/") {
window.history.pushState({}, "", route);
const queryClient = createTestQueryClient();
return render(
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>,
);
}
describe("App", () => {
beforeEach(() => {
appMocks.backgroundImage = undefined;
appMocks.fetchSetting.mockResolvedValue(settingResponse());
appMocks.injectContext.mockResolvedValue(undefined);
});
it("renders the main shell after settings load and applies global theme/background settings", async () => {
Object.assign(window, {
ForceTheme: "dark",
CustomMobileBackgroundImage: "/mobile.png",
});
appMocks.backgroundImage = "/desktop.png";
const { container } = renderApp();
expect(await screen.findByText("server-page")).toBeInTheDocument();
expect(screen.getByText("refresh-toast")).toBeInTheDocument();
expect(screen.getByText("header")).toBeInTheDocument();
expect(screen.getByText("dash-command")).toBeInTheDocument();
expect(screen.getByText("footer")).toBeInTheDocument();
expect(appMocks.setTheme).toHaveBeenCalledWith("dark");
expect(
Array.from(container.querySelectorAll<HTMLElement>("[style]")).some(
(element) => element.style.backgroundImage.includes("/desktop.png"),
),
).toBe(true);
expect(
Array.from(container.querySelectorAll<HTMLElement>("[style]")).some(
(element) => element.style.backgroundImage.includes("/mobile.png"),
),
).toBe(true);
});
it("injects custom code before showing the app shell", async () => {
appMocks.fetchSetting.mockResolvedValue(
settingResponse("<script>custom</script>"),
);
renderApp();
await waitFor(() => {
expect(appMocks.injectContext).toHaveBeenCalledWith(
"<script>custom</script>",
);
});
expect(await screen.findByText("server-page")).toBeInTheDocument();
});
it("renders fetch errors through the error page", async () => {
appMocks.fetchSetting.mockRejectedValue(new Error("settings failed"));
renderApp();
expect(await screen.findByText("settings failed")).toBeInTheDocument();
});
it("routes server detail paths through the app router", async () => {
renderApp("/server/42");
expect(await screen.findByText("server-detail-page")).toBeInTheDocument();
});
});
@@ -0,0 +1,22 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import RemainPercentBar from "@/components/RemainPercentBar";
describe("RemainPercentBar", () => {
it("uses status colors for low, medium, and healthy remaining percentages", () => {
const { rerender } = render(<RemainPercentBar value={29} />);
const progress = screen.getByRole("progressbar");
const indicator = progress.firstElementChild;
expect(progress).toHaveClass("w-[70px]");
expect(indicator).toHaveClass("bg-red-500");
expect(indicator).toHaveStyle({ transform: "translateX(-71%)" });
rerender(<RemainPercentBar value={30} />);
expect(progress.firstElementChild).toHaveClass("bg-orange-400");
rerender(<RemainPercentBar value={70} />);
expect(progress.firstElementChild).toHaveClass("bg-green-500");
});
});
+144
View File
@@ -0,0 +1,144 @@
import { act, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { ThemeColorManager } from "@/components/ThemeColorManager";
import {
ThemeProvider,
ThemeProviderContext,
} from "@/components/ThemeProvider";
import { useTheme } from "@/hooks/use-theme";
function ThemeProbe() {
const { setTheme, theme } = useTheme();
return (
<div>
<p data-testid="theme-state">{theme}</p>
<button type="button" onClick={() => setTheme("dark")}>
dark
</button>
<button type="button" onClick={() => setTheme("light")}>
light
</button>
</div>
);
}
function InvalidThemeProbe() {
useTheme();
return null;
}
describe("ThemeProvider", () => {
it("applies theme classes, meta color, and localStorage state", async () => {
const user = userEvent.setup();
const meta = document.createElement("meta");
meta.name = "theme-color";
document.head.appendChild(meta);
render(
<ThemeProvider storageKey="theme-test">
<ThemeProbe />
</ThemeProvider>,
);
expect(screen.getByTestId("theme-state")).toHaveTextContent("system");
expect(document.documentElement).toHaveClass("light");
await user.click(screen.getByRole("button", { name: "dark" }));
expect(screen.getByTestId("theme-state")).toHaveTextContent("dark");
expect(localStorage.getItem("theme-test")).toBe("dark");
expect(document.documentElement).toHaveClass("dark");
expect(document.documentElement.style.colorScheme).toBe("dark");
expect(document.querySelector('meta[name="theme-color"]')).toHaveAttribute(
"content",
"hsl(30 15% 8%)",
);
await user.click(screen.getByRole("button", { name: "light" }));
expect(document.documentElement).toHaveClass("light");
});
it("uses stored theme value as the initial theme", () => {
localStorage.setItem("theme-test", "dark");
render(
<ThemeProvider storageKey="theme-test">
<ThemeProbe />
</ThemeProvider>,
);
expect(screen.getByTestId("theme-state")).toHaveTextContent("dark");
expect(document.documentElement).toHaveClass("dark");
});
it("follows system color scheme changes and cleans up its listener", () => {
let systemDark = true;
let changeListener: (() => void) | undefined;
const addEventListener = vi.fn((_event: string, listener: () => void) => {
changeListener = listener;
});
const removeEventListener = vi.fn();
vi.mocked(window.matchMedia).mockReturnValue({
get matches() {
return systemDark;
},
media: "(prefers-color-scheme: dark)",
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener,
removeEventListener,
dispatchEvent: vi.fn(),
} as unknown as MediaQueryList);
const { unmount } = render(
<ThemeProvider storageKey="theme-system-test">
<ThemeProbe />
</ThemeProvider>,
);
expect(document.documentElement).toHaveClass("dark");
act(() => {
systemDark = false;
changeListener?.();
});
expect(document.documentElement).toHaveClass("light");
unmount();
expect(removeEventListener).toHaveBeenCalledWith("change", changeListener);
});
it("keeps theme-color meta in sync through ThemeColorManager", () => {
render(
<ThemeProvider storageKey="theme-test">
<ThemeColorManager />
<ThemeProbe />
</ThemeProvider>,
);
act(() => {
screen.getByRole("button", { name: "dark" }).click();
});
expect(document.querySelector('meta[name="theme-color"]')).toHaveAttribute(
"content",
"hsl(30 15% 8%)",
);
});
it("throws when the theme context is explicitly unavailable", () => {
expect(() =>
render(
<ThemeProviderContext.Provider value={undefined as never}>
<InvalidThemeProbe />
</ThemeProviderContext.Provider>,
),
).toThrow("useTheme must be used within a ThemeProvider");
});
});
@@ -0,0 +1,190 @@
import { fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import AnimateCountClient, { AnimateCount } from "@/components/AnimatedCount";
import ErrorBoundary from "@/components/ErrorBoundary";
import ChartSkeleton from "@/components/loading/ChartSkeleton";
import { Loader, LoadingSpinner } from "@/components/loading/Loader";
import NetworkChartLoading from "@/components/NetworkChartLoading";
import { SearchButton } from "@/components/SearchButton";
import ServerOverview from "@/components/ServerOverview";
import ServerUsageBar from "@/components/ServerUsageBar";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
vi.mock("@numeric-text/react", () => ({
default: ({
className,
value,
}: {
className?: string;
value: string | number;
}) => <span className={className}>{value}</span>,
}));
import { CommandProvider } from "@/context/command-provider";
import { StatusProvider } from "@/context/status-provider";
import { useCommand } from "@/hooks/use-command";
import { useStatus } from "@/hooks/use-status";
function BrokenComponent(): never {
throw new Error("render failed");
}
function CommandReadout() {
const { isOpen } = useCommand();
return <p>{isOpen ? "command-open" : "command-closed"}</p>;
}
function StatusReadout() {
const { status } = useStatus();
return <p>{`status:${status}`}</p>;
}
describe("basic display components", () => {
it("renders animated counts with padded digits", () => {
render(<AnimateCount minDigits={3}>{7}</AnimateCount>);
expect(screen.getAllByText("0")).toHaveLength(4);
expect(screen.getAllByText("7")).toHaveLength(1);
});
it("keeps the animated count client stable across count changes", () => {
const { rerender } = render(
<AnimateCountClient count={1} minDigits={2} className="count" />,
);
expect(screen.getAllByText("1")).toHaveLength(2);
rerender(<AnimateCountClient count={2} minDigits={2} className="count" />);
expect(screen.getByText("2")).toBeInTheDocument();
});
it("renders server usage colors for healthy, warning, and critical values", () => {
const { rerender } = render(<ServerUsageBar value={70} />);
expect(screen.getByRole("progressbar").firstElementChild).toHaveClass(
"bg-green-500",
);
rerender(<ServerUsageBar value={71} />);
expect(screen.getByRole("progressbar").firstElementChild).toHaveClass(
"bg-orange-400",
);
rerender(<ServerUsageBar value={91} />);
expect(screen.getByRole("progressbar").firstElementChild).toHaveClass(
"bg-red-500",
);
});
it("renders loading primitives", () => {
const { container } = render(
<>
<Loader visible={true} />
<LoadingSpinner />
<ChartSkeleton width={120} height="40%" />
<NetworkChartLoading />
</>,
);
expect(
container.querySelector("[data-visible='true']"),
).toBeInTheDocument();
expect(container.querySelectorAll(".hamster-loading-bar")).toHaveLength(16);
expect(container.querySelector(".animate-spin")).toBeInTheDocument();
expect(container.querySelector(".h-\\[250px\\]")).toBeInTheDocument();
});
it("catches render errors with ErrorBoundary", () => {
render(
<ErrorBoundary>
<BrokenComponent />
</ErrorBoundary>,
);
expect(screen.getByText("500")).toBeInTheDocument();
expect(screen.getByText("render failed")).toBeInTheDocument();
});
});
describe("interactive app controls", () => {
it("opens command search from the search button", async () => {
const user = userEvent.setup();
render(
<CommandProvider>
<SearchButton />
<CommandReadout />
</CommandProvider>,
);
expect(screen.getByText("command-closed")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Search" }));
expect(screen.getByText("command-open")).toBeInTheDocument();
});
it("lets overview cards update the global status filter", () => {
render(
<StatusProvider>
<ServerOverview
total={3}
online={2}
offline={1}
up={1024}
down={2048}
upSpeed={512}
downSpeed={256}
/>
<StatusReadout />
</StatusProvider>,
);
expect(screen.getByText("status:all")).toBeInTheDocument();
fireEvent.click(screen.getByText("serverOverview.onlineServers"));
expect(screen.getByText("status:online")).toBeInTheDocument();
fireEvent.click(screen.getByText("serverOverview.offlineServers"));
expect(screen.getByText("status:offline")).toBeInTheDocument();
fireEvent.click(screen.getByText("serverOverview.totalServers"));
expect(screen.getByText("status:all")).toBeInTheDocument();
});
});
describe("ui primitives", () => {
it("forwards classes and events through Button and Input", async () => {
const user = userEvent.setup();
const onClick = vi.fn();
const onChange = vi.fn();
render(
<>
<Button variant="secondary" size="sm" onClick={onClick}>
Save
</Button>
<Input placeholder="Name" onChange={onChange} />
</>,
);
await user.click(screen.getByRole("button", { name: "Save" }));
await user.type(screen.getByPlaceholderText("Name"), "edge");
expect(onClick).toHaveBeenCalledOnce();
expect(onChange).toHaveBeenCalled();
expect(screen.getByRole("button", { name: "Save" })).toHaveClass(
"bg-secondary",
);
});
it("renders skeletons and separators with expected orientation classes", () => {
const { container } = render(
<>
<Skeleton data-testid="skeleton" className="h-4" />
<Separator data-testid="horizontal" />
<Separator data-testid="vertical" orientation="vertical" />
</>,
);
expect(screen.getByTestId("skeleton")).toHaveClass("animate-pulse", "h-4");
expect(screen.getByTestId("horizontal")).toHaveClass("h-px", "w-full");
expect(screen.getByTestId("vertical")).toHaveClass("h-full", "w-px");
expect(container.querySelectorAll("[data-orientation]")).toHaveLength(2);
});
});
@@ -0,0 +1,136 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import {
ChartContainer,
ChartLegendContent,
ChartTooltipContent,
} from "@/components/ui/chart";
function CpuIcon() {
return <span data-testid="cpu-icon">icon</span>;
}
const chartConfig = {
cpu: {
label: "CPU Usage",
icon: CpuIcon,
},
mem: {
label: "Memory",
color: "#00ff00",
},
themed: {
label: "Themed",
theme: {
light: "#ffffff",
dark: "#000000",
},
},
};
describe("chart primitives", () => {
it("renders tooltip rows with config labels, icons, and formatted values", () => {
const formatter = vi.fn((value: number, name: string) => (
<span>{`${name}:${value.toFixed(1)}`}</span>
));
render(
<ChartContainer config={chartConfig}>
<ChartTooltipContent
active={true}
label="cpu"
payload={[
{
color: "#ff0000",
dataKey: "cpu",
name: "cpu",
payload: { fill: "#ff0000" },
value: 12.345,
},
{
color: "#00ff00",
dataKey: "mem",
name: "mem",
payload: { fill: "#00ff00" },
value: 42,
},
]}
formatter={formatter}
/>
</ChartContainer>,
);
expect(screen.getByText("CPU Usage")).toBeInTheDocument();
expect(screen.getByText("cpu:12.3")).toBeInTheDocument();
expect(screen.getByText("mem:42.0")).toBeInTheDocument();
expect(formatter).toHaveBeenCalled();
});
it("renders nested labels for single line indicators and hides inactive tooltips", () => {
const { container, rerender } = render(
<ChartContainer config={chartConfig}>
<ChartTooltipContent
active={true}
indicator="line"
label="cpu"
payload={[
{
color: "#ff0000",
dataKey: "cpu",
name: "cpu",
payload: { fill: "#ff0000" },
value: 12,
},
]}
/>
</ChartContainer>,
);
expect(screen.getAllByText("CPU Usage")).toHaveLength(2);
expect(screen.getByText("12.00")).toBeInTheDocument();
rerender(
<ChartContainer config={chartConfig}>
<ChartTooltipContent active={false} payload={[]} />
</ChartContainer>,
);
expect(container).not.toHaveTextContent("CPU Usage");
});
it("renders legend payloads and chart theme style blocks", () => {
const { container, rerender } = render(
<ChartContainer id="summary" config={chartConfig}>
<ChartLegendContent
verticalAlign="top"
payload={[
{
color: "#ff0000",
dataKey: "cpu",
value: "cpu",
},
{
color: "#00ff00",
dataKey: "mem",
value: "mem",
},
]}
/>
</ChartContainer>,
);
expect(screen.getByTestId("cpu-icon")).toBeInTheDocument();
expect(screen.getByText("mem")).toBeInTheDocument();
expect(container.querySelector("style")?.textContent).toContain(
"--color-themed",
);
rerender(
<ChartContainer config={chartConfig}>
<ChartLegendContent payload={[]} />
</ChartContainer>,
);
expect(screen.queryByText("mem")).not.toBeInTheDocument();
});
});
+92
View File
@@ -0,0 +1,92 @@
import { fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { DashCommand } from "@/components/DashCommand";
import { createServer } from "@/test/fixtures";
const dashMocks = vi.hoisted(() => ({
closeCommand: vi.fn(),
isOpen: true,
lastMessage: null as MessageEvent<string> | null,
navigate: vi.fn(),
setTheme: vi.fn(),
toggleCommand: vi.fn(),
connected: true,
}));
vi.mock("@/hooks/use-command", () => ({
useCommand: () => ({
closeCommand: dashMocks.closeCommand,
isOpen: dashMocks.isOpen,
toggleCommand: dashMocks.toggleCommand,
}),
}));
vi.mock("@/hooks/use-theme", () => ({
useTheme: () => ({
setTheme: dashMocks.setTheme,
}),
}));
vi.mock("@/hooks/use-websocket-context", () => ({
useWebSocketContext: () => ({
connected: dashMocks.connected,
lastMessage: dashMocks.lastMessage,
}),
}));
vi.mock("react-router-dom", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-router-dom")>();
return {
...actual,
useNavigate: () => dashMocks.navigate,
};
});
function seedWebSocketData() {
dashMocks.connected = true;
dashMocks.lastMessage = new MessageEvent("message", {
data: JSON.stringify({
now: Date.parse("2025-01-01T00:00:00.000Z") / 1000,
servers: [
createServer({
id: 7,
name: "edge-7",
last_active: "2025-01-01T00:00:00.000Z",
}),
],
}),
});
}
describe("DashCommand", () => {
it("does not render until websocket data is available", () => {
dashMocks.connected = false;
dashMocks.lastMessage = null;
const { container } = render(<DashCommand />);
expect(container).toBeEmptyDOMElement();
});
it("renders server and shortcut commands, handles selection, and listens for keyboard toggle", async () => {
const user = userEvent.setup();
seedWebSocketData();
render(<DashCommand />);
fireEvent.keyDown(document, { key: "k", metaKey: true });
expect(dashMocks.toggleCommand).toHaveBeenCalledOnce();
expect(screen.getByPlaceholderText("TypeCommand")).toBeInTheDocument();
expect(screen.getByText("edge-7")).toBeInTheDocument();
expect(screen.getByText("ToggleDarkMode")).toBeInTheDocument();
await user.click(screen.getByText("edge-7"));
expect(dashMocks.navigate).toHaveBeenCalledWith("/server/7");
expect(dashMocks.closeCommand).toHaveBeenCalled();
await user.click(screen.getByText("ToggleDarkMode"));
expect(dashMocks.setTheme).toHaveBeenCalledWith("dark");
});
});
+175
View File
@@ -0,0 +1,175 @@
import { fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter, useLocation } from "react-router-dom";
import { describe, expect, it } from "vitest";
import GlobalMap, { InteractiveMap } from "@/components/GlobalMap";
import { TooltipProvider } from "@/context/tooltip-provider";
import { createServer } from "@/test/fixtures";
type InteractiveMapProps = Parameters<typeof InteractiveMap>[0];
const now = Date.parse("2025-01-01T00:00:20.000Z");
function LocationProbe() {
const location = useLocation();
return <p>{location.pathname}</p>;
}
function renderMap(ui: React.ReactElement) {
return render(
<MemoryRouter initialEntries={["/"]}>
<TooltipProvider>
{ui}
<LocationProbe />
</TooltipProvider>
</MemoryRouter>,
);
}
function mapFeature(code: string, name: string) {
return {
type: "Feature",
properties: {
iso_a2_eh: code,
iso_a3_eh: `${code}A`,
name,
},
geometry: {
type: "Polygon",
coordinates: [
[
[-100, 30],
[-90, 30],
[-90, 40],
[-100, 40],
[-100, 30],
],
],
},
};
}
describe("GlobalMap", () => {
it("counts unique countries and ignores servers without country codes", () => {
Object.assign(window, { CustomBackgroundImage: "/background.png" });
const { container } = renderMap(
<GlobalMap
now={now}
serverList={[
createServer({ country_code: "us" }),
createServer({ id: 2, country_code: "US" }),
createServer({ id: 3, country_code: "sg" }),
createServer({ id: 4, country_code: "" }),
]}
/>,
);
expect(
screen.getByText(/map\.Distributions 2 map\.Regions/),
).toBeInTheDocument();
expect(container.querySelector("section")).toHaveClass("bg-card/70");
});
});
describe("InteractiveMap", () => {
it("shows country tooltips, navigates from servers, and clears tooltip state", async () => {
const user = userEvent.setup();
const filteredFeatures = [
mapFeature("US", "United States"),
mapFeature("DE", "Germany"),
] as unknown as InteractiveMapProps["filteredFeatures"];
const { container } = renderMap(
<InteractiveMap
countries={["US", "SG"]}
serverCounts={{ US: 2, SG: 1 }}
width={900}
height={500}
filteredFeatures={filteredFeatures}
nezhaServerList={[
createServer({
id: 11,
name: "us-online",
country_code: "us",
last_active: "2025-01-01T00:00:00.000Z",
}),
createServer({
id: 12,
name: "us-offline",
country_code: "us",
last_active: "2024-12-31T00:00:00.000Z",
}),
createServer({
id: 13,
name: "sg-edge",
country_code: "sg",
last_active: "2025-01-01T00:00:00.000Z",
}),
]}
now={now}
/>,
);
const highlightedCountry = container.querySelector("path.fill-green-700");
expect(highlightedCountry).toBeInTheDocument();
fireEvent.mouseEnter(highlightedCountry as SVGPathElement);
expect(await screen.findByText("United States")).toBeInTheDocument();
expect(screen.getByText("2 map.Servers")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /us-offline/ }));
expect(sessionStorage.getItem("fromMainPage")).toBe("true");
expect(screen.getByText("/server/12")).toBeInTheDocument();
const fallbackMarker = container.querySelector("circle.fill-sky-700");
expect(fallbackMarker).toBeInTheDocument();
fireEvent.mouseEnter(fallbackMarker as SVGCircleElement);
expect(await screen.findByText("Singapore")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /sg-edge/ })).toBeInTheDocument();
fireEvent.mouseLeave(container.querySelector(".relative") as HTMLElement);
expect(screen.queryByText("Singapore")).not.toBeInTheDocument();
});
it("clears tooltip state for non-highlighted countries and empty map areas", () => {
const filteredFeatures = [
mapFeature("US", "United States"),
mapFeature("DE", "Germany"),
] as unknown as InteractiveMapProps["filteredFeatures"];
const { container } = renderMap(
<InteractiveMap
countries={["US"]}
serverCounts={{ US: 1 }}
width={900}
height={500}
filteredFeatures={filteredFeatures}
nezhaServerList={[
createServer({
id: 11,
name: "us-online",
country_code: "us",
}),
]}
now={now}
/>,
);
const [highlightedCountry, neutralCountry] = Array.from(
container.querySelectorAll("path"),
);
fireEvent.mouseEnter(highlightedCountry as SVGPathElement);
expect(screen.getByText("United States")).toBeInTheDocument();
fireEvent.mouseEnter(neutralCountry as SVGPathElement);
expect(screen.queryByText("United States")).not.toBeInTheDocument();
fireEvent.mouseEnter(container.querySelector("rect") as SVGRectElement);
expect(screen.queryByText("United States")).not.toBeInTheDocument();
});
});
+243
View File
@@ -0,0 +1,243 @@
import { QueryClientProvider } from "@tanstack/react-query";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { ReactElement } from "react";
import { MemoryRouter, useLocation } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import Header, { RefreshToast } from "@/components/Header";
import { ThemeProvider } from "@/components/ThemeProvider";
import { CommandProvider } from "@/context/command-provider";
import { createSettingResponse } from "@/test/fixtures";
import { createTestQueryClient } from "@/test/utils";
const headerMocks = vi.hoisted(() => ({
backgroundImage: undefined as string | undefined,
connected: true,
fetchLoginUser: vi.fn(),
fetchSetting: vi.fn(),
lastMessage: null as MessageEvent<string> | null,
needReconnect: false,
setNeedReconnect: vi.fn(),
updateBackground: vi.fn(),
}));
vi.mock("@numeric-text/react", () => ({
default: ({ value }: { value: string | number }) => (
<span data-testid="numeric-text">{value}</span>
),
}));
vi.mock("@/hooks/use-background", () => ({
useBackground: () => ({
backgroundImage: headerMocks.backgroundImage,
updateBackground: headerMocks.updateBackground,
}),
}));
vi.mock("@/hooks/use-websocket-context", () => ({
useWebSocketContext: () => ({
connected: headerMocks.connected,
lastMessage: headerMocks.lastMessage,
needReconnect: headerMocks.needReconnect,
setNeedReconnect: headerMocks.setNeedReconnect,
}),
}));
vi.mock("@/lib/nezha-api", () => ({
fetchLoginUser: headerMocks.fetchLoginUser,
fetchSetting: headerMocks.fetchSetting,
}));
function settingResponse(siteName = "Nezha") {
return {
...createSettingResponse(),
data: {
...createSettingResponse().data,
config: {
...createSettingResponse().data.config,
site_name: siteName,
},
},
};
}
function loginResponse() {
return {
success: true,
data: {
id: 1,
username: "admin",
password: "",
created_at: "2025-01-01T00:00:00.000Z",
updated_at: "2025-01-01T00:00:00.000Z",
},
};
}
function LocationProbe() {
const location = useLocation();
return <p>{location.pathname}</p>;
}
function renderHeader(route = "/server/1") {
return render(
<QueryClientProvider client={createTestQueryClient()}>
<MemoryRouter initialEntries={[route]}>
<ThemeProvider storageKey="header-theme-test">
<CommandProvider>
<Header />
<LocationProbe />
</CommandProvider>
</ThemeProvider>
</MemoryRouter>
</QueryClientProvider>,
);
}
function renderInRouter(ui: ReactElement) {
return render(<MemoryRouter>{ui}</MemoryRouter>);
}
describe("Header", () => {
beforeEach(() => {
headerMocks.backgroundImage = undefined;
headerMocks.connected = true;
headerMocks.fetchLoginUser.mockReset();
headerMocks.fetchSetting.mockReset();
headerMocks.lastMessage = new MessageEvent("message", {
data: JSON.stringify({ online: 4 }),
});
headerMocks.needReconnect = false;
headerMocks.setNeedReconnect.mockReset();
headerMocks.updateBackground.mockReset();
Object.assign(window, {
CustomBackgroundImage: "",
CustomDesc: "",
CustomLinks: "",
CustomLogo: "",
CustomMobileBackgroundImage: "",
});
headerMocks.fetchSetting.mockResolvedValue(settingResponse());
headerMocks.fetchLoginUser.mockRejectedValue(new Error("anonymous"));
});
it("renders configured site identity, custom links, online count, and dashboard state", async () => {
const user = userEvent.setup();
Object.assign(window, {
CustomDesc: "edge status",
CustomLinks: JSON.stringify([
{ link: "https://example.test", name: "Docs" },
]),
CustomLogo: "/logo.png",
});
Object.defineProperty(document, "cookie", {
configurable: true,
value: "session=1",
});
sessionStorage.setItem("selectedGroup", "Edge");
headerMocks.fetchSetting.mockResolvedValue(settingResponse("Status Hub"));
headerMocks.fetchLoginUser.mockResolvedValue(loginResponse());
renderHeader();
const siteName = await screen.findByText("Status Hub");
expect(screen.getByText("edge status")).toBeInTheDocument();
expect(screen.getByAltText("apple-touch-icon")).toHaveAttribute(
"src",
"/logo.png",
);
expect(screen.getAllByRole("link", { name: "Docs" })).toHaveLength(2);
expect(await screen.findAllByText("dashboard")).toHaveLength(2);
expect(screen.getByTestId("numeric-text")).toHaveTextContent("4");
expect(screen.getByText("online")).toBeInTheDocument();
await waitFor(() => {
expect(document.title).toBe("Status Hub");
});
expect(document.querySelector("link[rel='shortcut icon']")).toHaveAttribute(
"href",
"/logo.png",
);
await user.click(siteName);
expect(sessionStorage.getItem("selectedGroup")).toBeNull();
expect(screen.getByText("/")).toBeInTheDocument();
});
it("uses the offline display and login links when websocket and auth are unavailable", async () => {
headerMocks.connected = false;
const { container } = renderHeader();
expect(await screen.findAllByText("login")).toHaveLength(2);
expect(screen.getByText("offline")).toBeInTheDocument();
expect(
container.querySelector("[data-visible='true']"),
).toBeInTheDocument();
});
it("stores and removes the active custom background", async () => {
const user = userEvent.setup();
headerMocks.backgroundImage = "/desktop.png";
Object.assign(window, {
CustomBackgroundImage: "/desktop.png",
CustomMobileBackgroundImage: "/mobile.png",
});
const { container } = renderHeader();
await screen.findByText("Nezha");
const toggleButton = container
.querySelector(".lucide-image-minus")
?.closest("button");
expect(toggleButton).toBeInTheDocument();
await user.click(toggleButton as HTMLButtonElement);
expect(sessionStorage.getItem("savedBackgroundImage")).toBe("/desktop.png");
expect(headerMocks.updateBackground).toHaveBeenCalledWith(undefined);
});
it("restores the saved custom background", async () => {
const user = userEvent.setup();
sessionStorage.setItem("savedBackgroundImage", "/saved.png");
const { container } = renderHeader();
await screen.findByText("Nezha");
const toggleButton = container
.querySelector(".lucide-image-minus")
?.closest("button");
expect(toggleButton).toBeInTheDocument();
await user.click(toggleButton as HTMLButtonElement);
expect(headerMocks.updateBackground).toHaveBeenCalledWith("/saved.png");
});
});
describe("RefreshToast", () => {
beforeEach(() => {
headerMocks.needReconnect = false;
});
it("renders only while reconnect refresh is needed", () => {
vi.useFakeTimers();
sessionStorage.setItem("needRefresh", "true");
const { container, rerender } = renderInRouter(<RefreshToast />);
expect(container).toBeEmptyDOMElement();
headerMocks.needReconnect = true;
rerender(
<MemoryRouter>
<RefreshToast />
</MemoryRouter>,
);
expect(screen.getByText("refreshing...")).toBeInTheDocument();
expect(sessionStorage.getItem("needRefresh")).toBeNull();
});
});
@@ -0,0 +1,218 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useEffect } from "react";
import { MemoryRouter, useLocation } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import BillingInfo from "@/components/billingInfo";
import MapTooltip from "@/components/MapTooltip";
import PlanInfo from "@/components/PlanInfo";
import ServerFlag from "@/components/ServerFlag";
import { TooltipProvider } from "@/context/tooltip-provider";
import { useTooltip } from "@/hooks/use-tooltip";
import type { PublicNoteData } from "@/lib/utils";
const planData: PublicNoteData = {
planDataMod: {
bandwidth: "1Gbps",
trafficVol: "2TB",
trafficType: "monthly",
IPv4: "1",
IPv6: "1",
networkRoute: "CN2,CMI",
extra: "Premium,Backup",
},
};
function TooltipSeeder() {
const { setTooltipData } = useTooltip();
useEffect(() => {
setTooltipData({
centroid: [12, 34],
country: "China",
count: 2,
servers: [
{ id: 1, name: "edge-1", status: true },
{ id: 2, name: "edge-2", status: false },
],
});
}, [setTooltipData]);
return null;
}
function LocationProbe() {
const location = useLocation();
return <p>{location.pathname}</p>;
}
describe("PlanInfo and BillingInfo", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2025-01-15T00:00:00.000Z"));
});
it("renders plan badges for every supported public note field", () => {
render(<PlanInfo parsedData={planData} />);
expect(screen.getByText("1Gbps")).toBeInTheDocument();
expect(screen.getByText("2TB")).toBeInTheDocument();
expect(screen.getByText("IPv4")).toBeInTheDocument();
expect(screen.getByText("IPv6")).toBeInTheDocument();
expect(screen.getByText("CN2CMI")).toBeInTheDocument();
expect(screen.getByText("Premium")).toBeInTheDocument();
expect(screen.getByText("Backup")).toBeInTheDocument();
});
it("renders active, free, usage-based, indefinite, and expired billing states", () => {
const { rerender } = render(
<BillingInfo
parsedData={{
billingDataMod: {
startDate: "2025-01-01T00:00:00.000Z",
endDate: "2025-01-31T00:00:00.000Z",
autoRenewal: "0",
cycle: "monthly",
amount: "10",
},
}}
/>,
);
expect(
screen.getByText("billingInfo.price: 10/monthly"),
).toBeInTheDocument();
expect(screen.getByText(/billingInfo.remaining: 16/)).toBeInTheDocument();
expect(screen.getByRole("progressbar")).toBeInTheDocument();
rerender(
<BillingInfo
parsedData={{
billingDataMod: {
startDate: "",
endDate: "",
autoRenewal: "0",
cycle: "",
amount: "0",
},
}}
/>,
);
expect(screen.getByText("billingInfo.free")).toBeInTheDocument();
rerender(
<BillingInfo
parsedData={{
billingDataMod: {
startDate: "",
endDate: "",
autoRenewal: "0",
cycle: "",
amount: "-1",
},
}}
/>,
);
expect(screen.getByText("billingInfo.usage-baseed")).toBeInTheDocument();
rerender(
<BillingInfo
parsedData={{
billingDataMod: {
startDate: "2025-01-01",
endDate: "0000-00-00",
autoRenewal: "0",
cycle: "",
amount: "",
},
}}
/>,
);
expect(screen.getByText(/billingInfo.indefinite/)).toBeInTheDocument();
rerender(
<BillingInfo
parsedData={{
billingDataMod: {
startDate: "2024-01-01",
endDate: "2024-01-31",
autoRenewal: "0",
cycle: "monthly",
amount: "10",
},
}}
/>,
);
expect(screen.getByText(/billingInfo.expired/)).toBeInTheDocument();
});
});
describe("MapTooltip", () => {
it("renders tooltip data and navigates to selected servers", async () => {
const user = userEvent.setup();
render(
<MemoryRouter initialEntries={["/"]}>
<TooltipProvider>
<TooltipSeeder />
<MapTooltip />
<LocationProbe />
</TooltipProvider>
</MemoryRouter>,
);
expect(await screen.findByText("Mainland China")).toBeInTheDocument();
expect(screen.getByText("2 map.Servers")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /edge-2/ }));
expect(sessionStorage.getItem("fromMainPage")).toBe("true");
expect(screen.getByText("/server/2")).toBeInTheDocument();
});
});
describe("ServerFlag", () => {
it("uses SVG flag classes when emoji flags are forced off", () => {
Object.assign(window, { ForceUseSvgFlag: true });
const { container } = render(<ServerFlag country_code="us" />);
expect(container.querySelector(".fi-us")).toBeInTheDocument();
});
it("uses emoji flags when the canvas probe detects support", async () => {
Object.assign(window, { ForceUseSvgFlag: false });
const originalCreateElement = document.createElement.bind(document);
const canvasContext = {
fillStyle: "",
textBaseline: "",
font: "",
fillText: vi.fn(),
getImageData: vi.fn(() => ({
data: new Uint8ClampedArray([0, 0, 0, 255]),
})),
} as unknown as CanvasRenderingContext2D;
vi.spyOn(document, "createElement").mockImplementation(
(tagName, options) => {
if (tagName === "canvas") {
return {
getContext: vi.fn(() => canvasContext),
} as unknown as HTMLCanvasElement;
}
return originalCreateElement(tagName, options);
},
);
const { container } = render(<ServerFlag country_code="US" />);
await waitFor(() => {
expect(container).toHaveTextContent("🇺🇸");
});
expect(container.querySelector(".fi-US")).not.toBeInTheDocument();
});
it("renders nothing for missing country codes", () => {
Object.assign(window, { ForceUseSvgFlag: true });
const { container } = render(<ServerFlag country_code="" />);
expect(container).toBeEmptyDOMElement();
});
});
@@ -0,0 +1,158 @@
import { QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter, useLocation } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import Footer from "@/components/Footer";
import GroupSwitch from "@/components/GroupSwitch";
import {
ServerDetailChartLoading,
ServerDetailLoading,
} from "@/components/loading/ServerDetailLoading";
import TabSwitch from "@/components/TabSwitch";
import { createTestQueryClient, renderWithProviders } from "@/test/utils";
const apiMocks = vi.hoisted(() => ({
fetchSetting: vi.fn(),
}));
vi.mock("@/lib/nezha-api", () => apiMocks);
function LocationProbe() {
const location = useLocation();
return <p>{location.pathname}</p>;
}
describe("Footer", () => {
beforeEach(() => {
apiMocks.fetchSetting.mockResolvedValue({
success: true,
data: {
config: {
debug: false,
language: "en-US",
site_name: "Nezha",
user_template: "",
admin_template: "",
custom_code: "",
},
version: "9.9.9",
},
});
});
it("renders setting version and project attribution", async () => {
renderWithProviders(<Footer />);
expect(screen.getByText("footer.themeBy")).toBeInTheDocument();
expect(screen.getByText("nezha-dash")).toHaveAttribute(
"href",
"https://github.com/hamster1963/nezha-dash",
);
expect(await screen.findByText("9.9.9")).toBeInTheDocument();
});
});
describe("Tab and group switches", () => {
it("switches tabs and marks the active tab", async () => {
const user = userEvent.setup();
const setCurrentTab = vi.fn();
render(
<TabSwitch
tabs={["Detail", "Network"]}
currentTab="Detail"
setCurrentTab={setCurrentTab}
/>,
);
await user.click(screen.getByText("tabSwitch.Network"));
expect(setCurrentTab).toHaveBeenCalledWith("Network");
expect(
screen.getByText("tabSwitch.Detail").parentElement?.parentElement,
).toHaveClass("text-black");
});
it("restores saved groups and hides itself when only All exists", async () => {
const setCurrentTab = vi.fn();
sessionStorage.setItem("selectedGroup", "Edge");
const { container, rerender } = render(
<GroupSwitch
tabs={["All", "Edge", "Asia"]}
currentTab="All"
setCurrentTab={setCurrentTab}
/>,
);
await waitFor(() => {
expect(setCurrentTab).toHaveBeenCalledWith("Edge");
});
rerender(
<GroupSwitch
tabs={["All"]}
currentTab="All"
setCurrentTab={setCurrentTab}
/>,
);
expect(container).toBeEmptyDOMElement();
});
it("scrolls overflowing group tabs and applies custom background styling", async () => {
const user = userEvent.setup();
const setCurrentTab = vi.fn();
vi.spyOn(HTMLElement.prototype, "scrollWidth", "get").mockReturnValue(320);
vi.spyOn(HTMLElement.prototype, "clientWidth", "get").mockReturnValue(120);
window.CustomBackgroundImage = "/background.jpg";
const { container } = render(
<GroupSwitch
tabs={["All", "Edge", "Asia"]}
currentTab="All"
setCurrentTab={setCurrentTab}
/>,
);
const scrollContainer = container.querySelector(
".scrollbar-hidden",
) as HTMLElement;
fireEvent.wheel(scrollContainer, { deltaY: 42 });
await user.click(screen.getByText("Asia"));
expect(scrollContainer.scrollLeft).toBe(42);
expect(setCurrentTab).toHaveBeenCalledWith("Asia");
expect(
container.querySelector(".relative.flex.items-center")?.className,
).toContain("bg-stone-100/70");
});
});
describe("server detail loading states", () => {
it("renders chart skeleton cards", () => {
const { container } = render(<ServerDetailChartLoading />);
expect(container.querySelectorAll(".h-\\[182px\\]")).toHaveLength(6);
});
it("navigates home from the detail loading back affordance", async () => {
const user = userEvent.setup();
const queryClient = createTestQueryClient();
render(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={["/server/7"]}>
<ServerDetailLoading />
<LocationProbe />
</MemoryRouter>
</QueryClientProvider>,
);
await user.click(screen.getAllByAltText("BackIcon")[0]);
expect(screen.getByText("/")).toBeInTheDocument();
});
});
+287
View File
@@ -0,0 +1,287 @@
import { QueryClientProvider } from "@tanstack/react-query";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { ReactElement, ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NetworkChart, NetworkChartClient } from "@/components/NetworkChart";
import type { ChartConfig } from "@/components/ui/chart";
import { createTestQueryClient } from "@/test/utils";
import type { NezhaMonitor, ServerMonitorChart } from "@/types/nezha-api";
const apiMocks = vi.hoisted(() => ({
fetchLoginUser: vi.fn(),
fetchMonitor: vi.fn(),
}));
vi.mock("@/lib/nezha-api", () => ({
fetchLoginUser: apiMocks.fetchLoginUser,
fetchMonitor: apiMocks.fetchMonitor,
}));
vi.mock("recharts", () => {
const createElement =
(testId: string) =>
({
children,
data,
dataKey,
}: {
children?: ReactNode;
data?: unknown[];
dataKey?: string;
}) => (
<div data-key={dataKey} data-points={data?.length} data-testid={testId}>
{children}
</div>
);
const ComposedChart = createElement("composed-chart");
const genericChart = createElement("generic-chart");
return {
Area: createElement("area"),
AreaChart: genericChart,
BarChart: genericChart,
CartesianGrid: createElement("grid"),
ComposedChart,
FunnelChart: genericChart,
Legend: ({ content }: { content?: ReactNode }) => (
<div data-testid="chart-legend">{content}</div>
),
Line: createElement("line"),
LineChart: genericChart,
PieChart: genericChart,
RadarChart: genericChart,
RadialBarChart: genericChart,
ResponsiveContainer: ({ children }: { children?: ReactNode }) => (
<div data-testid="responsive-chart">{children}</div>
),
Sankey: genericChart,
ScatterChart: genericChart,
Tooltip: ({ content }: { content?: ReactNode }) => (
<div data-testid="chart-tooltip">{content}</div>
),
Treemap: genericChart,
XAxis: createElement("x-axis"),
YAxis: createElement("y-axis"),
};
});
const times = Array.from(
{ length: 12 },
(_, index) => Date.parse("2025-01-01T00:00:00.000Z") + index * 60 * 60 * 1000,
);
const monitorData: NezhaMonitor[] = [
{
monitor_id: 2,
monitor_name: "Beta",
display_index: 1,
server_id: 7,
server_name: "edge-chart",
created_at: times,
avg_delay: [15, 18, 0, 45, 48, 52, 4000, 65, 70, 75, 80, 85],
},
{
monitor_id: 1,
monitor_name: "Alpha",
display_index: 3,
server_id: 7,
server_name: "edge-chart",
created_at: times,
avg_delay: [30, 32, 35, 36, 38, 40, 42, 44, 46, 48, 50, 52],
packet_loss: [0, 0, 1, 1, 2, 2, 2, 3, 3, 4, 4, 5],
},
];
const clientChartData: ServerMonitorChart = {
Alpha: times.map((created_at, index) => ({
created_at,
avg_delay: 30 + index,
packet_loss: index,
})),
Beta: times.map((created_at, index) => ({
created_at,
avg_delay: 60 + index,
packet_loss: index % 2,
})),
};
const clientFormattedData = times.map((created_at, index) => ({
created_at,
Alpha: 30 + index,
Alpha_packet_loss: index,
Beta: 60 + index,
Beta_packet_loss: index % 2,
}));
const chartConfig = {
avg_delay: { label: "monitor.avgDelay" },
Alpha: { label: "Alpha" },
Beta: { label: "Beta" },
} satisfies ChartConfig;
function loginResponse() {
return {
success: true,
data: {
id: 1,
username: "admin",
password: "",
created_at: "2025-01-01T00:00:00.000Z",
updated_at: "2025-01-01T00:00:00.000Z",
},
};
}
function renderWithQuery(ui: ReactElement) {
return render(
<QueryClientProvider client={createTestQueryClient()}>
{ui}
</QueryClientProvider>,
);
}
describe("NetworkChart", () => {
beforeEach(() => {
apiMocks.fetchLoginUser.mockReset();
apiMocks.fetchMonitor.mockReset();
apiMocks.fetchLoginUser.mockRejectedValue(new Error("anonymous"));
Object.defineProperty(document, "cookie", {
configurable: true,
value: "",
});
});
it("renders the loading state while monitor data is unavailable", () => {
apiMocks.fetchMonitor.mockReturnValue(new Promise(() => undefined));
const { container } = renderWithQuery(
<NetworkChart server_id={7} show={false} />,
);
expect(container.querySelector(".h-\\[250px\\]")).toBeInTheDocument();
expect(apiMocks.fetchMonitor).not.toHaveBeenCalled();
});
it("renders the no-data state from the monitor API", async () => {
apiMocks.fetchMonitor.mockResolvedValue({
success: true,
data: null,
});
renderWithQuery(<NetworkChart server_id={7} show={true} />);
expect(await screen.findByText("monitor.noData")).toBeInTheDocument();
});
it("fetches monitor data, transforms chart series, and allows logged-in period changes", async () => {
const user = userEvent.setup();
Object.defineProperty(document, "cookie", {
configurable: true,
value: "session=1",
});
apiMocks.fetchLoginUser.mockResolvedValue(loginResponse());
apiMocks.fetchMonitor.mockResolvedValue({
success: true,
data: monitorData,
});
renderWithQuery(<NetworkChart server_id={7} show={true} />);
expect(await screen.findByText("edge-chart")).toBeInTheDocument();
expect(apiMocks.fetchMonitor).toHaveBeenCalledWith(7, "1d");
expect(screen.getByText("2 monitor.monitorCount")).toBeInTheDocument();
expect(screen.getByText("Alpha")).toBeInTheDocument();
expect(screen.getByText("Beta")).toBeInTheDocument();
expect(screen.getByTestId("composed-chart")).toHaveAttribute(
"data-points",
"12",
);
await user.click(screen.getByText("monitor.period7d"));
await waitFor(() => {
expect(apiMocks.fetchMonitor).toHaveBeenCalledWith(7, "7d");
});
});
});
describe("NetworkChartClient", () => {
it("locks longer periods for anonymous users and manages chart selection state", async () => {
const user = userEvent.setup();
const onPeriodChange = vi.fn();
render(
<NetworkChartClient
chartDataKey={["Alpha", "Beta"]}
chartConfig={chartConfig}
chartData={clientChartData}
serverName="edge-client"
formattedData={clientFormattedData}
isPeriodLoading={false}
period="1d"
onPeriodChange={onPeriodChange}
isLogin={false}
/>,
);
expect(screen.getByText("edge-client")).toBeInTheDocument();
expect(screen.getByText("2 monitor.monitorCount")).toBeInTheDocument();
await user.click(screen.getByText("monitor.period7d"));
expect(onPeriodChange).not.toHaveBeenCalled();
await user.click(screen.getByText("Alpha"));
expect(
screen.getByRole("button", { name: /monitor.clearSelections/ }),
).toBeInTheDocument();
expect(screen.getByTestId("area")).toHaveAttribute(
"data-key",
"packet_loss",
);
await user.click(screen.getByText("Beta"));
expect(screen.queryByTestId("area")).not.toBeInTheDocument();
await user.click(
screen.getByRole("button", { name: /monitor.clearSelections/ }),
);
expect(
screen.queryByRole("button", { name: /monitor.clearSelections/ }),
).not.toBeInTheDocument();
});
it("shows period loading and honors the forced peak-cut global", async () => {
const user = userEvent.setup();
Object.assign(window, { ForcePeakCutEnabled: true });
const onPeriodChange = vi.fn();
const { container } = render(
<NetworkChartClient
chartDataKey={["Alpha", "Beta"]}
chartConfig={chartConfig}
chartData={clientChartData}
serverName="edge-client"
formattedData={clientFormattedData}
isPeriodLoading={true}
period="1d"
onPeriodChange={onPeriodChange}
isLogin={true}
/>,
);
expect(container.querySelector(".opacity-60")).toBeInTheDocument();
expect(
screen.getByRole("switch", { name: "monitor.peakCut" }),
).toHaveAttribute("data-state", "checked");
await user.click(screen.getByText("monitor.period30d"));
expect(onPeriodChange).toHaveBeenCalledWith("30d");
await user.click(screen.getByRole("switch", { name: "monitor.peakCut" }));
expect(
screen.getByRole("switch", { name: "monitor.peakCut" }),
).toHaveAttribute("data-state", "unchecked");
});
});
+148
View File
@@ -0,0 +1,148 @@
import { fireEvent, screen } from "@testing-library/react";
import { useLocation } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import ServerCard from "@/components/ServerCard";
import ServerCardInline from "@/components/ServerCardInline";
import { createServer } from "@/test/fixtures";
import { renderWithProviders } from "@/test/utils";
const publicNote = JSON.stringify({
billingDataMod: {
startDate: "2025-01-01T00:00:00.000Z",
endDate: "2025-01-31T00:00:00.000Z",
autoRenewal: "0",
cycle: "monthly",
amount: "10",
},
planDataMod: {
bandwidth: "1Gbps",
trafficVol: "2TB",
trafficType: "monthly",
IPv4: "1",
IPv6: "1",
networkRoute: "CN2,CMI",
extra: "Premium",
},
});
function LocationProbe() {
const location = useLocation();
return <p>{location.pathname}</p>;
}
describe("ServerCard", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2025-01-15T00:00:00.000Z"));
Object.assign(window, {
ForceUseSvgFlag: true,
FixedTopServerName: true,
ShowNetTransfer: true,
});
});
it("renders online server metrics, billing, plan data, and navigates on click", async () => {
const server = createServer({
id: 7,
name: "edge-online",
public_note: publicNote,
host: { platform: "Windows Server" },
});
renderWithProviders(
<>
<ServerCard
now={Date.parse("2025-01-01T00:00:20.000Z")}
serverInfo={server}
/>
<LocationProbe />
</>,
);
expect(screen.getByText("edge-online")).toBeInTheDocument();
expect(screen.getByText("Windows")).toBeInTheDocument();
expect(screen.getByText("12.00%")).toBeInTheDocument();
expect(screen.getAllByText("25.00%")).toHaveLength(2);
expect(screen.getByText("serverCard.upload:2.00 GiB")).toBeInTheDocument();
expect(
screen.getByText("serverCard.download:1.00 GiB"),
).toBeInTheDocument();
expect(screen.getByText("1Gbps")).toBeInTheDocument();
expect(
screen.getAllByText(/billingInfo.remaining: 16/).length,
).toBeGreaterThan(0);
fireEvent.click(screen.getByText("edge-online"));
expect(sessionStorage.getItem("fromMainPage")).toBe("true");
expect(screen.getByText("/server/7")).toBeInTheDocument();
});
it("renders a compact offline card without live metric blocks", () => {
const server = createServer({
id: 8,
name: "edge-offline",
public_note: publicNote,
last_active: "2024-12-31T23:00:00.000Z",
});
renderWithProviders(
<ServerCard
now={Date.parse("2025-01-01T00:00:20.000Z")}
serverInfo={server}
/>,
);
expect(screen.getByText("edge-offline")).toBeInTheDocument();
expect(screen.getByText("1Gbps")).toBeInTheDocument();
expect(screen.queryByText("CPU")).not.toBeInTheDocument();
});
});
describe("ServerCardInline", () => {
beforeEach(() => {
Object.assign(window, { ForceUseSvgFlag: true });
});
it("renders online inline server detail columns", () => {
const server = createServer({
id: 9,
name: "edge-inline",
public_note: publicNote,
state: { uptime: 2 * 86_400 },
});
renderWithProviders(
<ServerCardInline
now={Date.parse("2025-01-01T00:00:20.000Z")}
serverInfo={server}
/>,
);
expect(screen.getByText("edge-inline")).toBeInTheDocument();
expect(screen.getByText("serverCard.system")).toBeInTheDocument();
expect(screen.getByText("2 serverCard.days")).toBeInTheDocument();
expect(screen.getByText("2.00 GiB")).toBeInTheDocument();
expect(screen.getByText("1.00 GiB")).toBeInTheDocument();
});
it("renders offline inline server cards with saved plan data", () => {
const server = createServer({
id: 10,
name: "edge-inline-offline",
public_note: publicNote,
last_active: "2024-12-31T23:00:00.000Z",
});
renderWithProviders(
<ServerCardInline
now={Date.parse("2025-01-01T00:00:20.000Z")}
serverInfo={server}
/>,
);
expect(screen.getByText("edge-inline-offline")).toBeInTheDocument();
expect(screen.getByText("1Gbps")).toBeInTheDocument();
expect(screen.queryByText("serverCard.system")).not.toBeInTheDocument();
});
});
@@ -0,0 +1,290 @@
import { QueryClientProvider } from "@tanstack/react-query";
import { render, screen, waitFor } 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 ServerDetailChart from "@/components/ServerDetailChart";
import { createServer, createSettingResponse } from "@/test/fixtures";
import { createTestQueryClient } from "@/test/utils";
import type { NezhaServer } from "@/types/nezha-api";
const detailChartMocks = vi.hoisted(() => ({
connected: true,
fetchLoginUser: vi.fn(),
fetchServerMetrics: vi.fn(),
fetchSetting: vi.fn(),
lastMessage: null as { data: string } | null,
messageHistory: [] as { data: string }[],
}));
vi.mock("recharts", () => {
const createElement =
(testId: string) =>
({
children,
data,
dataKey,
}: {
children?: ReactNode;
data?: unknown[];
dataKey?: string;
}) => (
<div data-key={dataKey} data-points={data?.length} data-testid={testId}>
{children}
</div>
);
const AreaChart = createElement("area-chart");
const LineChart = createElement("line-chart");
const genericChart = createElement("generic-chart");
return {
Area: createElement("area"),
AreaChart,
BarChart: genericChart,
CartesianGrid: createElement("grid"),
ComposedChart: genericChart,
FunnelChart: genericChart,
Legend: ({ content }: { content?: ReactNode }) => (
<div data-testid="chart-legend">{content}</div>
),
Line: createElement("line"),
LineChart,
PieChart: genericChart,
RadarChart: genericChart,
RadialBarChart: genericChart,
ResponsiveContainer: ({ children }: { children?: ReactNode }) => (
<div data-testid="responsive-chart">{children}</div>
),
Sankey: genericChart,
ScatterChart: genericChart,
Tooltip: ({ content }: { content?: ReactNode }) => (
<div data-testid="chart-tooltip">{content}</div>
),
Treemap: genericChart,
XAxis: createElement("x-axis"),
YAxis: createElement("y-axis"),
};
});
vi.mock("@/hooks/use-websocket-context", () => ({
useWebSocketContext: () => ({
connected: detailChartMocks.connected,
lastMessage: detailChartMocks.lastMessage,
messageHistory: detailChartMocks.messageHistory,
}),
}));
vi.mock("@/lib/nezha-api", () => ({
fetchLoginUser: detailChartMocks.fetchLoginUser,
fetchServerMetrics: detailChartMocks.fetchServerMetrics,
fetchSetting: detailChartMocks.fetchSetting,
}));
function settingResponse(tsdbEnabled = true) {
return {
...createSettingResponse(),
data: {
...createSettingResponse().data,
tsdb_enabled: tsdbEnabled,
},
};
}
function loginResponse() {
return {
success: true,
data: {
id: 1,
username: "admin",
password: "",
created_at: "2025-01-01T00:00:00.000Z",
updated_at: "2025-01-01T00:00:00.000Z",
},
};
}
function metricsResponse(metric: string) {
return {
success: true,
data: {
server_id: 7,
server_name: "edge-chart-detail",
metric,
data_points: [
{ ts: Date.parse("2025-01-01T00:00:00.000Z"), value: 10 },
{ ts: Date.parse("2025-01-01T01:00:00.000Z"), value: 20 },
{ ts: Date.parse("2025-01-01T02:00:00.000Z"), value: 30 },
],
},
};
}
function websocketPayload(server: NezhaServer, now: number) {
return {
data: JSON.stringify({
now,
servers: [server],
}),
};
}
function renderWithQuery(ui: React.ReactElement) {
return render(
<QueryClientProvider client={createTestQueryClient()}>
{ui}
</QueryClientProvider>,
);
}
function seedWebSocketData() {
const baseNow = Date.parse("2025-01-01T00:00:20.000Z");
const server = createServer({
id: 7,
name: "edge-chart-detail",
host: {
gpu: ["NVIDIA T4"],
},
state: {
cpu: 45,
disk_used: 180,
gpu: [33],
mem_used: 80,
net_in_speed: 4 * 1024 ** 2,
net_out_speed: 3 * 1024 ** 2,
process_count: 77,
swap_used: 25,
tcp_conn_count: 18,
udp_conn_count: 9,
},
});
detailChartMocks.connected = true;
detailChartMocks.lastMessage = websocketPayload(server, baseNow);
detailChartMocks.messageHistory = [0, 1, 2].map((index) =>
websocketPayload(
createServer({
id: 7,
host: {
gpu: ["NVIDIA T4"],
},
state: {
cpu: 30 + index,
disk_used: 100 + index * 10,
gpu: [20 + index],
mem_used: 50 + index * 5,
net_in_speed: (1 + index) * 1024 ** 2,
net_out_speed: (2 + index) * 1024 ** 2,
process_count: 60 + index,
swap_used: 10 + index,
tcp_conn_count: 10 + index,
udp_conn_count: 5 + index,
},
}),
baseNow - index * 1000,
),
);
}
describe("ServerDetailChart", () => {
beforeEach(() => {
detailChartMocks.connected = true;
detailChartMocks.fetchLoginUser.mockReset();
detailChartMocks.fetchServerMetrics.mockReset();
detailChartMocks.fetchSetting.mockReset();
detailChartMocks.lastMessage = null;
detailChartMocks.messageHistory = [];
detailChartMocks.fetchLoginUser.mockRejectedValue(new Error("anonymous"));
detailChartMocks.fetchSetting.mockResolvedValue(settingResponse());
});
it("renders the loading grid without websocket data", () => {
detailChartMocks.connected = false;
const { container } = renderWithQuery(<ServerDetailChart server_id="7" />);
expect(container.querySelectorAll(".h-\\[182px\\]")).toHaveLength(6);
});
it("renders realtime resource, network, connection, and GPU charts", async () => {
const user = userEvent.setup();
seedWebSocketData();
renderWithQuery(<ServerDetailChart server_id="7" />);
expect(
await screen.findByText("serverDetailChart.realtime"),
).toBeInTheDocument();
expect(screen.getByText("serverDetailChart.period1d")).toBeInTheDocument();
expect(screen.getByText("serverDetailChart.period7d")).toBeInTheDocument();
expect(screen.getByText("CPU")).toBeInTheDocument();
expect(screen.getByText("GPU: NVIDIA T4")).toBeInTheDocument();
expect(screen.getByText("serverDetailChart.mem")).toBeInTheDocument();
expect(screen.getByText("serverDetailChart.swap")).toBeInTheDocument();
expect(screen.getByText("serverDetailChart.disk")).toBeInTheDocument();
expect(screen.getByText("serverDetailChart.process")).toBeInTheDocument();
expect(screen.getByText("serverDetailChart.upload")).toBeInTheDocument();
expect(screen.getByText("serverDetailChart.download")).toBeInTheDocument();
expect(screen.getByText("TCP")).toBeInTheDocument();
expect(screen.getByText("UDP")).toBeInTheDocument();
expect(screen.getAllByTestId("area-chart").length).toBeGreaterThan(0);
expect(screen.getAllByTestId("line-chart").length).toBeGreaterThan(0);
await user.click(screen.getByText("serverDetailChart.period7d"));
expect(detailChartMocks.fetchServerMetrics).not.toHaveBeenCalled();
});
it("prevents historical periods when TSDB is disabled", async () => {
const user = userEvent.setup();
seedWebSocketData();
detailChartMocks.fetchSetting.mockResolvedValue(settingResponse(false));
renderWithQuery(<ServerDetailChart server_id="7" />);
await screen.findByText("serverDetailChart.realtime");
await user.click(screen.getByText("serverDetailChart.period1d"));
expect(detailChartMocks.fetchServerMetrics).not.toHaveBeenCalled();
});
it("fetches every historical metric group for the selected period", async () => {
const user = userEvent.setup();
seedWebSocketData();
Object.defineProperty(document, "cookie", {
configurable: true,
value: "session=1",
});
detailChartMocks.fetchLoginUser.mockResolvedValue(loginResponse());
detailChartMocks.fetchServerMetrics.mockImplementation(
(_serverId: number, metric: string) =>
Promise.resolve(metricsResponse(metric)),
);
renderWithQuery(<ServerDetailChart server_id="7" />);
await screen.findByText("serverDetailChart.realtime");
await user.click(screen.getByText("serverDetailChart.period1d"));
for (const metric of [
"cpu",
"gpu",
"memory",
"swap",
"disk",
"process_count",
"net_out_speed",
"net_in_speed",
"tcp_conn",
"udp_conn",
]) {
await waitFor(() => {
expect(detailChartMocks.fetchServerMetrics).toHaveBeenCalledWith(
7,
metric,
"1d",
);
});
}
});
});
@@ -0,0 +1,138 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter, useLocation } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import ServerDetailOverview from "@/components/ServerDetailOverview";
import ServerDetailSummary from "@/components/ServerDetailSummary";
import { createServer } from "@/test/fixtures";
const websocketMocks = vi.hoisted(() => ({
connected: true,
lastMessage: null as MessageEvent<string> | null,
}));
vi.mock("@/hooks/use-websocket-context", () => ({
useWebSocketContext: () => websocketMocks,
}));
function seedWebSocketData({
server = createServer(),
now = Date.parse("2025-01-01T00:00:20.000Z"),
} = {}) {
websocketMocks.connected = true;
websocketMocks.lastMessage = new MessageEvent("message", {
data: JSON.stringify({
now,
servers: [server],
}),
});
}
function LocationProbe() {
const location = useLocation();
return <p>{location.pathname}</p>;
}
describe("ServerDetailSummary", () => {
beforeEach(() => {
websocketMocks.connected = true;
websocketMocks.lastMessage = null;
});
it("renders nothing until websocket data exists", () => {
websocketMocks.connected = false;
const { container } = render(<ServerDetailSummary server_id={1} />);
expect(container).toBeEmptyDOMElement();
});
it("renders resource, network, and connection summaries for the selected server", () => {
seedWebSocketData();
render(<ServerDetailSummary server_id={1} />);
expect(screen.getByText("12.00%")).toBeInTheDocument();
expect(screen.getAllByText("25.00%")).toHaveLength(2);
expect(screen.getByText("Process")).toBeInTheDocument();
expect(screen.getByText("88")).toBeInTheDocument();
expect(screen.getByText("TCP")).toBeInTheDocument();
expect(screen.getByText("8")).toBeInTheDocument();
expect(screen.getByText("UDP")).toBeInTheDocument();
expect(screen.getByText("4")).toBeInTheDocument();
expect(screen.getByText("1.00M/s")).toBeInTheDocument();
expect(screen.getByText("2.00M/s")).toBeInTheDocument();
});
});
describe("ServerDetailOverview", () => {
beforeEach(() => {
websocketMocks.connected = true;
websocketMocks.lastMessage = null;
Object.assign(window, { ForceUseSvgFlag: true });
});
it("shows the loading shell when websocket data or the selected server is missing", () => {
websocketMocks.connected = false;
const { rerender } = render(
<MemoryRouter>
<ServerDetailOverview server_id="1" />
</MemoryRouter>,
);
expect(screen.getAllByAltText("BackIcon").length).toBeGreaterThan(0);
seedWebSocketData();
rerender(
<MemoryRouter>
<ServerDetailOverview server_id="404" />
</MemoryRouter>,
);
expect(screen.getAllByAltText("BackIcon").length).toBeGreaterThan(0);
});
it("renders server identity, hardware, traffic, and temperature details", async () => {
const user = userEvent.setup();
seedWebSocketData({
server: createServer({
id: 7,
name: "edge-detail",
country_code: "us",
host: {
gpu: ["NVIDIA T4"],
},
state: {
temperatures: [{ Name: "CPU Core", Temperature: 55.5 }],
},
}),
});
render(
<MemoryRouter initialEntries={["/server/7"]}>
<ServerDetailOverview server_id="7" />
<LocationProbe />
</MemoryRouter>,
);
expect(screen.getByText("edge-detail")).toBeInTheDocument();
expect(screen.getByText("serverDetail.online")).toBeInTheDocument();
expect(screen.getByText("1.0.0")).toBeInTheDocument();
expect(screen.getByText("amd64")).toBeInTheDocument();
expect(screen.getByText("US")).toBeInTheDocument();
expect(screen.getByText(/linux - 6.8/)).toBeInTheDocument();
expect(screen.getByText(/AMD EPYC/)).toBeInTheDocument();
expect(screen.getByText("NVIDIA T4")).toBeInTheDocument();
expect(screen.getByText("2.00 GiB")).toBeInTheDocument();
expect(screen.getByText("1.00 GiB")).toBeInTheDocument();
await user.click(
screen.getByRole("button", { name: /serverDetail.temperature/ }),
);
expect(screen.getByText("CPU Core")).toBeInTheDocument();
expect(screen.getByText(/55.50 °C/)).toBeInTheDocument();
await user.click(screen.getByText("edge-detail"));
expect(screen.getByText("/")).toBeInTheDocument();
});
});
@@ -0,0 +1,249 @@
import { QueryClientProvider } from "@tanstack/react-query";
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { CycleTransferStatsCard } from "@/components/CycleTransferStats";
import CycleTransferStatsClient from "@/components/CycleTransferStatsClient";
import { ServiceTracker } from "@/components/ServiceTracker";
import ServiceTrackerClient from "@/components/ServiceTrackerClient";
import { createServer } from "@/test/fixtures";
import { createTestQueryClient } from "@/test/utils";
const apiMocks = vi.hoisted(() => ({
fetchService: vi.fn(),
}));
vi.mock("@/lib/nezha-api", () => apiMocks);
function renderWithQuery(ui: React.ReactElement) {
return render(
<QueryClientProvider client={createTestQueryClient()}>
{ui}
</QueryClientProvider>,
);
}
describe("ServiceTracker", () => {
it("shows the loading state while service data is pending", () => {
apiMocks.fetchService.mockReturnValue(new Promise(() => undefined));
renderWithQuery(<ServiceTracker serverList={[createServer()]} />);
expect(screen.getByText("serviceTracker.loading")).toBeInTheDocument();
});
it("shows an empty state when there are no services or cycle stats", async () => {
apiMocks.fetchService.mockResolvedValue({
success: true,
data: {},
});
renderWithQuery(<ServiceTracker serverList={[createServer()]} />);
expect(
await screen.findByText("serviceTracker.noService"),
).toBeInTheDocument();
});
it("renders processed service uptime, delay, and matching cycle transfer stats", async () => {
apiMocks.fetchService.mockResolvedValue({
success: true,
data: {
services: {
http: {
service_name: "HTTP Ping",
current_up: 0,
current_down: 0,
total_up: 5,
total_down: 3,
delay: [80, 120, 450],
up: [3, 2, 0],
down: [0, 1, 2],
},
},
cycle_transfer_stats: {
monthly: {
name: "Monthly",
from: "2025-01-01T00:00:00.000Z",
to: "2025-02-01T00:00:00.000Z",
max: 10 * 1024,
min: 0,
server_name: {
"1": "edge-1",
"2": "hidden-server",
},
transfer: {
"1": 5 * 1024,
"2": 1024,
},
next_update: {
"1": "2025-01-15T12:00:00.000Z",
"2": "2025-01-15T12:00:00.000Z",
},
},
},
},
});
renderWithQuery(<ServiceTracker serverList={[createServer({ id: 1 })]} />);
expect(await screen.findByText("HTTP Ping")).toBeInTheDocument();
expect(screen.getByText("217ms")).toBeInTheDocument();
expect(screen.getByText("62.5% serviceTracker.uptime")).toBeInTheDocument();
expect(screen.getByText("edge-1")).toBeInTheDocument();
expect(screen.getByText("Monthly")).toBeInTheDocument();
expect(screen.queryByText("hidden-server")).not.toBeInTheDocument();
});
});
describe("CycleTransferStatsCard", () => {
it("renders nothing for empty server lists", () => {
const { container } = render(
<CycleTransferStatsCard serverList={[]} cycleStats={{}} />,
);
expect(container).toBeEmptyDOMElement();
});
it("supports per-server date and max maps while skipping incomplete entries", () => {
render(
<CycleTransferStatsCard
serverList={[createServer({ id: 7 })]}
cycleStats={{
mapped: {
name: "Mapped",
from: {
"7": "2025-01-01T00:00:00.000Z",
},
to: {
"7": "2025-01-31T00:00:00.000Z",
},
max: {
"7": 100,
},
min: {
"7": 0,
},
server_name: {
"7": "edge-7",
"8": "edge-8",
},
transfer: {
"7": 25,
"8": 25,
},
next_update: {
"7": "2025-01-15T12:00:00.000Z",
"8": "2025-01-15T12:00:00.000Z",
},
},
incomplete: {
name: "Incomplete",
from: "",
to: "",
max: 0,
min: 0,
server_name: {
"7": "missing-fields",
},
transfer: {
"7": 25,
},
next_update: {
"7": "2025-01-15T12:00:00.000Z",
},
},
}}
/>,
);
expect(screen.getByText("edge-7")).toBeInTheDocument();
expect(screen.getByText("Mapped")).toBeInTheDocument();
expect(screen.queryByText("edge-8")).not.toBeInTheDocument();
expect(screen.queryByText("missing-fields")).not.toBeInTheDocument();
});
});
describe("ServiceTrackerClient and CycleTransferStatsClient", () => {
it("uses uptime and delay severity colors for service summaries", () => {
const days = [
{
completed: true,
date: new Date("2025-01-01T00:00:00.000Z"),
uptime: 99.5,
delay: 80,
},
{
completed: false,
date: new Date("2025-01-02T00:00:00.000Z"),
uptime: 90,
delay: 320,
},
];
const { rerender } = render(
<ServiceTrackerClient
title="API"
uptime={99.9}
avgDelay={80}
days={days}
/>,
);
expect(screen.getByText("99.9% serviceTracker.uptime")).toHaveClass(
"text-emerald-500",
);
expect(screen.getByText("80ms")).toHaveClass("text-emerald-500");
rerender(
<ServiceTrackerClient
title="API"
uptime={97}
avgDelay={180}
days={days}
/>,
);
expect(screen.getByText("97.0% serviceTracker.uptime")).toHaveClass(
"text-amber-500",
);
expect(screen.getByText("180ms")).toHaveClass("text-amber-500");
rerender(
<ServiceTrackerClient
title="API"
uptime={94}
avgDelay={320}
days={days}
/>,
);
expect(screen.getByText("94.0% serviceTracker.uptime")).toHaveClass(
"text-rose-500",
);
expect(screen.getByText("320ms")).toHaveClass("text-rose-500");
});
it("caps cycle transfer progress at one hundred percent", () => {
const { container } = render(
<CycleTransferStatsClient
name="Monthly"
from="2025-01-01T00:00:00.000Z"
to="2025-01-31T00:00:00.000Z"
max={1024}
serverStats={[
{
serverId: "1",
serverName: "edge-1",
transfer: 2048,
nextUpdate: "2025-01-15T12:00:00.000Z",
},
]}
/>,
);
expect(screen.getByText("200.0%")).toBeInTheDocument();
expect(
container.querySelector('[style="width: 100%;"]'),
).toBeInTheDocument();
});
});
@@ -0,0 +1,291 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
import { ThemeProvider } from "@/components/ThemeProvider";
import { ModeToggle } from "@/components/ThemeSwitcher";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Label } from "@/components/ui/label";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectSeparator,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import {
Table,
TableBody,
TableCaption,
TableCell,
TableFooter,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
describe("language and theme switchers", () => {
it("opens the language menu and marks the active locale", async () => {
const user = userEvent.setup();
render(<LanguageSwitcher />);
await user.click(screen.getByRole("button", { name: "Change language" }));
expect(screen.getByText("language.zh-CN")).toBeInTheDocument();
expect(screen.getByText("language.en-US")).toHaveClass("font-semibold");
});
it("updates the theme through the mode menu", async () => {
const user = userEvent.setup();
render(
<ThemeProvider storageKey="theme-switcher-test">
<ModeToggle />
</ThemeProvider>,
);
await user.click(screen.getByRole("button", { name: "Toggle theme" }));
await user.click(await screen.findByText("theme.dark"));
expect(document.documentElement).toHaveClass("dark");
expect(localStorage.getItem("theme-switcher-test")).toBe("dark");
});
});
describe("Radix UI wrappers", () => {
it("opens accordion, popover, tooltip, and dialog content", async () => {
const user = userEvent.setup();
render(
<>
<Accordion type="single" collapsible>
<AccordionItem value="status">
<AccordionTrigger>Toggle status</AccordionTrigger>
<AccordionContent>Status body</AccordionContent>
</AccordionItem>
</Accordion>
<Popover>
<PopoverTrigger>Open popover</PopoverTrigger>
<PopoverContent>Popover body</PopoverContent>
</Popover>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger>Hover status</TooltipTrigger>
<TooltipContent>Tooltip body</TooltipContent>
</Tooltip>
</TooltipProvider>
<Dialog>
<DialogTrigger>Open dialog</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Confirm action</DialogTitle>
<DialogDescription>Dialog body</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose>Done</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
</>,
);
await user.click(screen.getByRole("button", { name: "Toggle status" }));
expect(screen.getByText("Status body")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Open popover" }));
expect(screen.getByText("Popover body")).toBeInTheDocument();
await user.hover(screen.getByText("Hover status"));
expect(await screen.findByRole("tooltip")).toHaveTextContent(
"Tooltip body",
);
await user.click(screen.getByRole("button", { name: "Open dialog" }));
expect(screen.getByText("Confirm action")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Done" }));
await waitFor(() => {
expect(screen.queryByText("Confirm action")).not.toBeInTheDocument();
});
});
it("toggles checkbox and switch controls through labels", async () => {
const user = userEvent.setup();
const onCheckboxChange = vi.fn();
const onSwitchChange = vi.fn();
render(
<>
<Label htmlFor="alerts">Alerts</Label>
<Checkbox id="alerts" onCheckedChange={onCheckboxChange} />
<Switch
id="compact"
aria-label="Compact mode"
onCheckedChange={onSwitchChange}
/>
</>,
);
await user.click(screen.getByText("Alerts"));
await user.click(screen.getByRole("switch", { name: "Compact mode" }));
expect(onCheckboxChange).toHaveBeenCalledWith(true);
expect(onSwitchChange).toHaveBeenCalledWith(true);
expect(screen.getByRole("checkbox")).toHaveAttribute(
"data-state",
"checked",
);
expect(
screen.getByRole("switch", { name: "Compact mode" }),
).toHaveAttribute("data-state", "checked");
});
it("renders dropdown labels, indicators, shortcuts, and submenus", async () => {
render(
<DropdownMenu open={true}>
<DropdownMenuTrigger>Open actions</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLabel inset={true}>Actions</DropdownMenuLabel>
<DropdownMenuGroup>
<DropdownMenuItem inset={true}>
Copy
<DropdownMenuShortcut>Ctrl+C</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuCheckboxItem checked={true}>
Show hidden
</DropdownMenuCheckboxItem>
<DropdownMenuRadioGroup value="compact">
<DropdownMenuRadioItem value="compact">
Compact rows
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
<DropdownMenuSub open={true}>
<DropdownMenuSubTrigger inset={true}>
More actions
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem>Archive</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>,
);
expect(await screen.findByText("Actions")).toHaveClass("pl-8");
expect(screen.getByText("Copy")).toHaveClass("pl-8");
expect(screen.getByText("Ctrl+C")).toHaveClass("tracking-widest");
expect(screen.getByText("Show hidden")).toHaveAttribute(
"data-state",
"checked",
);
expect(screen.getByText("Compact rows")).toHaveAttribute(
"data-state",
"checked",
);
expect(screen.getByText("More actions")).toHaveClass("pl-8");
expect(screen.getByText("Archive")).toBeInTheDocument();
});
it("selects values and renders table structure", async () => {
const user = userEvent.setup();
const onValueChange = vi.fn();
render(
<>
<Select onValueChange={onValueChange}>
<SelectTrigger aria-label="Region">
<SelectValue placeholder="Choose region" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>Regions</SelectLabel>
<SelectItem value="asia">Asia</SelectItem>
<SelectSeparator />
<SelectItem value="europe">Europe</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<Table>
<TableCaption>Server summary</TableCaption>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell>edge-1</TableCell>
<TableCell>online</TableCell>
</TableRow>
</TableBody>
<TableFooter>
<TableRow>
<TableCell>Total</TableCell>
<TableCell>1</TableCell>
</TableRow>
</TableFooter>
</Table>
</>,
);
await user.click(screen.getByRole("combobox", { name: "Region" }));
await user.click(await screen.findByRole("option", { name: "Asia" }));
expect(onValueChange).toHaveBeenCalledWith("asia");
expect(screen.getByText("Server summary")).toBeInTheDocument();
expect(
screen.getByRole("columnheader", { name: "Name" }),
).toBeInTheDocument();
expect(screen.getByText("edge-1")).toBeInTheDocument();
});
});
+289
View File
@@ -0,0 +1,289 @@
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";
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<string>) => 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 (
<div>
<p data-testid="command-state">{isOpen ? "open" : "closed"}</p>
<button type="button" onClick={openCommand}>
open
</button>
<button type="button" onClick={closeCommand}>
close
</button>
<button type="button" onClick={toggleCommand}>
toggle
</button>
</div>
);
}
function SortProbe() {
const { setSortOrder, setSortType, sortOrder, sortType } = useSort();
return (
<div>
<p>{`${sortType}:${sortOrder}`}</p>
<button type="button" onClick={() => setSortType("cpu")}>
cpu
</button>
<button type="button" onClick={() => setSortOrder("asc")}>
asc
</button>
</div>
);
}
function StatusProbe() {
const { setStatus, status } = useStatus();
return (
<div>
<p data-testid="status-state">{status}</p>
<button type="button" onClick={() => setStatus("online")}>
online
</button>
</div>
);
}
function TooltipProbe() {
const { setTooltipData, tooltipData } = useTooltip();
return (
<div>
<p>{tooltipData?.country ?? "empty"}</p>
<button
type="button"
onClick={() =>
setTooltipData({
centroid: [10, 20],
country: "China",
count: 1,
servers: [{ id: 1, name: "edge", status: true }],
})
}
>
show
</button>
</div>
);
}
function WebSocketProbe() {
const {
connected,
lastMessage,
messageHistory,
needReconnect,
setNeedReconnect,
} = useWebSocketContext();
return (
<div>
<p>{connected ? "connected" : "disconnected"}</p>
<p>{lastMessage?.data ?? "none"}</p>
<p>{messageHistory.length}</p>
<p>{needReconnect ? "needs-reconnect" : "stable"}</p>
<button type="button" onClick={() => setNeedReconnect(true)}>
mark
</button>
</div>
);
}
describe("state providers", () => {
it("manages command palette open state", async () => {
const user = userEvent.setup();
render(
<CommandProvider>
<CommandProbe />
</CommandProvider>,
);
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(
<SortProvider>
<SortProbe />
</SortProvider>,
);
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(
<SortProvider>
<SortProbe />
</SortProvider>,
);
expect(screen.getByText("default:desc")).toBeInTheDocument();
});
it("manages server status filters", async () => {
const user = userEvent.setup();
render(
<StatusProvider>
<StatusProbe />
</StatusProvider>,
);
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(
<TooltipProvider>
<TooltipProbe />
</TooltipProvider>,
);
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(
<WebSocketProvider url="/api/v1/ws/server">{children}</WebSocketProvider>,
);
}
it("connects with a ws URL and records incoming messages", () => {
renderWebSocketProvider(<WebSocketProbe />);
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("first");
socket.message("second");
});
expect(screen.getByText("second")).toBeInTheDocument();
expect(screen.getByText("2")).toBeInTheDocument();
});
it("keeps only the latest thirty websocket messages", () => {
renderWebSocketProvider(<WebSocketProbe />);
const socket = FakeWebSocket.instances[0];
act(() => {
socket.open();
for (let index = 0; index < 31; index += 1) {
socket.message(`message-${index}`);
}
});
expect(screen.getByText("message-30")).toBeInTheDocument();
expect(screen.getByText("30")).toBeInTheDocument();
});
it("exposes manual reconnect state separately from socket state", async () => {
const user = userEvent.setup();
renderWebSocketProvider(<WebSocketProbe />);
expect(screen.getByText("stable")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "mark" }));
expect(screen.getByText("needs-reconnect")).toBeInTheDocument();
});
});
+77
View File
@@ -0,0 +1,77 @@
import type { NezhaServer } from "@/types/nezha-api";
type NezhaServerOverrides = Omit<Partial<NezhaServer>, "host" | "state"> & {
host?: Partial<NezhaServer["host"]>;
state?: Partial<NezhaServer["state"]>;
};
const baseHost: NezhaServer["host"] = {
platform: "linux",
platform_version: "6.8",
cpu: ["AMD EPYC"],
gpu: [],
mem_total: 200,
disk_total: 400,
swap_total: 100,
arch: "amd64",
boot_time: 1_735_603_200,
version: "1.0.0",
};
const baseState: NezhaServer["state"] = {
cpu: 12,
mem_used: 50,
swap_used: 10,
disk_used: 100,
net_in_transfer: 1024 ** 3,
net_out_transfer: 2 * 1024 ** 3,
net_in_speed: 1024 ** 2,
net_out_speed: 2 * 1024 ** 2,
uptime: 86_400,
load_1: 0.12,
load_5: 0.45,
load_15: 0.78,
tcp_conn_count: 8,
udp_conn_count: 4,
process_count: 88,
temperatures: [],
gpu: [],
};
export function createServer(
overrides: NezhaServerOverrides = {},
): NezhaServer {
return {
id: 1,
name: "edge-1",
public_note: "",
last_active: "2025-01-01T00:00:00.000Z",
country_code: "us",
...overrides,
host: {
...baseHost,
...overrides.host,
},
state: {
...baseState,
...overrides.state,
},
};
}
export function createSettingResponse() {
return {
success: true,
data: {
config: {
debug: false,
language: "en-US",
site_name: "Nezha",
user_template: "",
admin_template: "",
custom_code: "",
},
version: "1.0.0",
},
};
}
+129
View File
@@ -0,0 +1,129 @@
import { act, render, renderHook, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { useActiveIndicator } from "@/hooks/use-active-indicator";
import { useBackground } from "@/hooks/use-background";
import { useChartHistory } from "@/hooks/use-chart-history";
function BackgroundProbe() {
const { backgroundImage, updateBackground } = useBackground();
return (
<div>
<p>{backgroundImage ?? "empty"}</p>
<button type="button" onClick={() => updateBackground("/bg.png")}>
set
</button>
<button type="button" onClick={() => updateBackground(undefined)}>
clear
</button>
</div>
);
}
function ActiveIndicatorProbe({
active,
items,
}: {
active: string;
items: string[];
}) {
const { containerRef, enableIndicatorAnimation, indicator, setItemRef } =
useActiveIndicator(items, active);
return (
<div>
<div ref={containerRef}>
{items.map((item, index) => (
<div
key={item}
ref={setItemRef(index)}
data-testid={`item-${item}`}
onClick={enableIndicatorAnimation}
>
{item}
</div>
))}
</div>
<p>
{indicator ? `${indicator.width}:${indicator.shouldAnimate}` : "none"}
</p>
</div>
);
}
describe("useBackground", () => {
it("updates the global background image and broadcasts changes", async () => {
const user = userEvent.setup();
render(<BackgroundProbe />);
expect(screen.getByText("empty")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "set" }));
expect(window.CustomBackgroundImage).toBe("/bg.png");
expect(screen.getByText("/bg.png")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "clear" }));
expect(screen.getByText("empty")).toBeInTheDocument();
});
it("restores a saved background image during polling", async () => {
vi.useFakeTimers();
sessionStorage.setItem("savedBackgroundImage", "/saved.png");
render(<BackgroundProbe />);
act(() => {
vi.advanceTimersByTime(100);
});
expect(screen.getByText("/saved.png")).toBeInTheDocument();
});
});
describe("useChartHistory", () => {
it("formats websocket message history once and keeps newest data last", () => {
const history = [
{ data: JSON.stringify({ now: 2, servers: [{ id: 1 }] }) },
{ data: JSON.stringify({ now: 1, servers: [{ id: 1 }] }) },
];
const formatFn = vi.fn((wsData: { now: number }, serverId: number) =>
serverId === 1 ? wsData.now : null,
);
const { result, rerender } = renderHook(
({ messages }) => useChartHistory(messages, 1, formatFn),
{ initialProps: { messages: history } },
);
expect(result.current).toEqual([1, 2]);
expect(formatFn).toHaveBeenCalledTimes(2);
rerender({ messages: [...history, { data: JSON.stringify({ now: 3 }) }] });
expect(result.current).toEqual([1, 2]);
});
});
describe("useActiveIndicator", () => {
it("tracks the active item and clears when it is not available", async () => {
Object.defineProperty(HTMLElement.prototype, "offsetWidth", {
configurable: true,
get() {
return 20;
},
});
Object.defineProperty(HTMLElement.prototype, "offsetHeight", {
configurable: true,
get() {
return 10;
},
});
const { rerender } = render(
<ActiveIndicatorProbe active="One" items={["One", "Two"]} />,
);
expect(screen.getByText("20:false")).toBeInTheDocument();
rerender(<ActiveIndicatorProbe active="Missing" items={["One", "Two"]} />);
expect(screen.getByText("none")).toBeInTheDocument();
});
});
+21
View File
@@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import { formatBytes } from "@/lib/format";
describe("formatBytes", () => {
it("formats empty byte values as zero KiB", () => {
expect(formatBytes(0)).toBe("0 KiB");
expect(formatBytes(Number.NaN)).toBe("0 KiB");
});
it("keeps byte values in binary units", () => {
expect(formatBytes(512)).toBe("0.50 KiB");
expect(formatBytes(1024)).toBe("1.00 KiB");
expect(formatBytes(1024 ** 2)).toBe("1.00 MiB");
expect(formatBytes(1024 ** 3 * 2.5, 1)).toBe("2.5 GiB");
});
it("clamps negative decimal precision to an integer", () => {
expect(formatBytes(1536, -1)).toBe("2 KiB");
});
});
+64
View File
@@ -0,0 +1,64 @@
import { describe, expect, it, vi } from "vitest";
import { InjectContext } from "@/lib/inject";
describe("InjectContext", () => {
it("injects supported resource nodes and marks them for cleanup", async () => {
vi.spyOn(console, "log").mockImplementation(() => undefined);
await InjectContext(`
<meta name="x-test" content="enabled" />
<style>.custom { color: red; }</style>
<script>window.__injected = true;</script>
<div id="custom-node">custom content</div>
plain text
`);
expect(document.querySelector('meta[name="x-test"]')).toHaveAttribute(
"data-injected",
"true",
);
expect(document.querySelector("style[data-injected]")).toHaveTextContent(
".custom",
);
expect(document.querySelector("script[data-injected]")).toHaveTextContent(
"window.__injected = true;",
);
expect(document.querySelector("#custom-node")).toHaveAttribute(
"data-injected",
"true",
);
expect(document.body).toHaveTextContent("plain text");
});
it("cleans previous injected resources before applying new content", async () => {
vi.spyOn(console, "log").mockImplementation(() => undefined);
await InjectContext(`<div id="first">first</div>`);
await InjectContext(`<div id="second">second</div>`);
expect(document.querySelector("#first")).not.toBeInTheDocument();
expect(document.querySelector("#second")).toBeInTheDocument();
});
it("logs external resource failures without throwing to callers", async () => {
vi.spyOn(console, "error").mockImplementation(() => undefined);
const appendChild = vi
.spyOn(document.head, "appendChild")
.mockImplementation((node) => {
if (node instanceof HTMLScriptElement) {
setTimeout(() => node.onerror?.(new Event("error")), 0);
}
return node;
});
await InjectContext(`<script src="/missing.js"></script>`);
expect(appendChild).toHaveBeenCalled();
expect(console.error).toHaveBeenCalledWith(
"Error during resource injection:",
expect.any(Error),
);
});
});
+39
View File
@@ -0,0 +1,39 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import {
GetFontLogoClass,
GetOsName,
MageMicrosoftWindows,
} from "@/lib/logo-class";
describe("logo-class helpers", () => {
it("maps known platform aliases to font logo classes", () => {
expect(GetFontLogoClass("ubuntu")).toBe("ubuntu");
expect(GetFontLogoClass("darwin")).toBe("apple");
expect(GetFontLogoClass("linux")).toBe("tux");
expect(GetFontLogoClass("amazon")).toBe("redhat");
expect(GetFontLogoClass("arch")).toBe("archlinux");
expect(GetFontLogoClass("opensuse-tumbleweed")).toBe("opensuse");
expect(GetFontLogoClass("unknown")).toBe("tux");
});
it("maps platform aliases to readable operating system names", () => {
expect(GetOsName("ubuntu")).toBe("Ubuntu");
expect(GetOsName("darwin")).toBe("macOS");
expect(GetOsName("linux")).toBe("Linux");
expect(GetOsName("amazon")).toBe("Redhat");
expect(GetOsName("arch")).toBe("Archlinux");
expect(GetOsName("opensuse-tumbleweed")).toBe("Opensuse");
expect(GetOsName("unknown")).toBe("Linux");
});
it("renders the Windows SVG icon", () => {
render(<MageMicrosoftWindows data-testid="windows-icon" />);
expect(screen.getByTestId("windows-icon")).toHaveAttribute(
"viewBox",
"0 0 24 24",
);
});
});
+107
View File
@@ -0,0 +1,107 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
fetchLoginUser,
fetchMonitor,
fetchServerMetrics,
fetchService,
fetchSetting,
} from "@/lib/nezha-api";
const jsonResponse = (body: unknown, init?: ResponseInit) =>
new Response(JSON.stringify(body), {
status: init?.status ?? 200,
statusText: init?.statusText,
headers: {
"Content-Type": "application/json",
...init?.headers,
},
});
describe("nezha api fetchers", () => {
beforeEach(() => {
vi.stubGlobal("fetch", vi.fn());
});
it("returns setting payloads", async () => {
const payload = {
success: true,
data: {
config: {
debug: false,
language: "en-US",
site_name: "Nezha",
user_template: "",
admin_template: "",
custom_code: "",
},
version: "1.0.0",
},
};
vi.mocked(fetch).mockResolvedValueOnce(jsonResponse(payload));
await expect(fetchSetting()).resolves.toEqual(payload);
expect(fetch).toHaveBeenCalledWith("/api/v1/setting");
});
it("throws API error messages returned by service endpoints", async () => {
vi.mocked(fetch).mockResolvedValueOnce(
jsonResponse({ error: "service unavailable" }),
);
await expect(fetchService()).rejects.toThrow("service unavailable");
});
it("adds monitor and metrics query parameters when periods are provided", async () => {
vi.mocked(fetch)
.mockResolvedValueOnce(jsonResponse({ success: true, data: [] }))
.mockResolvedValueOnce(
jsonResponse({
success: true,
data: {
server_id: 7,
server_name: "edge",
metric: "cpu",
data_points: [],
},
}),
);
await fetchMonitor(7, "7d");
await fetchServerMetrics(7, "cpu", "30d");
expect(fetch).toHaveBeenNthCalledWith(
1,
"/api/v1/server/7/service?period=7d",
);
expect(fetch).toHaveBeenNthCalledWith(
2,
"/api/v1/server/7/metrics?metric=cpu&period=30d",
);
});
it("refreshes the token when a logged-in browser session has cookies", async () => {
Object.defineProperty(document, "cookie", {
configurable: true,
value: "nezha_token=token",
});
const payload = {
success: true,
data: {
id: 1,
username: "admin",
password: "",
created_at: "2025-01-01T00:00:00Z",
updated_at: "2025-01-01T00:00:00Z",
},
};
vi.mocked(fetch)
.mockResolvedValueOnce(jsonResponse(payload))
.mockResolvedValueOnce(jsonResponse({ success: true }));
await expect(fetchLoginUser()).resolves.toEqual(payload);
expect(fetch).toHaveBeenNthCalledWith(1, "/api/v1/profile");
expect(fetch).toHaveBeenNthCalledWith(2, "/api/v1/refresh-token");
});
});
+25
View File
@@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import { geoJsonString } from "@/lib/geo-json-string";
import { countryCoordinates } from "@/lib/geo-limit";
describe("static geographic data", () => {
it("provides known country coordinate metadata", () => {
expect(countryCoordinates.CN).toMatchObject({
lat: 35,
lng: 105,
name: "China",
});
expect(countryCoordinates.US.name).toBe("United States");
});
it("ships parseable GeoJSON feature collection data", () => {
const geoJson = JSON.parse(geoJsonString) as {
type: string;
features: unknown[];
};
expect(geoJson.type).toBe("FeatureCollection");
expect(geoJson.features.length).toBeGreaterThan(0);
});
});
+346
View File
@@ -0,0 +1,346 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
fetcher,
formatNezhaInfo,
formatRelativeTime,
formatTime,
getDaysBetweenDates,
getDaysBetweenDatesWithAutoRenewal,
getNextCycleTime,
handlePublicNote,
nezhaFetcher,
parsePublicNote,
} from "@/lib/utils";
import type { NezhaServer } from "@/types/nezha-api";
const serverFixture: NezhaServer = {
id: 1,
name: "edge",
public_note: '{"planDataMod":{"bandwidth":"1Gbps"}}',
last_active: "2025-01-01T00:00:00.000Z",
country_code: "US",
host: {
platform: "linux",
platform_version: "6.8",
cpu: ["AMD EPYC"],
gpu: [],
mem_total: 200,
disk_total: 400,
swap_total: 100,
arch: "amd64",
boot_time: 1_735_603_200,
version: "1.0.0",
},
state: {
cpu: 12,
mem_used: 50,
swap_used: 10,
disk_used: 100,
net_in_transfer: 1024,
net_out_transfer: 2048,
net_in_speed: 1024 * 1024,
net_out_speed: 2 * 1024 * 1024,
uptime: 3600,
load_1: 0.123,
load_5: 0.456,
load_15: 0.789,
tcp_conn_count: 8,
udp_conn_count: 4,
process_count: 88,
temperatures: [],
gpu: [],
},
};
describe("date and billing helpers", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2025-01-15T00:00:00.000Z"));
});
it("calculates the next renewal cycle after the specified date", () => {
const nextCycle = getNextCycleTime(
Date.UTC(2025, 0, 1),
1,
Date.UTC(2025, 2, 15),
);
expect(new Date(nextCycle).toISOString()).toBe("2025-04-01T00:00:00.000Z");
});
it("normalizes common cycle labels and remaining package days", () => {
const result = getDaysBetweenDatesWithAutoRenewal({
startDate: "2025-01-01T00:00:00.000Z",
endDate: "2025-01-31T00:00:00.000Z",
autoRenewal: "0",
cycle: "monthly",
amount: "10",
});
expect(result.days).toBe(16);
expect(result.cycleLabel).toBe("月");
expect(result.remainingPercentage).toBeCloseTo(16 / 30);
});
it("handles auto-renewal before and after the current cycle end", () => {
expect(
getDaysBetweenDatesWithAutoRenewal({
startDate: "2025-01-01T00:00:00.000Z",
endDate: "2025-02-01T00:00:00.000Z",
autoRenewal: "1",
cycle: "quarterly",
amount: "10",
}),
).toMatchObject({
days: 17,
cycleLabel: "季",
});
const renewed = getDaysBetweenDatesWithAutoRenewal({
startDate: "2024-01-01T00:00:00.000Z",
endDate: "2024-12-01T00:00:00.000Z",
autoRenewal: "1",
cycle: "annual",
amount: "10",
});
expect(renewed.days).toBeGreaterThan(300);
expect(renewed.cycleLabel).toBe("年");
expect(renewed.remainingPercentage).toBeLessThanOrEqual(1);
});
it("rejects invalid cycle calculations", () => {
expect(() => getNextCycleTime(Date.UTC(2025, 0, 1), 0, Date.now())).toThrow(
"参数无效",
);
});
it("formats absolute dates and day differences", () => {
expect(formatTime(new Date(2025, 0, 2, 3, 4, 5).getTime())).toBe(
"2025-1-2 03:04:05",
);
expect(getDaysBetweenDates("2025-01-20", "2025-01-15")).toBe(5);
});
it("formats relative timestamps using compact units", () => {
expect(formatRelativeTime(Date.now() - 45 * 1000)).toBe("45s");
expect(formatRelativeTime(Date.now() - 5 * 60 * 1000)).toBe("5m");
expect(formatRelativeTime(Date.now() - 2 * 60 * 60 * 1000)).toBe("2h");
expect(formatRelativeTime(Date.now() - 3 * 24 * 60 * 60 * 1000)).toBe("3d");
});
});
describe("public note helpers", () => {
it("parses supported public note blocks and defaults missing strings", () => {
expect(
parsePublicNote(
JSON.stringify({
billingDataMod: {
endDate: "2025-12-31",
cycle: "year",
},
planDataMod: {
bandwidth: "1Gbps",
IPv4: "1",
},
}),
),
).toEqual({
billingDataMod: {
startDate: "",
endDate: "2025-12-31",
autoRenewal: "",
cycle: "year",
amount: "",
},
planDataMod: {
bandwidth: "1Gbps",
trafficVol: "",
trafficType: "",
IPv4: "1",
IPv6: "",
networkRoute: "",
extra: "",
},
});
});
it("returns null for invalid public note JSON", () => {
vi.spyOn(console, "error").mockImplementation(() => undefined);
expect(parsePublicNote("{bad json")).toBeNull();
});
it("returns null or partial blocks for empty and single-section public notes", () => {
expect(parsePublicNote("")).toBeNull();
expect(parsePublicNote(JSON.stringify({ note: "plain" }))).toBeNull();
expect(
parsePublicNote(
JSON.stringify({
billingDataMod: {
endDate: "2025-12-31",
amount: "20",
},
}),
),
).toEqual({
billingDataMod: {
startDate: "",
endDate: "2025-12-31",
autoRenewal: "",
cycle: "",
amount: "20",
},
});
expect(
parsePublicNote(
JSON.stringify({
planDataMod: {
trafficVol: "1TB",
extra: "Backup",
},
}),
),
).toEqual({
planDataMod: {
bandwidth: "",
trafficVol: "1TB",
trafficType: "",
IPv4: "",
IPv6: "",
networkRoute: "",
extra: "Backup",
},
});
});
it("falls back to cached notes when websocket payloads are empty", () => {
expect(handlePublicNote(1, "live note")).toBe("live note");
expect(handlePublicNote(1, "")).toBe("live note");
expect(sessionStorage.getItem("server_1_public_note")).toBe("live note");
});
it("returns an empty public note when no live or cached note exists", () => {
expect(handlePublicNote(404, "")).toBe("");
});
});
describe("nezha data formatting", () => {
it("maps websocket server state into view-friendly metrics", () => {
const now = new Date("2025-01-01T00:00:20.000Z").getTime();
const result = formatNezhaInfo(now, serverFixture);
expect(result.online).toBe(true);
expect(result.up).toBe(2);
expect(result.down).toBe(1);
expect(result.mem).toBe(25);
expect(result.swap).toBe(10);
expect(result.disk).toBe(25);
expect(result.load_1).toBe("0.12");
expect(result.public_note).toBe(serverFixture.public_note);
});
it("falls back safely for offline timestamps and empty host totals", () => {
const result = formatNezhaInfo(Date.now(), {
...serverFixture,
public_note: "",
last_active: "0001-01-01T00:00:00.000Z",
host: {
...serverFixture.host,
boot_time: 0,
mem_total: 0,
swap_total: 0,
disk_total: 0,
version: "",
cpu: [],
},
state: {
...serverFixture.state,
cpu: 0,
mem_used: 0,
swap_used: 0,
disk_used: 0,
net_in_speed: 0,
net_out_speed: 0,
process_count: 0,
},
});
expect(result.online).toBe(false);
expect(result.last_active_time_string).toBe("");
expect(result.boot_time_string).toBe("");
expect(result.version).toBeNull();
expect(result.mem).toBe(0);
expect(result.swap).toBe(0);
expect(result.disk).toBe(0);
expect(result.public_note).toBe("");
});
});
describe("fetcher", () => {
it("returns nested data for successful responses", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
new Response(JSON.stringify({ data: { ok: true } }), {
headers: { "Content-Type": "application/json" },
}),
),
);
await expect(fetcher("/api/v1/demo")).resolves.toEqual({ ok: true });
});
it("logs and rethrows failed responses", async () => {
vi.spyOn(console, "error").mockImplementation(() => undefined);
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
new Response(JSON.stringify({ error: "bad" }), {
status: 500,
statusText: "Server Error",
headers: { "Content-Type": "application/json" },
}),
),
);
await expect(fetcher("/api/v1/demo")).rejects.toThrow("Server Error");
expect(console.error).toHaveBeenCalled();
});
});
describe("nezhaFetcher", () => {
it("returns JSON for successful responses", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
new Response(JSON.stringify({ success: true }), {
headers: { "Content-Type": "application/json" },
}),
),
);
await expect(nezhaFetcher("/api/v1/setting")).resolves.toEqual({
success: true,
});
});
it("throws status and response info for failed responses", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
new Response(JSON.stringify({ error: "forbidden" }), {
status: 403,
statusText: "Forbidden",
headers: { "Content-Type": "application/json" },
}),
),
);
await expect(nezhaFetcher("/api/v1/setting")).rejects.toMatchObject({
status: 403,
info: { error: "forbidden" },
});
});
});
+382
View File
@@ -0,0 +1,382 @@
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { SortProvider } from "@/context/sort-provider";
import { StatusProvider } from "@/context/status-provider";
import type { WebSocketContextType } from "@/context/websocket-context";
import { WebSocketContext } from "@/context/websocket-context";
import { useStatus } from "@/hooks/use-status";
import Servers from "@/pages/Server";
import { createServer } from "@/test/fixtures";
import { renderWithProviders } from "@/test/utils";
import type { NezhaServer } from "@/types/nezha-api";
const apiMocks = vi.hoisted(() => ({
fetchServerGroup: vi.fn(),
fetchService: vi.fn(),
}));
vi.mock("@/lib/nezha-api", () => apiMocks);
vi.mock("@/components/GlobalMap", () => ({
default: ({ serverList }: { serverList: NezhaServer[] }) => (
<div data-testid="global-map">{serverList.length}</div>
),
}));
vi.mock("@/components/GroupSwitch", () => ({
default: ({
tabs,
setCurrentTab,
}: {
tabs: string[];
setCurrentTab: (tab: string) => void;
}) => (
<div>
{tabs.map((tab) => (
<button
key={tab}
type="button"
data-testid={`group-${tab}`}
onClick={() => setCurrentTab(tab)}
>
{tab}
</button>
))}
</div>
),
}));
vi.mock("@/components/ServerOverview", () => ({
default: ({
offline,
online,
total,
}: {
offline: number;
online: number;
total: number;
}) => (
<div data-testid="server-overview">{`${total}:${online}:${offline}`}</div>
),
}));
vi.mock("@/components/ServerCard", () => ({
default: ({ serverInfo }: { serverInfo: NezhaServer }) => (
<article data-testid="server-card">{serverInfo.name}</article>
),
}));
vi.mock("@/components/ServerCardInline", () => ({
default: ({ serverInfo }: { serverInfo: NezhaServer }) => (
<article data-testid="server-card-inline">{serverInfo.name}</article>
),
}));
vi.mock("@/components/ServiceTracker", () => ({
ServiceTracker: ({ serverList }: { serverList: NezhaServer[] }) => (
<div data-testid="service-tracker">{serverList.length}</div>
),
}));
function StatusControl() {
const { setStatus } = useStatus();
return (
<button type="button" onClick={() => setStatus("online")}>
online-only
</button>
);
}
function renderServerPage(
websocketValue: Partial<WebSocketContextType>,
{ withStatusControl = false } = {},
) {
const defaultWebsocketValue: WebSocketContextType = {
lastMessage: null,
connected: false,
messageHistory: [],
reconnect: vi.fn(),
needReconnect: false,
setNeedReconnect: vi.fn(),
};
return renderWithProviders(
<SortProvider>
<StatusProvider>
<WebSocketContext.Provider
value={{ ...defaultWebsocketValue, ...websocketValue }}
>
{withStatusControl && <StatusControl />}
<Servers />
</WebSocketContext.Provider>
</StatusProvider>
</SortProvider>,
);
}
function websocketPayload(servers: NezhaServer[]) {
return {
data: JSON.stringify({
now: Date.parse("2025-01-01T00:00:20.000Z"),
servers,
}),
};
}
describe("Servers page", () => {
beforeEach(() => {
apiMocks.fetchServerGroup.mockResolvedValue({
success: true,
data: [
{
group: {
id: 1,
created_at: "",
updated_at: "",
name: "Edge",
},
servers: [2],
},
],
});
apiMocks.fetchService.mockResolvedValue({
success: true,
data: {
services: {},
cycle_transfer_stats: {},
},
});
});
it("renders websocket loading and processing states", () => {
const { rerender } = renderServerPage({
connected: false,
lastMessage: null,
});
expect(screen.getByText("info.websocketConnecting")).toBeInTheDocument();
rerender(
<SortProvider>
<StatusProvider>
<WebSocketContext.Provider
value={{
lastMessage: null,
connected: true,
messageHistory: [],
reconnect: vi.fn(),
needReconnect: false,
setNeedReconnect: vi.fn(),
}}
>
<Servers />
</WebSocketContext.Provider>
</StatusProvider>
</SortProvider>,
);
expect(screen.getByText("info.processing")).toBeInTheDocument();
});
it("summarizes online and offline servers from websocket data", async () => {
const online = createServer({ id: 1, name: "alpha" });
const offline = createServer({
id: 2,
name: "beta",
last_active: "2024-12-31T23:00:00.000Z",
});
renderServerPage({
connected: true,
lastMessage: websocketPayload([online, offline]),
});
expect(screen.getByTestId("server-overview")).toHaveTextContent("2:1:1");
expect(screen.getAllByTestId("server-card")).toHaveLength(2);
expect(screen.getByText("alpha")).toBeInTheDocument();
expect(screen.getByText("beta")).toBeInTheDocument();
await waitFor(() => {
expect(apiMocks.fetchServerGroup).toHaveBeenCalled();
expect(apiMocks.fetchService).toHaveBeenCalled();
});
});
it("filters servers by selected group", async () => {
const online = createServer({ id: 1, name: "alpha" });
const offline = createServer({
id: 2,
name: "beta",
last_active: "2024-12-31T23:00:00.000Z",
});
const user = userEvent.setup();
renderServerPage({
connected: true,
lastMessage: websocketPayload([online, offline]),
});
await user.click(await screen.findByTestId("group-Edge"));
expect(screen.queryByText("alpha")).not.toBeInTheDocument();
expect(screen.getByText("beta")).toBeInTheDocument();
expect(sessionStorage.getItem("selectedGroup")).toBe("Edge");
});
it("sorts server cards by selected metrics and direction", async () => {
const lowCpu = createServer({
id: 1,
name: "alpha",
state: { cpu: 10 },
});
const highCpu = createServer({
id: 2,
name: "beta",
state: { cpu: 90 },
});
const user = userEvent.setup();
renderServerPage({
connected: true,
lastMessage: websocketPayload([lowCpu, highCpu]),
});
await user.selectOptions(screen.getByLabelText("Sort metric"), "cpu");
expect(screen.getAllByTestId("server-card")[0]).toHaveTextContent("beta");
await user.click(screen.getByLabelText("Toggle sort direction"));
expect(screen.getAllByTestId("server-card")[0]).toHaveTextContent("alpha");
});
it("keeps name sorting independent from online status", async () => {
const onlineAlpha = createServer({
id: 1,
name: "alpha",
});
const offlineZeta = createServer({
id: 2,
name: "zeta",
last_active: "2024-12-31T23:00:00.000Z",
});
const user = userEvent.setup();
renderServerPage({
connected: true,
lastMessage: websocketPayload([onlineAlpha, offlineZeta]),
});
expect(screen.getByLabelText("Toggle sort direction")).toBeDisabled();
await user.selectOptions(screen.getByLabelText("Sort metric"), "name");
expect(screen.getAllByTestId("server-card")[0]).toHaveTextContent("zeta");
await user.click(screen.getByLabelText("Toggle sort direction"));
expect(screen.getAllByTestId("server-card")[0]).toHaveTextContent("alpha");
});
it("toggles map and service tracker controls when service data exists", async () => {
apiMocks.fetchService.mockResolvedValue({
success: true,
data: {
services: {
http: {
service_name: "HTTP",
current_up: 1,
current_down: 0,
total_up: 1,
total_down: 0,
delay: [10],
up: [1],
down: [0],
},
},
cycle_transfer_stats: {},
},
});
const user = userEvent.setup();
const online = createServer({ id: 1, name: "alpha" });
const offline = createServer({
id: 2,
name: "beta",
last_active: "2024-12-31T23:00:00.000Z",
});
const { container } = renderServerPage({
connected: true,
lastMessage: websocketPayload([online, offline]),
});
await waitFor(() => {
expect(apiMocks.fetchService).toHaveBeenCalled();
expect(
container.querySelectorAll(
".server-overview-controls section > button",
),
).toHaveLength(3);
});
const controls = container.querySelectorAll(
".server-overview-controls section > button",
);
await user.click(controls[0]);
expect(screen.getByTestId("global-map")).toHaveTextContent("2");
expect(localStorage.getItem("showMap")).toBe("1");
await user.click(controls[1]);
expect(screen.getByTestId("service-tracker")).toHaveTextContent("2");
expect(localStorage.getItem("showServices")).toBe("1");
});
it("does not enable inline cards from storage on mobile widths", () => {
localStorage.setItem("inline", "1");
Object.defineProperty(window, "innerWidth", {
configurable: true,
value: 500,
});
const online = createServer({ id: 1, name: "alpha" });
renderServerPage({
connected: true,
lastMessage: websocketPayload([online]),
});
expect(screen.getByTestId("server-card")).toHaveTextContent("alpha");
expect(screen.queryByTestId("server-card-inline")).not.toBeInTheDocument();
});
it("applies external status filters and inline card preferences", async () => {
localStorage.setItem("inline", "1");
Object.defineProperty(window, "innerWidth", {
configurable: true,
value: 1024,
});
const online = createServer({ id: 1, name: "alpha" });
const offline = createServer({
id: 2,
name: "beta",
last_active: "2024-12-31T23:00:00.000Z",
});
const user = userEvent.setup();
renderServerPage(
{
connected: true,
lastMessage: websocketPayload([online, offline]),
},
{ withStatusControl: true },
);
await waitFor(() => {
expect(screen.getAllByTestId("server-card-inline")).toHaveLength(2);
});
await user.click(screen.getByRole("button", { name: "online-only" }));
expect(screen.getAllByTestId("server-card-inline")).toHaveLength(1);
expect(screen.getByText("alpha")).toBeInTheDocument();
expect(screen.queryByText("beta")).not.toBeInTheDocument();
});
});
+130
View File
@@ -0,0 +1,130 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import ErrorPage from "@/pages/ErrorPage";
import NotFound from "@/pages/NotFound";
import ServerDetail from "@/pages/ServerDetail";
vi.mock("@/components/NetworkChart", () => ({
NetworkChart: ({ server_id, show }: { server_id: number; show: boolean }) => (
<div data-testid="network-chart">{`${server_id}:${show}`}</div>
),
}));
vi.mock("@/components/ServerDetailChart", () => ({
default: ({ server_id }: { server_id: string }) => (
<div data-testid="detail-chart">{server_id}</div>
),
}));
vi.mock("@/components/ServerDetailOverview", () => ({
default: ({ server_id }: { server_id: string }) => (
<div data-testid="detail-overview">{server_id}</div>
),
}));
vi.mock("@/components/TabSwitch", () => ({
default: ({
tabs,
setCurrentTab,
}: {
tabs: string[];
setCurrentTab: (tab: string) => void;
}) => (
<div>
{tabs.map((tab) => (
<button key={tab} type="button" onClick={() => setCurrentTab(tab)}>
{tab}
</button>
))}
</div>
),
}));
function LocationProbe() {
const location = useLocation();
return <p>{location.pathname}</p>;
}
describe("simple pages", () => {
it("renders explicit and translated error messages", () => {
const { rerender } = render(<ErrorPage code={418} message="short" />);
expect(screen.getByText("418")).toBeInTheDocument();
expect(screen.getByText("short")).toBeInTheDocument();
rerender(<ErrorPage />);
expect(screen.getByText("error.somethingWentWrong")).toBeInTheDocument();
});
it("navigates back home from the not found page", async () => {
const user = userEvent.setup();
render(
<MemoryRouter initialEntries={["/missing"]}>
<Routes>
<Route
path="/missing"
element={
<>
<NotFound />
<LocationProbe />
</>
}
/>
<Route
path="/"
element={
<>
<p>home</p>
<LocationProbe />
</>
}
/>
</Routes>
</MemoryRouter>,
);
expect(screen.getByText("404")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "error.backToHome" }));
expect(screen.getByText("home")).toBeInTheDocument();
expect(screen.getByText("/")).toBeInTheDocument();
});
});
describe("ServerDetail", () => {
beforeEach(() => {
vi.stubGlobal("scrollTo", vi.fn());
});
it("renders detail tab by default and can switch to network tab", async () => {
const user = userEvent.setup();
render(
<MemoryRouter initialEntries={["/server/7"]}>
<Routes>
<Route path="/server/:id" element={<ServerDetail />} />
</Routes>
</MemoryRouter>,
);
expect(screen.getByTestId("detail-overview")).toHaveTextContent("7");
expect(screen.getByTestId("detail-chart")).toHaveTextContent("7");
expect(screen.getByTestId("network-chart")).toHaveTextContent("7:false");
await user.click(screen.getByRole("button", { name: "Network" }));
expect(screen.getByTestId("network-chart")).toHaveTextContent("7:true");
});
it("redirects when route params are missing", async () => {
render(
<MemoryRouter initialEntries={["/server"]}>
<Routes>
<Route path="/server" element={<ServerDetail />} />
<Route path="/404" element={<p>redirected</p>} />
</Routes>
</MemoryRouter>,
);
expect(await screen.findByText("redirected")).toBeInTheDocument();
});
});
+110
View File
@@ -0,0 +1,110 @@
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach, vi } from "vitest";
vi.mock("react-i18next", () => ({
initReactI18next: {
type: "3rdParty",
init: vi.fn(),
},
useTranslation: () => ({
t: (key: string) => key,
i18n: {
language: "en-US",
languages: ["en-US"],
changeLanguage: vi.fn(),
},
}),
}));
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
class ResizeObserverMock {
observe = vi.fn();
unobserve = vi.fn();
disconnect = vi.fn();
}
class IntersectionObserverMock {
readonly root = null;
readonly rootMargin = "";
readonly thresholds = [];
observe = vi.fn();
unobserve = vi.fn();
disconnect = vi.fn();
takeRecords = vi.fn(() => []);
}
Object.defineProperty(globalThis, "ResizeObserver", {
writable: true,
value: ResizeObserverMock,
});
Object.defineProperty(globalThis, "IntersectionObserver", {
writable: true,
value: IntersectionObserverMock,
});
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
configurable: true,
value: vi.fn(),
});
Object.defineProperty(HTMLElement.prototype, "hasPointerCapture", {
configurable: true,
value: vi.fn(() => false),
});
Object.defineProperty(HTMLElement.prototype, "setPointerCapture", {
configurable: true,
value: vi.fn(),
});
Object.defineProperty(HTMLElement.prototype, "releasePointerCapture", {
configurable: true,
value: vi.fn(),
});
afterEach(() => {
cleanup();
localStorage.clear();
sessionStorage.clear();
document.head
.querySelectorAll('meta[name="theme-color"], [data-injected]')
.forEach((node) => {
node.remove();
});
document.body.querySelectorAll("[data-injected]").forEach((node) => {
node.remove();
});
document.documentElement.className = "";
document.documentElement.removeAttribute("style");
Object.defineProperty(document, "cookie", {
configurable: true,
value: "",
});
window.CustomBackgroundImage = "";
window.CustomMobileBackgroundImage = "";
window.ForceShowServices = false;
window.ForceCardInline = false;
window.ForceShowMap = false;
window.ForcePeakCutEnabled = false;
window.ForceSortType = undefined;
window.ForceSortOrder = undefined;
vi.unstubAllGlobals();
vi.restoreAllMocks();
vi.useRealTimers();
});
+42
View File
@@ -0,0 +1,42 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { type RenderOptions, render } from "@testing-library/react";
import type { ReactElement, ReactNode } from "react";
import { MemoryRouter } from "react-router-dom";
export function createTestQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
retry: false,
gcTime: 0,
},
},
});
}
type RenderWithProvidersOptions = RenderOptions & {
route?: string;
queryClient?: QueryClient;
};
export function renderWithProviders(
ui: ReactElement,
{
route = "/",
queryClient = createTestQueryClient(),
...renderOptions
}: RenderWithProvidersOptions = {},
) {
function Wrapper({ children }: { children: ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={[route]}>{children}</MemoryRouter>
</QueryClientProvider>
);
}
return {
queryClient,
...render(ui, { wrapper: Wrapper, ...renderOptions }),
};
}