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