mirror of
https://github.com/Buriburizaem0n/nezha-dash-v1.git
synced 2026-09-19 09:40:14 +00:00
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:
@@ -15,3 +15,11 @@ updates:
|
||||
all:
|
||||
patterns:
|
||||
- "*"
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
groups:
|
||||
all:
|
||||
patterns:
|
||||
- "*"
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
name: Auto Fix Lint and Format
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
pull_request:
|
||||
types: [opened, synchronize]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
auto-fix:
|
||||
if: github.event.pull_request.head.repo.full_name == github.repository
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@@ -13,29 +18,32 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.head_ref }}
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
|
||||
- name: Set up Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
- name: Set up pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
bun-version: "latest"
|
||||
node-version: "22"
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Run linter and fix issues
|
||||
run: bun run lint:fix
|
||||
run: pnpm run lint:fix
|
||||
|
||||
- name: Run formatter
|
||||
run: bun run format
|
||||
run: pnpm run format
|
||||
|
||||
- name: Check for changes
|
||||
id: check_changes
|
||||
run: |
|
||||
git diff --exit-code || echo "has_changes=true" >> $GITHUB_ENV
|
||||
git diff --exit-code || echo "has_changes=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Commit and push changes
|
||||
if: steps.check_changes.outputs.has_changes == 'true' || env.has_changes == 'true'
|
||||
if: steps.check_changes.outputs.has_changes == 'true'
|
||||
uses: stefanzweifel/git-auto-commit-action@v7
|
||||
with:
|
||||
commit_message: "chore: auto-fix linting and formatting issues"
|
||||
@@ -43,10 +51,10 @@ jobs:
|
||||
file_pattern: "."
|
||||
|
||||
- name: Add PR comment
|
||||
if: steps.check_changes.outputs.has_changes == 'true' || env.has_changes == 'true'
|
||||
if: steps.check_changes.outputs.has_changes == 'true'
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
github-token: ${{secrets.GITHUB_TOKEN}}
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
github.rest.issues.createComment({
|
||||
issue_number: context.issue.number,
|
||||
|
||||
@@ -33,5 +33,11 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Run quality checks
|
||||
run: pnpm run check
|
||||
|
||||
- name: Run tests
|
||||
run: pnpm run test:coverage
|
||||
|
||||
- name: Run build
|
||||
run: pnpm run build
|
||||
@@ -12,6 +12,7 @@ package-lock.json
|
||||
dist
|
||||
dist-ssr
|
||||
dev-dist
|
||||
coverage
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
|
||||
+17
-2
@@ -3,14 +3,23 @@
|
||||
"private": true,
|
||||
"version": "2.0.2",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=22",
|
||||
"pnpm": ">=11"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"typecheck": "tsc -b",
|
||||
"build": "pnpm run typecheck && vite build",
|
||||
"lint": "biome lint",
|
||||
"lint:fix": "biome lint --fix",
|
||||
"format": "biome format --write .",
|
||||
"check": "biome check",
|
||||
"check:fix": "biome check --fix --unsafe",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"verify": "pnpm run check && pnpm run test:coverage && pnpm run build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -56,15 +65,21 @@
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.4.16",
|
||||
"@tailwindcss/postcss": "^4.3.0",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "25.9.2",
|
||||
"@types/react": "19.2.17",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"@vitest/coverage-v8": "^4.1.8",
|
||||
"globals": "17.6.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"postcss": "8.5.15",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"typescript": "~6.0.3",
|
||||
"vite": "8.0.16"
|
||||
"vite": "8.0.16",
|
||||
"vitest": "^4.1.8"
|
||||
},
|
||||
"packageManager": "pnpm@11.5.1+sha512.93f7b57422ea7068257235b4c16eb60762eb68e1dc23723199cc739043ea9be2c4143274a399d8c6defa2b1176226d9ca1c4b63482d6200c1a8fbaa78c1d1485"
|
||||
}
|
||||
|
||||
Generated
+912
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { Navigate, useParams } from "react-router-dom";
|
||||
import { NetworkChart } from "@/components/NetworkChart";
|
||||
import ServerDetailChart from "@/components/ServerDetailChart";
|
||||
import ServerDetailOverview from "@/components/ServerDetailOverview";
|
||||
@@ -7,8 +7,6 @@ import TabSwitch from "@/components/TabSwitch";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
|
||||
export default function ServerDetail() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
window.scrollTo({ top: 0, left: 0, behavior: "instant" });
|
||||
}, []);
|
||||
@@ -19,8 +17,7 @@ export default function ServerDetail() {
|
||||
const { id: server_id } = useParams();
|
||||
|
||||
if (!server_id) {
|
||||
navigate("/404");
|
||||
return null;
|
||||
return <Navigate to="/404" replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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("CN2|CMI")).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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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" },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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 }),
|
||||
};
|
||||
}
|
||||
+1
-1
@@ -14,7 +14,7 @@
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"types": ["node"],
|
||||
"types": ["node", "vitest/globals", "@testing-library/jest-dom"],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
|
||||
+34
-1
@@ -2,7 +2,7 @@ import { execSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vite";
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
// Get git commit hash
|
||||
const getGitHash = () => {
|
||||
@@ -63,6 +63,39 @@ export default defineConfig({
|
||||
Pragma: "no-cache",
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
environmentOptions: {
|
||||
jsdom: {
|
||||
url: "https://localhost/",
|
||||
},
|
||||
},
|
||||
globals: true,
|
||||
setupFiles: ["./src/test/setup.ts"],
|
||||
css: true,
|
||||
include: ["src/test/**/*.{test,spec}.{ts,tsx}"],
|
||||
coverage: {
|
||||
provider: "v8",
|
||||
reporter: ["text", "html", "lcov"],
|
||||
reportsDirectory: "./coverage",
|
||||
thresholds: {
|
||||
statements: 86,
|
||||
branches: 74,
|
||||
functions: 86,
|
||||
lines: 86,
|
||||
},
|
||||
include: ["src/**/*.{ts,tsx}"],
|
||||
exclude: [
|
||||
"src/**/*.test.{ts,tsx}",
|
||||
"src/**/*.spec.{ts,tsx}",
|
||||
"src/test/**",
|
||||
"src/types/**",
|
||||
"src/main.tsx",
|
||||
"src/i18n.js",
|
||||
"src/vite-env.d.ts",
|
||||
],
|
||||
},
|
||||
},
|
||||
build: {
|
||||
rolldownOptions: {
|
||||
output: {
|
||||
|
||||
Reference in New Issue
Block a user