mirror of
https://github.com/gethomepage/homepage.git
synced 2026-09-28 23:11:17 -07:00
Chore: homepage tests (#6278)
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Stub Menu/Transition to always render children (keeps tests deterministic).
|
||||
vi.mock("@headlessui/react", async () => {
|
||||
const React = await import("react");
|
||||
const { Fragment } = React;
|
||||
|
||||
function Transition({ as: As = Fragment, children }) {
|
||||
if (As === Fragment) return <>{children}</>;
|
||||
return <As>{children}</As>;
|
||||
}
|
||||
|
||||
function Menu({ as: As = "div", children, ...props }) {
|
||||
const content = typeof children === "function" ? children({ open: true }) : children;
|
||||
return <As {...props}>{content}</As>;
|
||||
}
|
||||
|
||||
function MenuButton(props) {
|
||||
return <button type="button" {...props} />;
|
||||
}
|
||||
function MenuItems(props) {
|
||||
return <div {...props} />;
|
||||
}
|
||||
function MenuItem({ children }) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
Menu.Button = MenuButton;
|
||||
Menu.Items = MenuItems;
|
||||
Menu.Item = MenuItem;
|
||||
|
||||
return { Menu, Transition };
|
||||
});
|
||||
|
||||
import Dropdown from "./dropdown";
|
||||
|
||||
describe("components/services/dropdown", () => {
|
||||
it("renders the selected label and updates value when an option is clicked", () => {
|
||||
const setValue = vi.fn();
|
||||
const options = [
|
||||
{ value: "a", label: "Alpha" },
|
||||
{ value: "b", label: "Beta" },
|
||||
];
|
||||
|
||||
render(<Dropdown options={options} value="a" setValue={setValue} />);
|
||||
|
||||
// "Alpha" appears both in the menu button and in the list of options.
|
||||
expect(screen.getAllByRole("button", { name: "Alpha" })[0]).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Beta" }));
|
||||
expect(setValue).toHaveBeenCalledWith("b");
|
||||
});
|
||||
});
|
||||
@@ -36,7 +36,7 @@ export default function ServicesGroup({
|
||||
isSubgroup ? "subgroup" : "",
|
||||
)}
|
||||
>
|
||||
<Disclosure defaultOpen={!(layout?.initiallyCollapsed ?? groupsInitiallyCollapsed) ?? true}>
|
||||
<Disclosure defaultOpen={!(layout?.initiallyCollapsed ?? groupsInitiallyCollapsed)}>
|
||||
{({ open }) => (
|
||||
<>
|
||||
{layout?.header !== false && (
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@headlessui/react", async () => {
|
||||
const React = await import("react");
|
||||
const { Fragment } = React;
|
||||
|
||||
function Transition({ as: As = Fragment, children }) {
|
||||
if (As === Fragment) return <>{children}</>;
|
||||
return <As>{children}</As>;
|
||||
}
|
||||
|
||||
function Disclosure({ defaultOpen = true, children }) {
|
||||
const content = typeof children === "function" ? children({ open: defaultOpen }) : children;
|
||||
return <div>{content}</div>;
|
||||
}
|
||||
|
||||
function DisclosureButton(props) {
|
||||
return <button type="button" {...props} />;
|
||||
}
|
||||
|
||||
const DisclosurePanel = React.forwardRef(function DisclosurePanel(props, ref) {
|
||||
return <div ref={ref} data-testid="disclosure-panel" {...props} static="true" />;
|
||||
});
|
||||
|
||||
Disclosure.Button = DisclosureButton;
|
||||
Disclosure.Panel = DisclosurePanel;
|
||||
|
||||
return { Disclosure, Transition };
|
||||
});
|
||||
|
||||
vi.mock("components/resolvedicon", () => ({
|
||||
default: function ResolvedIconMock() {
|
||||
return <div data-testid="resolved-icon" />;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("components/services/list", () => ({
|
||||
default: function ServicesListMock({ groupName, services }) {
|
||||
return (
|
||||
<div data-testid="services-list-mock">
|
||||
{groupName}:{services?.length ?? 0}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
import ServicesGroup from "./group";
|
||||
|
||||
describe("components/services/group", () => {
|
||||
it("renders group and subgroup headers", () => {
|
||||
render(
|
||||
<ServicesGroup
|
||||
group={{
|
||||
name: "Main",
|
||||
services: [{ name: "svc" }],
|
||||
groups: [{ name: "Sub", services: [], groups: [] }],
|
||||
}}
|
||||
layout={{ icon: "mdi:test" }}
|
||||
groupsInitiallyCollapsed={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Main")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("resolved-icon")).toBeInTheDocument();
|
||||
const lists = screen.getAllByTestId("services-list-mock");
|
||||
expect(lists[0]).toHaveTextContent("Main:1");
|
||||
expect(screen.getByText("Sub")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("sets the panel height to 0 when initially collapsed", async () => {
|
||||
render(
|
||||
<ServicesGroup
|
||||
group={{ name: "Main", services: [], groups: [] }}
|
||||
layout={{ initiallyCollapsed: true }}
|
||||
groupsInitiallyCollapsed={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
const panel = screen.getAllByTestId("disclosure-panel")[0];
|
||||
await waitFor(() => {
|
||||
expect(panel.style.height).toBe("0px");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@headlessui/react", async () => {
|
||||
const React = await import("react");
|
||||
const { Fragment, useEffect } = React;
|
||||
|
||||
function Transition({ as: As = Fragment, beforeEnter, beforeLeave, children }) {
|
||||
useEffect(() => {
|
||||
// Simulate a mount -> enter animation, then a leave animation shortly after.
|
||||
beforeEnter?.();
|
||||
setTimeout(() => beforeLeave?.(), 200);
|
||||
}, [beforeEnter, beforeLeave]);
|
||||
|
||||
if (As === Fragment) return <>{children}</>;
|
||||
return <As>{children}</As>;
|
||||
}
|
||||
|
||||
function Disclosure({ defaultOpen = true, children }) {
|
||||
const content = typeof children === "function" ? children({ open: defaultOpen }) : children;
|
||||
return <div>{content}</div>;
|
||||
}
|
||||
|
||||
function DisclosureButton(props) {
|
||||
return <button type="button" {...props} />;
|
||||
}
|
||||
|
||||
const DisclosurePanel = React.forwardRef(function DisclosurePanel(props, ref) {
|
||||
const { static: _static, ...rest } = props;
|
||||
return (
|
||||
<div
|
||||
ref={(node) => {
|
||||
if (node) {
|
||||
// JSDOM doesn't calculate layout; give the panel a deterministic height.
|
||||
Object.defineProperty(node, "scrollHeight", { value: 123, configurable: true });
|
||||
}
|
||||
if (typeof ref === "function") ref(node);
|
||||
else if (ref) ref.current = node;
|
||||
}}
|
||||
data-testid="disclosure-panel"
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
Disclosure.Button = DisclosureButton;
|
||||
Disclosure.Panel = DisclosurePanel;
|
||||
|
||||
return { Disclosure, Transition };
|
||||
});
|
||||
|
||||
vi.mock("components/resolvedicon", () => ({
|
||||
default: function ResolvedIconMock() {
|
||||
return <div data-testid="resolved-icon" />;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("components/services/list", () => ({
|
||||
default: function ServicesListMock() {
|
||||
return <div data-testid="services-list" />;
|
||||
},
|
||||
}));
|
||||
|
||||
import ServicesGroup from "./group";
|
||||
|
||||
describe("components/services/group transition hooks", () => {
|
||||
it("runs the Transition beforeEnter/beforeLeave height calculations", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
render(
|
||||
<ServicesGroup
|
||||
group={{ name: "Main", services: [], groups: [] }}
|
||||
layout={{ initiallyCollapsed: false }}
|
||||
groupsInitiallyCollapsed={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
const panel = screen.getByTestId("disclosure-panel");
|
||||
expect(panel).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
vi.runAllTimers();
|
||||
});
|
||||
|
||||
// The leave animation sets height back to 0.
|
||||
expect(panel.style.height).toBe("0px");
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,247 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { fireEvent, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { renderWithProviders } from "test-utils/render-with-providers";
|
||||
|
||||
vi.mock("components/resolvedicon", () => ({
|
||||
default: function ResolvedIconMock() {
|
||||
return <div data-testid="resolved-icon" />;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("widgets/docker/component", () => ({
|
||||
default: function DockerWidgetMock() {
|
||||
return <div data-testid="docker-widget" />;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("widgets/kubernetes/component", () => ({
|
||||
default: function KubernetesWidgetMock() {
|
||||
return <div data-testid="kubernetes-widget" />;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("widgets/proxmoxvm/component", () => ({
|
||||
default: function ProxmoxVMWidgetMock() {
|
||||
return <div data-testid="proxmoxvm-widget" />;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./ping", () => ({
|
||||
default: function PingMock() {
|
||||
return <div data-testid="ping" />;
|
||||
},
|
||||
}));
|
||||
vi.mock("./site-monitor", () => ({
|
||||
default: function SiteMonitorMock() {
|
||||
return <div data-testid="site-monitor" />;
|
||||
},
|
||||
}));
|
||||
vi.mock("./status", () => ({
|
||||
default: function StatusMock() {
|
||||
return <div data-testid="status" />;
|
||||
},
|
||||
}));
|
||||
vi.mock("./kubernetes-status", () => ({
|
||||
default: function KubernetesStatusMock() {
|
||||
return <div data-testid="kubernetes-status" />;
|
||||
},
|
||||
}));
|
||||
vi.mock("./proxmox-status", () => ({
|
||||
default: function ProxmoxStatusMock() {
|
||||
return <div data-testid="proxmox-status" />;
|
||||
},
|
||||
}));
|
||||
vi.mock("./widget", () => ({
|
||||
default: function ServiceWidgetMock({ widget }) {
|
||||
return <div data-testid="service-widget">idx:{widget.index}</div>;
|
||||
},
|
||||
}));
|
||||
|
||||
import Item from "./item";
|
||||
|
||||
describe("components/services/item", () => {
|
||||
it("renders the service title as a link when href is provided", () => {
|
||||
renderWithProviders(
|
||||
<Item
|
||||
groupName="G"
|
||||
useEqualHeights={false}
|
||||
service={{
|
||||
id: "svc1",
|
||||
name: "My Service",
|
||||
description: "Desc",
|
||||
href: "https://example.com",
|
||||
icon: "mdi:test",
|
||||
widgets: [],
|
||||
}}
|
||||
/>,
|
||||
{ settings: { target: "_self", showStats: false, statusStyle: "basic" } },
|
||||
);
|
||||
|
||||
const links = screen.getAllByRole("link");
|
||||
expect(links.some((l) => l.getAttribute("href") === "https://example.com")).toBe(true);
|
||||
expect(screen.getByText("My Service")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the icon without a link when href is missing or '#'", () => {
|
||||
renderWithProviders(
|
||||
<Item
|
||||
groupName="G"
|
||||
useEqualHeights={false}
|
||||
service={{
|
||||
id: "svc1",
|
||||
name: "My Service",
|
||||
description: "Desc",
|
||||
href: "#",
|
||||
icon: "mdi:test",
|
||||
widgets: [],
|
||||
}}
|
||||
/>,
|
||||
{ settings: { target: "_self", showStats: false, statusStyle: "basic" } },
|
||||
);
|
||||
|
||||
// The title area should not create a clickable href="#" link.
|
||||
expect(screen.queryByRole("link")).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("resolved-icon")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("toggles container stats on click when stats are hidden by default", () => {
|
||||
renderWithProviders(
|
||||
<Item
|
||||
groupName="G"
|
||||
useEqualHeights={false}
|
||||
service={{
|
||||
id: "svc1",
|
||||
name: "My Service",
|
||||
description: "Desc",
|
||||
href: "https://example.com",
|
||||
container: "c",
|
||||
server: "s",
|
||||
ping: true,
|
||||
siteMonitor: true,
|
||||
widgets: [{ index: 1 }, { index: 2 }],
|
||||
}}
|
||||
/>,
|
||||
{ settings: { showStats: false, statusStyle: "basic" } },
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("docker-widget")).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("ping")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("site-monitor")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "View container stats" }));
|
||||
expect(screen.getByTestId("docker-widget")).toBeInTheDocument();
|
||||
|
||||
expect(screen.getAllByTestId("service-widget")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("shows stats by default when settings.showStats is enabled, unless overridden by the service", () => {
|
||||
const baseService = {
|
||||
id: "svc1",
|
||||
name: "My Service",
|
||||
description: "Desc",
|
||||
container: "c",
|
||||
server: "s",
|
||||
widgets: [],
|
||||
};
|
||||
|
||||
renderWithProviders(<Item groupName="G" useEqualHeights={false} service={baseService} />, {
|
||||
settings: { showStats: true, statusStyle: "basic" },
|
||||
});
|
||||
expect(screen.getByTestId("docker-widget")).toBeInTheDocument();
|
||||
|
||||
renderWithProviders(
|
||||
<Item groupName="G" useEqualHeights={false} service={{ ...baseService, id: "svc2", showStats: false }} />,
|
||||
{
|
||||
settings: { showStats: true, statusStyle: "basic" },
|
||||
},
|
||||
);
|
||||
expect(screen.getAllByTestId("docker-widget")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("closes stats after a short delay when toggled closed", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
renderWithProviders(
|
||||
<Item
|
||||
groupName="G"
|
||||
useEqualHeights={false}
|
||||
service={{
|
||||
id: "svc1",
|
||||
name: "My Service",
|
||||
description: "Desc",
|
||||
container: "c",
|
||||
server: "s",
|
||||
widgets: [],
|
||||
}}
|
||||
/>,
|
||||
{ settings: { showStats: false, statusStyle: "basic" } },
|
||||
);
|
||||
|
||||
const btn = screen.getByRole("button", { name: "View container stats" });
|
||||
fireEvent.click(btn);
|
||||
expect(screen.getByTestId("docker-widget")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(btn);
|
||||
// Still rendered while the close animation runs.
|
||||
expect(screen.getByTestId("docker-widget")).toBeInTheDocument();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(300);
|
||||
expect(screen.queryByTestId("docker-widget")).not.toBeInTheDocument();
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("toggles app and proxmox stats using their respective status tags", () => {
|
||||
renderWithProviders(
|
||||
<Item
|
||||
groupName="G"
|
||||
useEqualHeights={false}
|
||||
service={{
|
||||
id: "svc1",
|
||||
name: "My Service",
|
||||
description: "Desc",
|
||||
app: "app",
|
||||
namespace: "default",
|
||||
proxmoxNode: "pve",
|
||||
proxmoxVMID: "100",
|
||||
proxmoxType: "qemu",
|
||||
widgets: [],
|
||||
}}
|
||||
/>,
|
||||
{ settings: { showStats: false, statusStyle: "basic" } },
|
||||
);
|
||||
|
||||
const appBtn = screen.getByTestId("kubernetes-status").closest("button");
|
||||
expect(appBtn).toBeTruthy();
|
||||
fireEvent.click(appBtn);
|
||||
expect(screen.getByTestId("kubernetes-widget")).toBeInTheDocument();
|
||||
|
||||
const proxmoxBtn = screen.getByTestId("proxmox-status").closest("button");
|
||||
expect(proxmoxBtn).toBeTruthy();
|
||||
fireEvent.click(proxmoxBtn);
|
||||
expect(screen.getByTestId("proxmoxvm-widget")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render the app status tag when the service is marked external", () => {
|
||||
renderWithProviders(
|
||||
<Item
|
||||
groupName="G"
|
||||
useEqualHeights={false}
|
||||
service={{
|
||||
id: "svc1",
|
||||
name: "My Service",
|
||||
description: "Desc",
|
||||
app: "app",
|
||||
external: true,
|
||||
widgets: [],
|
||||
}}
|
||||
/>,
|
||||
{ settings: { showStats: false, statusStyle: "basic" } },
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("kubernetes-status")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { useSWR } = vi.hoisted(() => ({ useSWR: vi.fn() }));
|
||||
|
||||
vi.mock("swr", () => ({
|
||||
default: useSWR,
|
||||
}));
|
||||
|
||||
vi.mock("i18next", () => ({
|
||||
t: (key) => key,
|
||||
}));
|
||||
|
||||
import KubernetesStatus from "./kubernetes-status";
|
||||
|
||||
describe("components/services/kubernetes-status", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("includes podSelector in the request when provided", () => {
|
||||
useSWR.mockReturnValue({ data: undefined, error: undefined });
|
||||
|
||||
render(<KubernetesStatus service={{ namespace: "ns", app: "app", podSelector: "x=y" }} />);
|
||||
|
||||
expect(useSWR).toHaveBeenCalledWith("/api/kubernetes/status/ns/app?podSelector=x=y");
|
||||
});
|
||||
|
||||
it("renders the health/status label when running", () => {
|
||||
useSWR.mockReturnValue({ data: { status: "running", health: "healthy" }, error: undefined });
|
||||
|
||||
render(<KubernetesStatus service={{ namespace: "ns", app: "app" }} />);
|
||||
|
||||
expect(screen.getByText("healthy")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a dot when style is dot", () => {
|
||||
useSWR.mockReturnValue({ data: { status: "running" }, error: undefined });
|
||||
|
||||
const { container } = render(<KubernetesStatus service={{ namespace: "ns", app: "app" }} style="dot" />);
|
||||
|
||||
expect(container.querySelector(".rounded-full")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders an error label when SWR returns an error", () => {
|
||||
useSWR.mockReturnValue({ data: undefined, error: new Error("nope") });
|
||||
|
||||
const { container } = render(<KubernetesStatus service={{ namespace: "ns", app: "app" }} />);
|
||||
|
||||
expect(screen.getByText("docker.error")).toBeInTheDocument();
|
||||
expect(container.querySelector(".k8s-status")?.getAttribute("title")).toBe("docker.error");
|
||||
});
|
||||
|
||||
it("renders orange status labels when the workload is down/partial/not found", () => {
|
||||
useSWR.mockReturnValue({ data: { status: "down" }, error: undefined });
|
||||
|
||||
const { container } = render(<KubernetesStatus service={{ namespace: "ns", app: "app" }} />);
|
||||
|
||||
expect(screen.getByText("down")).toBeInTheDocument();
|
||||
// Ensure the status is used as a tooltip/title too.
|
||||
expect(container.querySelector(".k8s-status")?.getAttribute("title")).toBe("down");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("components/services/item", () => ({
|
||||
default: function ServiceItemMock({ service, groupName, useEqualHeights }) {
|
||||
return (
|
||||
<li data-testid="service-item">
|
||||
{groupName}:{service.name}:{String(useEqualHeights)}
|
||||
</li>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
import List from "./list";
|
||||
|
||||
describe("components/services/list", () => {
|
||||
it("renders items and passes the computed useEqualHeights value", () => {
|
||||
render(
|
||||
<List
|
||||
groupName="G"
|
||||
services={[{ name: "A" }, { name: "B" }]}
|
||||
layout={{ useEqualHeights: true }}
|
||||
useEqualHeights={false}
|
||||
header
|
||||
/>,
|
||||
);
|
||||
|
||||
const items = screen.getAllByTestId("service-item");
|
||||
expect(items).toHaveLength(2);
|
||||
expect(items[0]).toHaveTextContent("G:A:true");
|
||||
expect(items[1]).toHaveTextContent("G:B:true");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { useSWR } = vi.hoisted(() => ({ useSWR: vi.fn() }));
|
||||
|
||||
vi.mock("swr", () => ({
|
||||
default: useSWR,
|
||||
}));
|
||||
|
||||
import Ping from "./ping";
|
||||
|
||||
describe("components/services/ping", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders a loading state when data is not available yet", () => {
|
||||
useSWR.mockReturnValue({ data: undefined, error: undefined });
|
||||
|
||||
render(<Ping groupName="g" serviceName="s" />);
|
||||
|
||||
expect(screen.getByText("ping.ping")).toBeInTheDocument();
|
||||
expect(screen.getByText("ping.ping").closest(".ping-status")).toHaveAttribute(
|
||||
"title",
|
||||
expect.stringContaining("ping.not_available"),
|
||||
);
|
||||
});
|
||||
|
||||
it("renders an error label when SWR returns error", () => {
|
||||
useSWR.mockReturnValue({ data: undefined, error: new Error("boom") });
|
||||
|
||||
render(<Ping groupName="g" serviceName="s" />);
|
||||
|
||||
expect(screen.getByText("ping.error")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders down when the host is not alive", () => {
|
||||
useSWR.mockReturnValue({ data: { alive: false, time: 0 }, error: undefined });
|
||||
|
||||
render(<Ping groupName="g" serviceName="s" />);
|
||||
|
||||
expect(screen.getByText("ping.down")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the ping time when the host is alive", () => {
|
||||
useSWR.mockReturnValue({ data: { alive: true, time: 123 }, error: undefined });
|
||||
|
||||
render(<Ping groupName="g" serviceName="s" />);
|
||||
|
||||
expect(useSWR).toHaveBeenCalledWith("/api/ping?groupName=g&serviceName=s", { refreshInterval: 30000 });
|
||||
expect(screen.getByText("123")).toBeInTheDocument();
|
||||
expect(screen.getByText("123").closest(".ping-status")).toHaveAttribute(
|
||||
"title",
|
||||
expect.stringContaining("ping.up"),
|
||||
);
|
||||
});
|
||||
|
||||
it("renders an up label for basic style", () => {
|
||||
useSWR.mockReturnValue({ data: { alive: true, time: 1 }, error: undefined });
|
||||
|
||||
render(<Ping groupName="g" serviceName="s" style="basic" />);
|
||||
|
||||
expect(screen.getByText("ping.up")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a dot when style is dot", () => {
|
||||
useSWR.mockReturnValue({ data: { alive: true, time: 5 }, error: undefined });
|
||||
|
||||
const { container } = render(<Ping groupName="g" serviceName="s" style="dot" />);
|
||||
|
||||
expect(screen.queryByText("5")).not.toBeInTheDocument();
|
||||
expect(container.querySelector(".rounded-full")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { useSWR } = vi.hoisted(() => ({ useSWR: vi.fn() }));
|
||||
|
||||
vi.mock("swr", () => ({
|
||||
default: useSWR,
|
||||
}));
|
||||
|
||||
import ProxmoxStatus from "./proxmox-status";
|
||||
|
||||
describe("components/services/proxmox-status", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders unknown when data is not available yet", () => {
|
||||
useSWR.mockReturnValue({ data: undefined, error: undefined });
|
||||
|
||||
render(<ProxmoxStatus service={{ proxmoxNode: "n1", proxmoxVMID: "100" }} />);
|
||||
|
||||
expect(screen.getByText("docker.unknown")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders error when SWR returns an error", () => {
|
||||
useSWR.mockReturnValue({ data: undefined, error: new Error("nope") });
|
||||
|
||||
render(<ProxmoxStatus service={{ proxmoxNode: "n1", proxmoxVMID: "100" }} />);
|
||||
|
||||
expect(screen.getByText("docker.error")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("requests vm stats and renders running when status is running", () => {
|
||||
useSWR.mockReturnValue({ data: { status: "running" }, error: undefined });
|
||||
|
||||
render(<ProxmoxStatus service={{ proxmoxNode: "n1", proxmoxVMID: "100" }} />);
|
||||
|
||||
expect(useSWR).toHaveBeenCalledWith("/api/proxmox/stats/n1/100?type=qemu");
|
||||
expect(screen.getByText("docker.running")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders paused for paused vms", () => {
|
||||
useSWR.mockReturnValue({ data: { status: "paused" }, error: undefined });
|
||||
|
||||
render(<ProxmoxStatus service={{ proxmoxNode: "n1", proxmoxVMID: "100", proxmoxType: "lxc" }} />);
|
||||
|
||||
expect(useSWR).toHaveBeenCalledWith("/api/proxmox/stats/n1/100?type=lxc");
|
||||
expect(screen.getByText("paused")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders other terminal statuses (stopped/offline/not found)", () => {
|
||||
useSWR.mockReturnValue({ data: { status: "stopped" }, error: undefined });
|
||||
render(<ProxmoxStatus service={{ proxmoxNode: "n1", proxmoxVMID: "100" }} />);
|
||||
expect(screen.getByText("docker.exited")).toBeInTheDocument();
|
||||
|
||||
useSWR.mockReturnValue({ data: { status: "offline" }, error: undefined });
|
||||
render(<ProxmoxStatus service={{ proxmoxNode: "n1", proxmoxVMID: "100" }} />);
|
||||
expect(screen.getByText("offline")).toBeInTheDocument();
|
||||
|
||||
useSWR.mockReturnValue({ data: { status: "not found" }, error: undefined });
|
||||
render(<ProxmoxStatus service={{ proxmoxNode: "n1", proxmoxVMID: "100" }} />);
|
||||
expect(screen.getByText("docker.not_found")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a dot status when style=dot", () => {
|
||||
useSWR.mockReturnValue({ data: { status: "running" }, error: undefined });
|
||||
|
||||
const { container } = render(<ProxmoxStatus service={{ proxmoxNode: "n1", proxmoxVMID: "100" }} style="dot" />);
|
||||
|
||||
expect(container.querySelector(".rounded-full")).toBeTruthy();
|
||||
expect(screen.queryByText("docker.running")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { useSWR } = vi.hoisted(() => ({ useSWR: vi.fn() }));
|
||||
|
||||
vi.mock("swr", () => ({
|
||||
default: useSWR,
|
||||
}));
|
||||
|
||||
import SiteMonitor from "./site-monitor";
|
||||
|
||||
describe("components/services/site-monitor", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders a loading state when data is not available yet", () => {
|
||||
useSWR.mockReturnValue({ data: undefined, error: undefined });
|
||||
|
||||
render(<SiteMonitor groupName="g" serviceName="s" />);
|
||||
|
||||
expect(screen.getByText("siteMonitor.response")).toBeInTheDocument();
|
||||
expect(screen.getByText("siteMonitor.response").closest(".site-monitor-status")).toHaveAttribute(
|
||||
"title",
|
||||
expect.stringContaining("siteMonitor.not_available"),
|
||||
);
|
||||
});
|
||||
|
||||
it("renders response time when status is up", () => {
|
||||
useSWR.mockReturnValue({ data: { status: 200, latency: 10 }, error: undefined });
|
||||
|
||||
render(<SiteMonitor groupName="g" serviceName="s" />);
|
||||
|
||||
expect(useSWR).toHaveBeenCalledWith("/api/siteMonitor?groupName=g&serviceName=s", { refreshInterval: 30000 });
|
||||
expect(screen.getByText("10")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders up label for basic style when status is ok", () => {
|
||||
useSWR.mockReturnValue({ data: { status: 200, latency: 1 }, error: undefined });
|
||||
|
||||
render(<SiteMonitor groupName="g" serviceName="s" style="basic" />);
|
||||
|
||||
expect(screen.getByText("siteMonitor.up")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders down label for failing status in basic style", () => {
|
||||
useSWR.mockReturnValue({ data: { status: 500, latency: 0 }, error: undefined });
|
||||
|
||||
render(<SiteMonitor groupName="g" serviceName="s" style="basic" />);
|
||||
|
||||
expect(screen.getByText("siteMonitor.down")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the http status code for failing status in non-basic style", () => {
|
||||
useSWR.mockReturnValue({ data: { status: 500, latency: 0 }, error: undefined });
|
||||
|
||||
render(<SiteMonitor groupName="g" serviceName="s" />);
|
||||
|
||||
expect(screen.getByText("500")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders an error label when SWR returns error", () => {
|
||||
useSWR.mockReturnValue({ data: undefined, error: new Error("boom") });
|
||||
|
||||
render(<SiteMonitor groupName="g" serviceName="s" />);
|
||||
|
||||
expect(screen.getByText("siteMonitor.error")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("treats an embedded data.error as an error state", () => {
|
||||
useSWR.mockReturnValue({ data: { error: "bad" }, error: undefined });
|
||||
|
||||
render(<SiteMonitor groupName="g" serviceName="s" />);
|
||||
|
||||
expect(screen.getByText("siteMonitor.error")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a dot when style is dot", () => {
|
||||
useSWR.mockReturnValue({ data: { status: 500, latency: 0 }, error: undefined });
|
||||
|
||||
const { container } = render(<SiteMonitor groupName="g" serviceName="s" style="dot" />);
|
||||
|
||||
expect(container.querySelector(".rounded-full")).toBeTruthy();
|
||||
expect(screen.queryByText("500")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { useSWR } = vi.hoisted(() => ({ useSWR: vi.fn() }));
|
||||
|
||||
vi.mock("swr", () => ({
|
||||
default: useSWR,
|
||||
}));
|
||||
|
||||
import Status from "./status";
|
||||
|
||||
describe("components/services/status", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("requests docker status and renders unknown by default", () => {
|
||||
useSWR.mockReturnValue({ data: undefined, error: undefined });
|
||||
|
||||
render(<Status service={{ container: "c", server: "s" }} />);
|
||||
|
||||
expect(useSWR).toHaveBeenCalledWith("/api/docker/status/c/s");
|
||||
expect(screen.getByText("docker.unknown")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders error when SWR fails", () => {
|
||||
useSWR.mockReturnValue({ data: undefined, error: new Error("nope") });
|
||||
|
||||
render(<Status service={{ container: "c", server: "s" }} />);
|
||||
|
||||
expect(screen.getByText("docker.error")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders healthy/unhealthy and partial/exited/not found statuses", () => {
|
||||
useSWR.mockReturnValue({ data: { status: "running", health: "healthy" }, error: undefined });
|
||||
render(<Status service={{ container: "c", server: "s" }} />);
|
||||
expect(screen.getByText("docker.healthy")).toBeInTheDocument();
|
||||
|
||||
useSWR.mockReturnValue({ data: { status: "running", health: "unhealthy" }, error: undefined });
|
||||
render(<Status service={{ container: "c", server: "s" }} />);
|
||||
expect(screen.getByText("docker.unhealthy")).toBeInTheDocument();
|
||||
|
||||
useSWR.mockReturnValue({ data: { status: "partial 1/2" }, error: undefined });
|
||||
render(<Status service={{ container: "c", server: "s" }} />);
|
||||
expect(screen.getByText("docker.partial 1/2")).toBeInTheDocument();
|
||||
|
||||
useSWR.mockReturnValue({ data: { status: "exited" }, error: undefined });
|
||||
render(<Status service={{ container: "c", server: "s" }} />);
|
||||
expect(screen.getByText("docker.exited")).toBeInTheDocument();
|
||||
|
||||
useSWR.mockReturnValue({ data: { status: "not found" }, error: undefined });
|
||||
render(<Status service={{ container: "c", server: "s" }} />);
|
||||
expect(screen.getByText("docker.not_found")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders starting health when container is running and starting", () => {
|
||||
useSWR.mockReturnValue({ data: { status: "running", health: "starting" }, error: undefined });
|
||||
|
||||
render(<Status service={{ container: "c", server: "s" }} />);
|
||||
|
||||
expect(screen.getByText("docker.starting")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a dot when style is dot", () => {
|
||||
useSWR.mockReturnValue({ data: { status: "running" }, error: undefined });
|
||||
|
||||
const { container } = render(<Status service={{ container: "c", server: "s" }} style="dot" />);
|
||||
|
||||
expect(screen.queryByText("docker.running")).not.toBeInTheDocument();
|
||||
expect(container.querySelector(".rounded-full")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -8,8 +8,7 @@ export default function Widget({ widget, service }) {
|
||||
|
||||
const ServiceWidget = components[widget.type];
|
||||
|
||||
const fullService = Object.apply({}, service);
|
||||
fullService.widget = widget;
|
||||
const fullService = { ...service, widget };
|
||||
if (ServiceWidget) {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("components/errorboundry", () => ({
|
||||
default: function ErrorBoundaryMock({ children }) {
|
||||
return <>{children}</>;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("widgets/components", () => ({
|
||||
default: {
|
||||
mock: function MockWidget({ service }) {
|
||||
return (
|
||||
<div data-testid="mock-service-widget">
|
||||
{service.name}:{service.widget?.type}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import Widget from "./widget";
|
||||
|
||||
describe("components/services/widget", () => {
|
||||
it("renders the mapped widget component and passes merged service.widget", () => {
|
||||
render(<Widget widget={{ type: "mock" }} service={{ name: "Svc" }} />);
|
||||
|
||||
expect(screen.getByTestId("mock-service-widget")).toHaveTextContent("Svc:mock");
|
||||
});
|
||||
|
||||
it("renders a missing widget message when the type is unknown", () => {
|
||||
render(<Widget widget={{ type: "nope" }} service={{ name: "Svc" }} />);
|
||||
|
||||
expect(screen.getByText("widget.missing_type")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import Block from "./block";
|
||||
import { BlockHighlightContext } from "./highlight-context";
|
||||
|
||||
import { renderWithProviders } from "test-utils/render-with-providers";
|
||||
|
||||
describe("components/services/widget/block", () => {
|
||||
it("renders a placeholder when value is undefined", () => {
|
||||
const { container } = renderWithProviders(<Block label="some.label" />, { settings: {} });
|
||||
|
||||
// Value slot is rendered as "-" while loading.
|
||||
expect(container.textContent).toContain("-");
|
||||
expect(container.textContent).toContain("some.label");
|
||||
});
|
||||
|
||||
it("sets highlight metadata when a rule matches", () => {
|
||||
const highlightConfig = {
|
||||
levels: { danger: "danger-class" },
|
||||
fields: {
|
||||
foo: {
|
||||
numeric: { when: "gt", value: 10, level: "danger" },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const { container } = renderWithProviders(
|
||||
<BlockHighlightContext.Provider value={highlightConfig}>
|
||||
<Block label="foo.label" field="foo" value="11" />
|
||||
</BlockHighlightContext.Provider>,
|
||||
{ settings: {} },
|
||||
);
|
||||
|
||||
const el = container.querySelector(".service-block");
|
||||
expect(el).not.toBeNull();
|
||||
expect(el.getAttribute("data-highlight-level")).toBe("danger");
|
||||
expect(el.className).toContain("danger-class");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { screen } from "@testing-library/react";
|
||||
import { useContext } from "react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { renderWithProviders } from "test-utils/render-with-providers";
|
||||
|
||||
import Container from "./container";
|
||||
import { BlockHighlightContext } from "./highlight-context";
|
||||
|
||||
function Dummy({ label }) {
|
||||
return <div data-testid={label} />;
|
||||
}
|
||||
|
||||
function HighlightProbe() {
|
||||
const value = useContext(BlockHighlightContext);
|
||||
return <div data-testid="highlight-probe" data-highlight={value ? "yes" : "no"} />;
|
||||
}
|
||||
|
||||
describe("components/services/widget/container", () => {
|
||||
it("filters children based on widget.fields (auto-namespaced by widget type)", () => {
|
||||
renderWithProviders(
|
||||
<Container service={{ widget: { type: "omada", fields: ["connectedAp", "alerts"] } }}>
|
||||
<Dummy label="omada.connectedAp" />
|
||||
<Dummy label="omada.alerts" />
|
||||
<Dummy label="omada.activeUser" />
|
||||
</Container>,
|
||||
{ settings: {} },
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("omada.connectedAp")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("omada.alerts")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("omada.activeUser")).toBeNull();
|
||||
});
|
||||
|
||||
it("accepts widget.fields as a JSON string", () => {
|
||||
renderWithProviders(
|
||||
<Container service={{ widget: { type: "omada", fields: JSON.stringify(["alerts"]) } }}>
|
||||
<Dummy label="omada.connectedAp" />
|
||||
<Dummy label="omada.alerts" />
|
||||
</Container>,
|
||||
{ settings: {} },
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("omada.alerts")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("omada.connectedAp")).toBeNull();
|
||||
});
|
||||
|
||||
it("supports aliased widget types when filtering (hoarder -> karakeep)", () => {
|
||||
renderWithProviders(
|
||||
<Container service={{ widget: { type: "hoarder", fields: ["hoarder.count"] } }}>
|
||||
<Dummy label="karakeep.count" />
|
||||
</Container>,
|
||||
{ settings: {} },
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("karakeep.count")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("returns null when errors are hidden via settings.hideErrors", () => {
|
||||
const { container } = renderWithProviders(
|
||||
<Container error="nope" service={{ widget: { type: "omada", hide_errors: false } }}>
|
||||
<Dummy label="omada.alerts" />
|
||||
</Container>,
|
||||
{ settings: { hideErrors: true } },
|
||||
);
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("skips the highlight provider when highlight levels are fully disabled", () => {
|
||||
renderWithProviders(
|
||||
<Container service={{ widget: { type: "omada" } }}>
|
||||
<HighlightProbe />
|
||||
</Container>,
|
||||
{
|
||||
settings: {
|
||||
blockHighlights: { levels: { good: null, warn: null, danger: null } },
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("highlight-probe").getAttribute("data-highlight")).toBe("no");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import Error from "./error";
|
||||
|
||||
describe("components/services/widget/error", () => {
|
||||
it("normalizes string errors to an object with a message", () => {
|
||||
render(<Error error="boom" />);
|
||||
|
||||
expect(screen.getByText((_, el) => el?.textContent === "widget.api_error:")).toBeInTheDocument();
|
||||
expect(screen.getByText(/boom/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("normalizes numeric errors to an object with a message", () => {
|
||||
render(<Error error={500} />);
|
||||
|
||||
expect(screen.getByText(/Error 500/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("unwraps nested response errors and renders raw/data sections", () => {
|
||||
render(
|
||||
<Error
|
||||
error={{
|
||||
message: "outer",
|
||||
data: {
|
||||
error: {
|
||||
message: "inner",
|
||||
url: "https://example.com",
|
||||
rawError: ["oops", { code: 1 }],
|
||||
data: { type: "Buffer", data: [97, 98] },
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(/inner/)).toBeInTheDocument();
|
||||
expect(screen.getByText("https://example.com")).toBeInTheDocument();
|
||||
expect(screen.getByText(/\"code\": 1/)).toBeInTheDocument();
|
||||
// Buffer.from({type:"Buffer",data:[97,98]}).toString() === "ab"
|
||||
expect(screen.getByText(/ab/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { useContext } from "react";
|
||||
|
||||
import { BlockHighlightContext } from "./highlight-context";
|
||||
|
||||
function Reader() {
|
||||
const value = useContext(BlockHighlightContext);
|
||||
return <div data-testid="value">{value === null ? "null" : value}</div>;
|
||||
}
|
||||
|
||||
describe("components/services/widget/highlight-context", () => {
|
||||
it("defaults to null", () => {
|
||||
render(<Reader />);
|
||||
expect(screen.getByTestId("value")).toHaveTextContent("null");
|
||||
});
|
||||
|
||||
it("provides a value to consumers", () => {
|
||||
render(
|
||||
<BlockHighlightContext.Provider value="on">
|
||||
<Reader />
|
||||
</BlockHighlightContext.Provider>,
|
||||
);
|
||||
expect(screen.getByTestId("value")).toHaveTextContent("on");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user