Chore: homepage tests (#6278)

This commit is contained in:
shamoon
2026-02-04 19:58:39 -08:00
committed by GitHub
parent 7d019185a3
commit 872a3600aa
558 changed files with 32606 additions and 84 deletions

View File

@@ -32,7 +32,7 @@ export default function BookmarksGroup({
layout?.header === false ? "px-1" : "p-1 pb-0",
)}
>
<Disclosure defaultOpen={!(layout?.initiallyCollapsed ?? groupsInitiallyCollapsed) ?? true}>
<Disclosure defaultOpen={!(layout?.initiallyCollapsed ?? groupsInitiallyCollapsed)}>
{({ open }) => (
<>
{layout?.header !== false && (

View File

@@ -0,0 +1,86 @@
// @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) {
// HeadlessUI uses a boolean `static` prop; avoid forwarding it to the DOM.
const { static: _static, ...rest } = props;
return <div ref={ref} data-testid="disclosure-panel" {...rest} />;
});
Disclosure.Button = DisclosureButton;
Disclosure.Panel = DisclosurePanel;
return { Disclosure, Transition };
});
vi.mock("components/bookmarks/list", () => ({
default: function BookmarksListMock({ bookmarks }) {
return <div data-testid="bookmarks-list">count:{bookmarks?.length ?? 0}</div>;
},
}));
vi.mock("components/errorboundry", () => ({
default: function ErrorBoundaryMock({ children }) {
return <>{children}</>;
},
}));
vi.mock("components/resolvedicon", () => ({
default: function ResolvedIconMock() {
return <div data-testid="resolved-icon" />;
},
}));
import BookmarksGroup from "./group";
describe("components/bookmarks/group", () => {
it("renders the group header and list", () => {
render(
<BookmarksGroup
bookmarks={{ name: "Bookmarks", bookmarks: [{ name: "A" }] }}
layout={{ icon: "mdi:test" }}
disableCollapse={false}
groupsInitiallyCollapsed={false}
/>,
);
expect(screen.getByText("Bookmarks")).toBeInTheDocument();
expect(screen.getByTestId("resolved-icon")).toBeInTheDocument();
expect(screen.getByTestId("bookmarks-list")).toHaveTextContent("count:1");
});
it("sets the panel height to 0 when initially collapsed", async () => {
render(
<BookmarksGroup
bookmarks={{ name: "Bookmarks", bookmarks: [] }}
layout={{ initiallyCollapsed: true }}
groupsInitiallyCollapsed={false}
/>,
);
const panel = screen.getByTestId("disclosure-panel");
await waitFor(() => {
expect(panel.style.height).toBe("0px");
});
});
});

View File

@@ -0,0 +1,95 @@
// @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(() => {
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) Object.defineProperty(node, "scrollHeight", { value: 50, 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/bookmarks/list", () => ({
default: function BookmarksListMock() {
return <div data-testid="bookmarks-list" />;
},
}));
vi.mock("components/errorboundry", () => ({
default: function ErrorBoundaryMock({ children }) {
return <>{children}</>;
},
}));
vi.mock("components/resolvedicon", () => ({
default: function ResolvedIconMock() {
return <div data-testid="resolved-icon" />;
},
}));
import BookmarksGroup from "./group";
describe("components/bookmarks/group transition hooks", () => {
it("runs the Transition beforeEnter/beforeLeave height calculations and applies maxGroupColumns", async () => {
vi.useFakeTimers();
render(
<BookmarksGroup
bookmarks={{ name: "Bookmarks", bookmarks: [] }}
layout={{ initiallyCollapsed: false }}
groupsInitiallyCollapsed={false}
maxGroupColumns="7"
/>,
);
const wrapper = screen.getByText("Bookmarks").closest(".bookmark-group");
expect(wrapper?.className).toContain("3xl:basis-1/7");
const panel = screen.getByTestId("disclosure-panel");
await act(async () => {
vi.runAllTimers();
});
expect(panel.style.height).toBe("0px");
vi.useRealTimers();
});
});

View File

@@ -0,0 +1,41 @@
// @vitest-environment jsdom
import { 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: ({ icon }) => <div data-testid="resolved-icon" data-icon={icon} />,
}));
import Item from "./item";
describe("components/bookmarks/item", () => {
it("falls back description to href hostname and uses settings.target", () => {
renderWithProviders(<Item bookmark={{ name: "A", href: "http://example.com/x", abbr: "A" }} iconOnly={false} />, {
settings: { target: "_self", cardBlur: "" },
});
expect(screen.getByText("example.com")).toBeInTheDocument();
expect(screen.getByRole("link").getAttribute("target")).toBe("_self");
});
it("renders icon-only layout with icon when provided", () => {
renderWithProviders(
<Item bookmark={{ name: "A", href: "http://example.com/x", abbr: "A", icon: "mdi-home" }} iconOnly />,
{ settings: { target: "_self" } },
);
expect(screen.getByTestId("resolved-icon").getAttribute("data-icon")).toBe("mdi-home");
});
it("renders the non-icon-only layout with an icon when provided", () => {
renderWithProviders(
<Item bookmark={{ name: "A", href: "http://example.com/x", abbr: "A", icon: "mdi-home" }} iconOnly={false} />,
{ settings: { target: "_self", cardBlur: "" } },
);
expect(screen.getByTestId("resolved-icon").getAttribute("data-icon")).toBe("mdi-home");
});
});

View File

@@ -0,0 +1,38 @@
// @vitest-environment jsdom
import { render } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const { Item } = vi.hoisted(() => ({
Item: vi.fn(({ bookmark, iconOnly }) => (
<li data-testid="bookmark-item" data-name={bookmark.name} data-icononly={String(iconOnly)} />
)),
}));
vi.mock("components/bookmarks/item", () => ({
default: Item,
}));
import List from "./list";
describe("components/bookmarks/list", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders items with iconOnly when iconsOnly is set", () => {
render(<List bookmarks={[{ name: "A", href: "http://a" }]} layout={{ iconsOnly: true }} bookmarksStyle="text" />);
expect(Item).toHaveBeenCalled();
expect(Item.mock.calls[0][0].iconOnly).toBe(true);
});
it("applies gridTemplateColumns in icons style", () => {
const { container } = render(
<List bookmarks={[{ name: "A", href: "http://a" }]} layout={{ header: false }} bookmarksStyle="icons" />,
);
const ul = container.querySelector("ul");
expect(ul.style.gridTemplateColumns).toContain("minmax(60px");
});
});

View File

@@ -0,0 +1,38 @@
// @vitest-environment jsdom
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import ErrorBoundary from "./errorboundry";
describe("components/errorboundry", () => {
it("renders children when no error is thrown", () => {
render(
<ErrorBoundary>
<div>ok</div>
</ErrorBoundary>,
);
expect(screen.getByText("ok")).toBeInTheDocument();
});
it("renders a fallback UI when a child throws", () => {
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
try {
const Boom = () => {
throw new Error("boom");
};
render(
<ErrorBoundary>
<Boom />
</ErrorBoundary>,
);
expect(screen.getByText("Something went wrong.")).toBeInTheDocument();
expect(screen.getByText("Error: boom")).toBeInTheDocument();
} finally {
consoleSpy.mockRestore();
}
});
});

View File

@@ -0,0 +1,74 @@
// @vitest-environment jsdom
import { render, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ColorContext } from "utils/contexts/color";
import Favicon from "./favicon";
describe("components/favicon", () => {
beforeEach(() => {
document.head.querySelectorAll('link[rel="shortcut icon"]').forEach((el) => el.remove());
});
it("appends a shortcut icon link after rendering the SVG to canvas", async () => {
const drawImage = vi.fn();
const getContextSpy = vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue({ drawImage });
const toDataURLSpy = vi
.spyOn(HTMLCanvasElement.prototype, "toDataURL")
.mockReturnValue("data:image/x-icon;base64,AAA");
const { container } = render(
<ColorContext.Provider value={{ color: "slate", setColor: vi.fn() }}>
<Favicon />
</ColorContext.Provider>,
);
const img = container.querySelector("img");
await waitFor(() => {
expect(typeof img.onload).toBe("function");
});
img.onload();
const link = document.head.querySelector('link[rel="shortcut icon"]');
expect(link).not.toBeNull();
expect(link.getAttribute("href")).toBe("data:image/x-icon;base64,AAA");
expect(drawImage).toHaveBeenCalled();
getContextSpy.mockRestore();
toDataURLSpy.mockRestore();
});
it("returns early when refs are missing (defensive guard)", async () => {
vi.resetModules();
vi.doMock("react", async () => {
const actual = await vi.importActual("react");
return {
...actual,
// Run the effect immediately to hit the defensive guard before refs are attached.
useEffect: (fn) => fn(),
};
});
const { ColorContext: TestColorContext } = await import("utils/contexts/color");
const { default: FaviconWithMissingRefs } = await import("./favicon");
const { container } = render(
<TestColorContext.Provider value={{ color: "slate", setColor: vi.fn() }}>
<FaviconWithMissingRefs />
</TestColorContext.Provider>,
);
// Allow effects to flush; the guard should prevent the icon link from being appended.
await waitFor(() => {
expect(container.querySelector("img")).toBeTruthy();
});
expect(document.head.querySelector('link[rel="shortcut icon"]')).toBeNull();
vi.unmock("react");
vi.resetModules();
});
});

View File

@@ -0,0 +1,390 @@
// @vitest-environment jsdom
import { act, fireEvent, screen, waitFor } from "@testing-library/react";
import { useState } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
const { state, useSWR, getStoredProvider } = vi.hoisted(() => ({
state: {
widgets: {},
},
useSWR: vi.fn((key) => {
if (key === "/api/widgets") return { data: state.widgets, error: undefined };
return { data: undefined, error: undefined };
}),
getStoredProvider: vi.fn(() => null),
}));
vi.mock("swr", () => ({
default: useSWR,
}));
vi.mock("./resolvedicon", () => ({
default: function ResolvedIconMock() {
return <div data-testid="resolved-icon" />;
},
}));
vi.mock("./widgets/search/search", () => ({
getStoredProvider,
searchProviders: {
duckduckgo: {
name: "DuckDuckGo",
url: "https://duckduckgo.example/?q=",
suggestionUrl: "https://duckduckgo.example/ac/?q=",
target: "_self",
},
},
}));
import QuickLaunch from "./quicklaunch";
function Wrapper({ servicesAndBookmarks = [], initialOpen = true } = {}) {
const [searchString, setSearchString] = useState("");
const [isOpen, setSearching] = useState(initialOpen);
return (
<QuickLaunch
servicesAndBookmarks={servicesAndBookmarks}
searchString={searchString}
setSearchString={setSearchString}
isOpen={isOpen}
setSearching={setSearching}
/>
);
}
describe("components/quicklaunch", () => {
beforeEach(() => {
vi.clearAllMocks();
state.widgets = {};
});
it("uses a custom provider from quicklaunch settings when configured", async () => {
renderWithProviders(<Wrapper />, {
settings: {
quicklaunch: {
provider: "custom",
name: "MySearch",
url: "https://custom.example/?q=",
showSearchSuggestions: false,
},
},
});
const input = screen.getByPlaceholderText("Search");
await waitFor(() => expect(input).toHaveFocus());
fireEvent.change(input, { target: { value: "abc" } });
expect(await screen.findByText("MySearch quicklaunch.search")).toBeInTheDocument();
});
it("uses the search widget's custom provider configuration when quicklaunch settings are not provided", async () => {
state.widgets = {
w: {
type: "search",
options: { provider: "custom", name: "WidgetSearch", url: "https://widget.example/?q=" },
},
};
renderWithProviders(<Wrapper />, { settings: { quicklaunch: { showSearchSuggestions: false } } });
const input = screen.getByPlaceholderText("Search");
await waitFor(() => expect(input).toHaveFocus());
fireEvent.change(input, { target: { value: "abc" } });
expect(await screen.findByText("WidgetSearch quicklaunch.search")).toBeInTheDocument();
});
it("uses the search widget's provider setting when quicklaunch settings are not provided", async () => {
state.widgets = {
w: {
type: "search",
options: { provider: "duckduckgo" },
},
};
renderWithProviders(<Wrapper />, { settings: { quicklaunch: { showSearchSuggestions: false } } });
const input = screen.getByPlaceholderText("Search");
await waitFor(() => expect(input).toHaveFocus());
fireEvent.change(input, { target: { value: "abc" } });
expect(await screen.findByText("DuckDuckGo quicklaunch.search")).toBeInTheDocument();
});
it("renders results for urls and opens the selected result on Enter", async () => {
const openSpy = vi.spyOn(window, "open").mockImplementation(() => null);
renderWithProviders(<Wrapper />, {
settings: {
target: "_self",
quicklaunch: {
provider: "duckduckgo",
showSearchSuggestions: false,
},
},
});
const input = screen.getByPlaceholderText("Search");
await waitFor(() => expect(input).toHaveFocus());
fireEvent.change(input, { target: { value: "example.com" } });
expect(await screen.findByText("quicklaunch.visit URL")).toBeInTheDocument();
expect(screen.getByText("DuckDuckGo quicklaunch.search")).toBeInTheDocument();
fireEvent.keyDown(input, { key: "Enter" });
await act(async () => {
// Close/reset schedules timeouts (200ms + 300ms); flush them to avoid state updates after cleanup.
await new Promise((r) => setTimeout(r, 350));
});
expect(openSpy).toHaveBeenCalledWith("https://example.com/", "_self", "noreferrer");
openSpy.mockRestore();
});
it("closes on Escape and clears the search string after the timeout", async () => {
renderWithProviders(<Wrapper />, {
settings: {
quicklaunch: {
provider: "duckduckgo",
showSearchSuggestions: false,
},
},
});
const input = screen.getByPlaceholderText("Search");
await waitFor(() => expect(input).toHaveFocus());
fireEvent.change(input, { target: { value: "abc" } });
expect(input).toHaveValue("abc");
fireEvent.keyDown(input, { key: "Escape" });
await act(async () => {
await new Promise((r) => setTimeout(r, 350));
});
expect(input).toHaveValue("");
});
it("supports ArrowUp/ArrowDown navigation and opens a result on click", async () => {
const openSpy = vi.spyOn(window, "open").mockImplementation(() => null);
renderWithProviders(
<Wrapper
servicesAndBookmarks={[
{ name: "Alpha", href: "https://alpha.example", icon: "mdi:test" },
{ name: "Alpine", href: "https://alpine.example" },
]}
/>,
{ settings: { target: "_self", quicklaunch: { showSearchSuggestions: false } } },
);
const input = screen.getByPlaceholderText("Search");
await waitFor(() => expect(input).toHaveFocus());
fireEvent.change(input, { target: { value: "al" } });
await waitFor(() => {
expect(document.querySelector('button[data-index="0"]')).toBeTruthy();
expect(document.querySelector('button[data-index="1"]')).toBeTruthy();
});
// Icon/abbr container renders when icon is present.
expect(screen.getByTestId("resolved-icon")).toBeInTheDocument();
const button0 = document.querySelector('button[data-index="0"]');
const button1 = document.querySelector('button[data-index="1"]');
expect(button0.className).toContain("bg-theme-300/50");
fireEvent.keyDown(input, { key: "ArrowDown" });
expect(button1.className).toContain("bg-theme-300/50");
fireEvent.keyDown(input, { key: "ArrowUp" });
expect(button0.className).toContain("bg-theme-300/50");
fireEvent.click(button0);
await act(async () => {
await new Promise((r) => setTimeout(r, 350));
});
expect(openSpy).toHaveBeenCalledWith("https://alpha.example", "_self", "noreferrer");
openSpy.mockRestore();
});
it("handles Escape on a result button (not just the input)", async () => {
renderWithProviders(<Wrapper servicesAndBookmarks={[{ name: "Alpha", href: "https://alpha.example" }]} />, {
settings: { quicklaunch: { showSearchSuggestions: false } },
});
const input = screen.getByPlaceholderText("Search");
await waitFor(() => expect(input).toHaveFocus());
fireEvent.change(input, { target: { value: "al" } });
await waitFor(() => expect(document.querySelector('button[data-index="0"]')).toBeTruthy());
const button0 = document.querySelector('button[data-index="0"]');
button0.focus();
fireEvent.keyDown(button0, { key: "Escape" });
await act(async () => {
await new Promise((r) => setTimeout(r, 350));
});
expect(input).toHaveValue("");
});
it("highlights matching description text when searchDescriptions is enabled", async () => {
renderWithProviders(
<Wrapper
servicesAndBookmarks={[
{ name: "Unrelated", description: "This has MatchMe inside", href: "https://example.com" },
]}
/>,
{
settings: {
quicklaunch: {
provider: "duckduckgo",
searchDescriptions: true,
showSearchSuggestions: false,
},
},
},
);
const input = screen.getByPlaceholderText("Search");
await waitFor(() => expect(input).toHaveFocus());
fireEvent.change(input, { target: { value: "matchme" } });
// A description-only match uses highlightText (bg-theme-300/10).
const highlight = await screen.findByText(/matchme/i);
expect(highlight.closest("span")?.className).toContain("bg-theme-300/10");
});
it("fetches search suggestions and ArrowRight autocompletes the selected suggestion", async () => {
const originalFetch = globalThis.fetch;
const fetchSpy = vi.fn(async () => ({
json: async () => ["test", ["test 1", "test 2", "test 3", "test 4", "test 5"]],
}));
// eslint-disable-next-line no-global-assign
fetch = fetchSpy;
renderWithProviders(<Wrapper />, {
settings: {
quicklaunch: {
provider: "duckduckgo",
showSearchSuggestions: true,
},
},
});
const input = screen.getByPlaceholderText("Search");
await waitFor(() => expect(input).toHaveFocus());
fireEvent.change(input, { target: { value: "test" } });
// Suggestions are fetched via the API route.
await waitFor(() => {
expect(fetchSpy).toHaveBeenCalledWith(
expect.stringContaining("/api/search/searchSuggestion?query=test"),
expect.objectContaining({ signal: expect.any(AbortSignal) }),
);
});
await waitFor(() => {
expect(screen.getAllByText("quicklaunch.searchsuggestion").length).toBeGreaterThan(0);
});
const suggestionButton = Array.from(document.querySelectorAll("button")).find((btn) =>
btn.textContent?.includes("test 1"),
);
expect(suggestionButton).toBeTruthy();
fireEvent.mouseEnter(suggestionButton);
fireEvent.keyDown(input, { key: "ArrowRight" });
expect(input).toHaveValue("test 1");
// eslint-disable-next-line no-global-assign
fetch = originalFetch;
});
it("uses the stored provider when the search widget provides a provider list", async () => {
state.widgets = {
w: {
type: "search",
options: { provider: ["duckduckgo"] },
},
};
getStoredProvider.mockReturnValue({
name: "StoredProvider",
url: "https://stored.example/?q=",
suggestionUrl: "https://stored.example/ac/?q=",
});
renderWithProviders(<Wrapper />, { settings: { quicklaunch: { showSearchSuggestions: false } } });
const input = screen.getByPlaceholderText("Search");
await waitFor(() => expect(input).toHaveFocus());
fireEvent.change(input, { target: { value: "abc" } });
expect(await screen.findByText("StoredProvider quicklaunch.search")).toBeInTheDocument();
});
it("renders the mobile button when configured and opens the dialog when clicked", async () => {
renderWithProviders(<Wrapper initialOpen={false} />, {
settings: {
quicklaunch: {
mobileButtonPosition: "top-right",
provider: "duckduckgo",
},
},
});
const mobileButton = screen.getByRole("button", { name: "" });
expect(mobileButton.className).toContain("top-4 right-4");
fireEvent.click(mobileButton);
const input = await screen.findByPlaceholderText("Search");
await waitFor(() => expect(input).toHaveFocus());
});
it("closes when the backdrop is clicked and clears the search string after the timeout", async () => {
renderWithProviders(<Wrapper />, {
settings: {
quicklaunch: {
provider: "duckduckgo",
showSearchSuggestions: false,
},
},
});
const input = screen.getByPlaceholderText("Search");
await waitFor(() => expect(input).toHaveFocus());
fireEvent.change(input, { target: { value: "example.com" } });
expect(input).toHaveValue("example.com");
// The backdrop is a DIV; clicking it should close and schedule a reset.
const backdrop = document.querySelector(".fixed.inset-0.bg-gray-500.opacity-50");
expect(backdrop).toBeTruthy();
fireEvent.click(backdrop);
await act(async () => {
await new Promise((r) => setTimeout(r, 350));
});
expect(input).toHaveValue("");
});
});

View File

@@ -0,0 +1,82 @@
// @vitest-environment jsdom
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { SettingsContext } from "utils/contexts/settings";
import { ThemeContext } from "utils/contexts/theme";
vi.mock("next/image", () => ({
default: ({ src, alt }) => <div data-testid="next-image" data-src={src} data-alt={alt} />,
}));
import ResolvedIcon from "./resolvedicon";
function renderWithContexts(ui, { settings = {}, theme = "dark" } = {}) {
return render(
<SettingsContext.Provider value={{ settings, setSettings: () => {} }}>
<ThemeContext.Provider value={{ theme, setTheme: vi.fn() }}>{ui}</ThemeContext.Provider>
</SettingsContext.Provider>,
);
}
describe("components/resolvedicon", () => {
it("renders direct URL icons via next/image", () => {
renderWithContexts(<ResolvedIcon icon="http://example.com/x.png" alt="x" />);
expect(screen.getByTestId("next-image").getAttribute("data-src")).toBe("http://example.com/x.png");
});
it("renders relative URL icons via next/image", () => {
renderWithContexts(<ResolvedIcon icon="/icons/x.png" alt="x" />);
expect(screen.getByTestId("next-image").getAttribute("data-src")).toBe("/icons/x.png");
});
it("renders selfh.st icons for sh- prefix with extension", () => {
renderWithContexts(<ResolvedIcon icon="sh-test.webp" alt="x" />);
expect(screen.getByTestId("next-image").getAttribute("data-src")).toContain("/webp/test.webp");
});
it("renders selfh.st icons as svg or png based on file extension", () => {
renderWithContexts(<ResolvedIcon icon="sh-test.svg" alt="x" />);
expect(screen.getByTestId("next-image").getAttribute("data-src")).toContain("/svg/test.svg");
renderWithContexts(<ResolvedIcon icon="sh-test.png" alt="x" />);
expect(screen.getAllByTestId("next-image")[1].getAttribute("data-src")).toContain("/png/test.png");
});
it("renders mdi icons as a masked div and supports custom hex colors", () => {
const { container } = renderWithContexts(<ResolvedIcon icon="mdi-home-#ff00ff" />, {
settings: { iconStyle: "theme" },
theme: "dark",
});
const div = container.querySelector("div");
// Browser normalizes hex colors to rgb() strings on assignment.
expect(div.style.background).toMatch(/(#ff00ff|rgb\(255, 0, 255\))/);
expect(div.getAttribute("style")).toContain("home.svg");
});
it("renders si icons with a masked div using the configured icon style", () => {
const { container } = renderWithContexts(<ResolvedIcon icon="si-github" />, {
settings: { iconStyle: "gradient" },
theme: "light",
});
const div = container.querySelector("div");
expect(div.getAttribute("style")).toContain("github.svg");
expect(div.style.background).toContain("linear-gradient");
});
it("falls back to dashboard-icons for .svg", () => {
renderWithContexts(<ResolvedIcon icon="foo.svg" />);
expect(screen.getByTestId("next-image").getAttribute("data-src")).toContain("/dashboard-icons/svg/foo.svg");
});
it("falls back to dashboard-icons for .webp and .png", () => {
renderWithContexts(<ResolvedIcon icon="foo.webp" />);
expect(screen.getAllByTestId("next-image")[0].getAttribute("data-src")).toContain("/dashboard-icons/webp/foo.webp");
renderWithContexts(<ResolvedIcon icon="foo.png" />);
expect(screen.getAllByTestId("next-image")[1].getAttribute("data-src")).toContain("/dashboard-icons/png/foo.png");
});
});

View File

@@ -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");
});
});

View File

@@ -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 && (

View File

@@ -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");
});
});
});

View File

@@ -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();
});
});

View File

@@ -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();
});
});

View File

@@ -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");
});
});

View File

@@ -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");
});
});

View File

@@ -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();
});
});

View File

@@ -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();
});
});

View File

@@ -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();
});
});

View File

@@ -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();
});
});

View File

@@ -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>

View File

@@ -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();
});
});

View File

@@ -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");
});
});

View File

@@ -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");
});
});

View File

@@ -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();
});
});

View File

@@ -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");
});
});

View File

@@ -0,0 +1,32 @@
// @vitest-environment jsdom
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { TabContext } from "utils/contexts/tab";
import Tab, { slugifyAndEncode } from "./tab";
describe("components/tab", () => {
it("slugifyAndEncode lowercases and encodes spaces", () => {
expect(slugifyAndEncode("My Tab")).toBe("my-tab");
expect(slugifyAndEncode(undefined)).toBe("");
});
it("marks the matching tab as selected and updates hash on click", () => {
const setActiveTab = vi.fn();
render(
<TabContext.Provider value={{ activeTab: "my-tab", setActiveTab }}>
<Tab tab="My Tab" />
</TabContext.Provider>,
);
const btn = screen.getByRole("tab");
expect(btn.getAttribute("aria-selected")).toBe("true");
fireEvent.click(btn);
expect(setActiveTab).toHaveBeenCalledWith("my-tab");
expect(window.location.hash).toBe("#my-tab");
});
});

View File

@@ -0,0 +1,59 @@
// @vitest-environment jsdom
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { ColorContext } from "utils/contexts/color";
// Stub Popover/Transition to always render children.
vi.mock("@headlessui/react", async () => {
const React = await import("react");
const { Fragment } = React;
function passthrough({ as: As = "div", children, ...props }) {
if (As === Fragment) return <>{typeof children === "function" ? children({ open: true }) : children}</>;
const content = typeof children === "function" ? children({ open: true }) : children;
return <As {...props}>{content}</As>;
}
function Popover({ children }) {
return <div>{typeof children === "function" ? children({ open: true }) : children}</div>;
}
function PopoverButton(props) {
return <button type="button" {...props} />;
}
function PopoverPanel(props) {
return <div {...props} />;
}
Popover.Button = PopoverButton;
Popover.Panel = PopoverPanel;
return { Popover, Transition: passthrough };
});
import ColorToggle from "./color";
describe("components/toggles/color", () => {
it("renders nothing when no active color is set", () => {
const { container } = render(
<ColorContext.Provider value={{ color: null, setColor: vi.fn() }}>
<ColorToggle />
</ColorContext.Provider>,
);
expect(container).toBeEmptyDOMElement();
});
it("invokes setColor when a color button is clicked", () => {
const setColor = vi.fn();
render(
<ColorContext.Provider value={{ color: "slate", setColor }}>
<ColorToggle />
</ColorContext.Provider>,
);
// Buttons contain a sr-only span with the color name.
const blue = screen.getByText("blue").closest("button");
fireEvent.click(blue);
expect(setColor).toHaveBeenCalledWith("blue");
});
});

View File

@@ -0,0 +1,27 @@
// @vitest-environment jsdom
import { fireEvent, render } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import Revalidate from "./revalidate";
describe("components/toggles/revalidate", () => {
it("calls /api/revalidate and reloads when ok", async () => {
const reload = vi.fn();
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ ok: true });
vi.stubGlobal("location", { reload });
render(<Revalidate />);
const icon = document.querySelector("svg");
fireEvent.click(icon);
// allow promise chain to flush
await Promise.resolve();
expect(fetchSpy).toHaveBeenCalledWith("/api/revalidate");
expect(reload).toHaveBeenCalledTimes(1);
fetchSpy.mockRestore();
vi.unstubAllGlobals();
});
});

View File

@@ -0,0 +1,46 @@
// @vitest-environment jsdom
import { fireEvent, render } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { ThemeContext } from "utils/contexts/theme";
import ThemeToggle from "./theme";
describe("components/toggles/theme", () => {
it("renders nothing when theme is missing", () => {
const { container } = render(
<ThemeContext.Provider value={{ theme: null, setTheme: vi.fn() }}>
<ThemeToggle />
</ThemeContext.Provider>,
);
expect(container).toBeEmptyDOMElement();
});
it("toggles from dark to light when clicked", () => {
const setTheme = vi.fn();
render(
<ThemeContext.Provider value={{ theme: "dark", setTheme }}>
<ThemeToggle />
</ThemeContext.Provider>,
);
// The toggle is a clickable icon rendered as an svg (react-icons).
const toggles = document.querySelectorAll("svg");
fireEvent.click(toggles[1]);
expect(setTheme).toHaveBeenCalledWith("light");
});
it("toggles from light to dark when clicked", () => {
const setTheme = vi.fn();
render(
<ThemeContext.Provider value={{ theme: "light", setTheme }}>
<ThemeToggle />
</ThemeContext.Provider>,
);
const toggles = document.querySelectorAll("svg");
fireEvent.click(toggles[1]);
expect(setTheme).toHaveBeenCalledWith("dark");
});
});

View File

@@ -0,0 +1,85 @@
// @vitest-environment jsdom
import { render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const { cache, cv, useSWR } = vi.hoisted(() => ({
cache: {
get: vi.fn(),
put: vi.fn(),
},
cv: {
validate: vi.fn(),
compareVersions: vi.fn(),
},
useSWR: vi.fn(),
}));
vi.mock("memory-cache", () => ({
default: cache,
}));
vi.mock("compare-versions", () => ({
validate: cv.validate,
compareVersions: cv.compareVersions,
}));
vi.mock("swr", () => ({
default: useSWR,
}));
import Version from "./version";
describe("components/version", () => {
beforeEach(() => {
vi.clearAllMocks();
process.env.NEXT_PUBLIC_VERSION = "dev";
process.env.NEXT_PUBLIC_REVISION = "abcdef012345";
process.env.NEXT_PUBLIC_BUILDTIME = "2020-01-01T00:00:00.000Z";
});
it("renders non-link version text for dev/main/nightly", () => {
cv.validate.mockReturnValue(false);
cache.get.mockReturnValue(null);
useSWR.mockReturnValue({ data: undefined });
render(<Version />);
expect(screen.getByText(/dev \(abcdef0/)).toBeInTheDocument();
expect(screen.queryAllByRole("link")).toHaveLength(0);
});
it("renders tag link and shows update available when a newer release exists", () => {
process.env.NEXT_PUBLIC_VERSION = "1.2.3";
cv.validate.mockReturnValue(true);
cache.get.mockReturnValue(null);
useSWR.mockReturnValue({
data: [{ tag_name: "1.2.4", html_url: "http://example.com/release" }],
});
cv.compareVersions.mockReturnValue(1);
render(<Version />);
const links = screen.getAllByRole("link");
expect(links.find((a) => a.getAttribute("href")?.includes("/releases/tag/1.2.3"))).toBeTruthy();
expect(links.find((a) => a.getAttribute("href") === "http://example.com/release")).toBeTruthy();
});
it("falls back build time to the current date when NEXT_PUBLIC_BUILDTIME is missing", () => {
vi.useFakeTimers();
try {
vi.setSystemTime(new Date("2021-01-02T12:00:00.000Z"));
process.env.NEXT_PUBLIC_BUILDTIME = "";
cv.validate.mockReturnValue(false);
cache.get.mockReturnValue(null);
useSWR.mockReturnValue({ data: undefined });
render(<Version />);
expect(screen.getByText(/2021/)).toBeInTheDocument();
} finally {
vi.useRealTimers();
}
});
});

View File

@@ -0,0 +1,32 @@
// @vitest-environment jsdom
import { screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import DateTime from "./datetime";
describe("components/widgets/datetime", () => {
it("renders formatted date/time and updates on an interval", async () => {
vi.useFakeTimers();
try {
vi.setSystemTime(new Date("2020-01-01T00:00:00.000Z"));
const format = { timeZone: "UTC", hour: "2-digit", minute: "2-digit", second: "2-digit" };
const expected0 = new Intl.DateTimeFormat("en-US", format).format(new Date());
renderWithProviders(<DateTime options={{ locale: "en-US", format }} />, { settings: { target: "_self" } });
// `render` wraps in `act`, so effects should flush synchronously.
expect(screen.getByText(expected0)).toBeInTheDocument();
await vi.advanceTimersByTimeAsync(1000);
const expected1 = new Intl.DateTimeFormat("en-US", format).format(new Date());
expect(screen.getByText(expected1)).toBeInTheDocument();
} finally {
vi.useRealTimers();
}
});
});

View File

@@ -0,0 +1,166 @@
// @vitest-environment jsdom
import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
const { useSWR } = vi.hoisted(() => ({ useSWR: vi.fn() }));
vi.mock("swr", () => ({ default: useSWR }));
import Glances from "./glances";
describe("components/widgets/glances", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders an error state when SWR errors", () => {
useSWR.mockReturnValue({ data: undefined, error: new Error("nope") });
renderWithProviders(<Glances options={{ cpu: true, mem: true }} />, { settings: { target: "_self" } });
expect(screen.getByText("widget.api_error")).toBeInTheDocument();
});
it("renders placeholder resources while loading", () => {
useSWR.mockReturnValue({ data: undefined, error: undefined });
renderWithProviders(<Glances options={{ cpu: true, mem: true, cputemp: true, disk: "/", uptime: true }} />, {
settings: { target: "_self" },
});
// All placeholders use glances.wait.
expect(screen.getAllByText("glances.wait").length).toBeGreaterThan(0);
});
it("renders placeholder disk resources when loading and disk is an array", () => {
useSWR.mockReturnValue({ data: undefined, error: undefined });
renderWithProviders(<Glances options={{ disk: ["/", "/data"] }} />, { settings: { target: "_self" } });
expect(screen.getAllByText("glances.wait").length).toBeGreaterThan(0);
});
it("renders cpu percent and memory available when data is present", () => {
useSWR.mockReturnValue({
data: {
cpu: { total: 12.34 },
load: { min15: 5 },
mem: { available: 1024, total: 2048, percent: 50 },
fs: [{ mnt_point: "/", free: 100, size: 200, percent: 50 }],
sensors: [],
uptime: "1 days, 00:00:00",
},
error: undefined,
});
renderWithProviders(<Glances options={{ cpu: true, mem: true, disk: "/", uptime: true }} />, {
settings: { target: "_self" },
});
// common.number is mocked to return the numeric value as a string.
expect(screen.getByText("12.34")).toBeInTheDocument();
// common.bytes is mocked similarly; we just assert the numeric value is present.
expect(screen.getByText("1024")).toBeInTheDocument();
});
it("handles cpu sensor retrieval failures gracefully", () => {
const sensor = {
label: "cpu_thermal-0",
type: "temperature_core",
get value() {
throw new Error("boom");
},
warning: 90,
};
useSWR.mockReturnValue({
data: {
cpu: { total: 1 },
load: { min15: 1 },
mem: { available: 1, total: 1, percent: 1 },
fs: [],
sensors: [sensor],
},
error: undefined,
});
renderWithProviders(<Glances options={{ cputemp: true }} />, { settings: { target: "_self" } });
// When sensor processing fails, it should not render the temp block.
expect(screen.queryByText("glances.temp")).toBeNull();
expect(screen.getByText("glances.cpu")).toBeInTheDocument();
});
it("renders temperature in fahrenheit for matching cpu sensors and marks the widget expanded", () => {
useSWR.mockReturnValue({
data: {
cpu: { total: 1 },
load: { min15: 1 },
mem: { available: 1, total: 1, percent: 1 },
fs: [],
sensors: [
{ label: "cpu_thermal-0", type: "temperature_core", value: 40, warning: 90 },
{ label: "Core 1", type: "temperature_core", value: 50, warning: 100 },
],
},
error: undefined,
});
renderWithProviders(
<Glances options={{ cputemp: true, units: "imperial", expanded: true, url: "http://glances" }} />,
{
settings: { target: "_self" },
},
);
// avg(40,50)=45C => 113F
expect(screen.getByText("113")).toBeInTheDocument();
expect(screen.getByRole("link")).toHaveClass("expanded");
});
it("renders disk resources for an array of mount points and filters missing mounts", () => {
useSWR.mockReturnValue({
data: {
cpu: { total: 1 },
load: { min15: 1 },
mem: { available: 1, total: 1, percent: 1 },
fs: [{ mnt_point: "/", free: 10, size: 20, percent: 50 }],
sensors: [],
},
error: undefined,
});
renderWithProviders(
<Glances options={{ disk: ["/", "/missing"], diskUnits: "bbytes", expanded: true, url: "http://glances" }} />,
{
settings: { target: "_self" },
},
);
// only one mount exists, but both free + total values should render for it
expect(screen.getByText("10")).toBeInTheDocument();
expect(screen.getByText("20")).toBeInTheDocument();
});
it("formats uptime into translated day/hour labels", () => {
useSWR.mockReturnValue({
data: {
cpu: { total: 1 },
load: { min15: 1 },
mem: { available: 1, total: 1, percent: 1 },
fs: [],
sensors: [],
uptime: "1 days, 00:00:00",
},
error: undefined,
});
renderWithProviders(<Glances options={{ uptime: true, url: "http://glances" }} />, {
settings: { target: "_self" },
});
expect(screen.getByText("1glances.days 00glances.hours")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,20 @@
// @vitest-environment jsdom
import { screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import Greeting from "./greeting";
describe("components/widgets/greeting", () => {
it("renders nothing when text is not configured", () => {
const { container } = renderWithProviders(<Greeting options={{}} />, { settings: { target: "_self" } });
expect(container).toBeEmptyDOMElement();
});
it("renders configured greeting text", () => {
renderWithProviders(<Greeting options={{ text: "Hello there" }} />, { settings: { target: "_self" } });
expect(screen.getByText("Hello there")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,55 @@
// @vitest-environment jsdom
import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
const { useSWR } = vi.hoisted(() => ({ useSWR: vi.fn() }));
vi.mock("swr", () => ({ default: useSWR }));
vi.mock("./node", () => ({
default: ({ type }) => <div data-testid="kube-node" data-type={type} />,
}));
import Kubernetes from "./kubernetes";
describe("components/widgets/kubernetes", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders an error state when SWR errors", () => {
useSWR.mockReturnValue({ data: undefined, error: new Error("nope") });
renderWithProviders(<Kubernetes options={{ cluster: { show: true }, nodes: { show: true } }} />, {
settings: { target: "_self" },
});
expect(screen.getByText("widget.api_error")).toBeInTheDocument();
});
it("renders placeholder nodes while loading", () => {
useSWR.mockReturnValue({ data: undefined, error: undefined });
renderWithProviders(<Kubernetes options={{ cluster: { show: true }, nodes: { show: true } }} />, {
settings: { target: "_self" },
});
expect(screen.getAllByTestId("kube-node").map((n) => n.getAttribute("data-type"))).toEqual(["cluster", "node"]);
});
it("renders a node per returned entry when data is available", () => {
useSWR.mockReturnValue({
data: { cluster: {}, nodes: [{ name: "n1" }, { name: "n2" }] },
error: undefined,
});
renderWithProviders(<Kubernetes options={{ cluster: { show: true }, nodes: { show: true } }} />, {
settings: { target: "_self" },
});
// cluster + 2 nodes
expect(screen.getAllByTestId("kube-node")).toHaveLength(3);
});
});

View File

@@ -0,0 +1,35 @@
// @vitest-environment jsdom
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import Node from "./node";
describe("components/widgets/kubernetes/node", () => {
it("renders cluster label when showLabel is enabled", () => {
const data = { cpu: { percent: 50 }, memory: { free: 123, percent: 10 } };
const { container } = render(<Node type="cluster" options={{ showLabel: true, label: "Cluster A" }} data={data} />);
expect(screen.getByText("50")).toBeInTheDocument();
expect(screen.getByText("123")).toBeInTheDocument();
expect(screen.getByText("Cluster A")).toBeInTheDocument();
expect(container.querySelectorAll('div[style*="width:"]').length).toBeGreaterThan(0);
});
it("renders node name when showLabel is enabled for node type", () => {
const data = { name: "node-1", ready: true, cpu: { percent: 1 }, memory: { free: 2, percent: 3 } };
render(<Node type="node" options={{ showLabel: true }} data={data} />);
expect(screen.getByText("node-1")).toBeInTheDocument();
});
it("renders a warning icon when the node is not ready", () => {
const data = { name: "node-2", ready: false, cpu: { percent: 1 }, memory: { free: 2, percent: 3 } };
render(<Node type="node" options={{ showLabel: true }} data={data} />);
expect(screen.getByText("node-2")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,26 @@
// @vitest-environment jsdom
import { 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: ({ icon }) => <div data-testid="resolved-icon" data-icon={icon} />,
}));
import Logo from "./logo";
describe("components/widgets/logo", () => {
it("renders a fallback SVG when no icon is configured", () => {
const { container } = renderWithProviders(<Logo options={{}} />, { settings: { target: "_self" } });
expect(screen.queryByTestId("resolved-icon")).toBeNull();
expect(container.querySelector("svg")).not.toBeNull();
});
it("renders the configured icon via ResolvedIcon", () => {
renderWithProviders(<Logo options={{ icon: "mdi:home" }} />, { settings: { target: "_self" } });
const icon = screen.getByTestId("resolved-icon");
expect(icon.getAttribute("data-icon")).toBe("mdi:home");
});
});

View File

@@ -0,0 +1,72 @@
// @vitest-environment jsdom
import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
const { useSWR } = vi.hoisted(() => ({ useSWR: vi.fn() }));
vi.mock("swr", () => ({ default: useSWR }));
vi.mock("./node", () => ({
default: ({ data }) => <div data-testid="longhorn-node" data-id={data.node.id} />,
}));
import Longhorn from "./longhorn";
describe("components/widgets/longhorn", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders an error state when SWR errors", () => {
useSWR.mockReturnValue({ data: undefined, error: new Error("nope") });
renderWithProviders(<Longhorn options={{ nodes: true, total: true }} />, { settings: { target: "_self" } });
expect(screen.getByText("widget.api_error")).toBeInTheDocument();
});
it("renders an empty container while loading", () => {
useSWR.mockReturnValue({ data: undefined, error: undefined });
const { container } = renderWithProviders(<Longhorn options={{ nodes: true, total: true }} />, {
settings: { target: "_self" },
});
expect(container.querySelector(".infomation-widget-longhorn")).not.toBeNull();
expect(screen.queryAllByTestId("longhorn-node")).toHaveLength(0);
});
it("filters nodes based on options (total/include)", () => {
useSWR.mockReturnValue({
data: {
nodes: [{ id: "total" }, { id: "node1" }, { id: "node2" }],
},
error: undefined,
});
renderWithProviders(
<Longhorn options={{ nodes: true, total: true, include: ["node1"], expanded: false, labels: false }} />,
{ settings: { target: "_self" } },
);
const nodes = screen.getAllByTestId("longhorn-node");
expect(nodes.map((n) => n.getAttribute("data-id"))).toEqual(["total", "node1"]);
});
it("omits non-total nodes when options.nodes is false", () => {
useSWR.mockReturnValue({
data: {
nodes: [{ id: "total" }, { id: "node1" }],
},
error: undefined,
});
renderWithProviders(<Longhorn options={{ nodes: false, total: true }} />, { settings: { target: "_self" } });
const nodes = screen.getAllByTestId("longhorn-node");
expect(nodes.map((n) => n.getAttribute("data-id"))).toEqual(["total"]);
});
});

View File

@@ -0,0 +1,32 @@
// @vitest-environment jsdom
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
const { Resource } = vi.hoisted(() => ({
Resource: vi.fn(({ children }) => <div data-testid="lh-resource">{children}</div>),
}));
vi.mock("../widget/resource", () => ({
default: Resource,
}));
vi.mock("../widget/widget_label", () => ({
default: ({ label }) => <div data-testid="lh-label">{label}</div>,
}));
import Node from "./node";
describe("components/widgets/longhorn/node", () => {
it("passes calculated percentage and renders label when enabled", () => {
const data = { node: { id: "n1", available: 25, maximum: 100 } };
render(<Node data={{ node: data.node }} expanded labels />);
expect(Resource).toHaveBeenCalledTimes(1);
const callProps = Resource.mock.calls[0][0];
expect(callProps.percentage).toBe(75);
expect(callProps.expanded).toBe(true);
expect(screen.getByTestId("lh-label")).toHaveTextContent("n1");
});
});

View File

@@ -0,0 +1,135 @@
// @vitest-environment jsdom
import { fireEvent, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
const { useSWR } = vi.hoisted(() => ({ useSWR: vi.fn() }));
vi.mock("swr", () => ({ default: useSWR }));
vi.mock("react-icons/md", () => ({
MdLocationDisabled: (props) => <svg data-testid="location-disabled" {...props} />,
MdLocationSearching: (props) => <svg data-testid="location-searching" {...props} />,
}));
import OpenMeteo from "./openmeteo";
describe("components/widgets/openmeteo", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("renders an error state when the widget api returns an error", async () => {
useSWR.mockReturnValue({ data: { error: "nope" }, error: undefined });
renderWithProviders(<OpenMeteo options={{ latitude: 1, longitude: 2 }} />, { settings: { target: "_self" } });
expect(screen.getByText("widget.api_error")).toBeInTheDocument();
});
it("renders a location prompt when no coordinates are available", () => {
renderWithProviders(<OpenMeteo options={{}} />, { settings: { target: "_self" } });
expect(screen.getByText("weather.current")).toBeInTheDocument();
expect(screen.getByText("weather.allow")).toBeInTheDocument();
});
it("requests browser geolocation on click and then renders the updating state", async () => {
const getCurrentPosition = vi.fn((success) => success({ coords: { latitude: 10, longitude: 20 } }));
vi.stubGlobal("navigator", {
permissions: { query: vi.fn().mockResolvedValue({ state: "prompt" }) },
geolocation: { getCurrentPosition },
});
useSWR.mockReturnValue({ data: undefined, error: undefined });
renderWithProviders(<OpenMeteo options={{}} />, { settings: { target: "_self" } });
screen.getByRole("button").click();
await waitFor(() => {
expect(getCurrentPosition).toHaveBeenCalled();
});
expect(screen.getByText("weather.updating")).toBeInTheDocument();
});
it("clears the requesting state when the browser denies geolocation", async () => {
const getCurrentPosition = vi.fn((_success, failure) => setTimeout(() => failure(), 10));
vi.stubGlobal("navigator", {
permissions: { query: vi.fn().mockResolvedValue({ state: "prompt" }) },
geolocation: { getCurrentPosition },
});
useSWR.mockReturnValue({ data: undefined, error: undefined });
renderWithProviders(<OpenMeteo options={{}} />, { settings: { target: "_self" } });
fireEvent.click(screen.getByRole("button"));
expect(screen.getByTestId("location-searching")).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByTestId("location-disabled")).toBeInTheDocument();
});
});
it("auto-requests geolocation when permissions are granted", async () => {
const getCurrentPosition = vi.fn((success) => success({ coords: { latitude: 30, longitude: 40 } }));
const query = vi.fn().mockResolvedValue({ state: "granted" });
vi.stubGlobal("navigator", {
permissions: { query },
geolocation: { getCurrentPosition },
});
useSWR.mockReturnValue({ data: undefined, error: undefined });
renderWithProviders(<OpenMeteo options={{}} />, { settings: { target: "_self" } });
await waitFor(() => {
expect(query).toHaveBeenCalled();
expect(getCurrentPosition).toHaveBeenCalled();
});
});
it("renders temperature and condition when coordinates are provided", async () => {
useSWR.mockReturnValue({
data: {
current_weather: { temperature: 22.2, weathercode: 0, time: "2020-01-01T12:00" },
daily: { sunrise: ["2020-01-01T06:00"], sunset: ["2020-01-01T18:00"] },
},
error: undefined,
});
renderWithProviders(<OpenMeteo options={{ latitude: 1, longitude: 2, label: "Home", format: {} }} />, {
settings: { target: "_self" },
});
await waitFor(() => {
expect(screen.getByText("Home, 22.2")).toBeInTheDocument();
});
expect(screen.getByText("wmo.0-day")).toBeInTheDocument();
});
it("uses night conditions and fahrenheit units when configured", async () => {
useSWR.mockReturnValue({
data: {
current_weather: { temperature: 72, weathercode: 1, time: "2020-01-01T23:00" },
daily: { sunrise: ["2020-01-01T06:00"], sunset: ["2020-01-01T18:00"] },
},
error: undefined,
});
renderWithProviders(<OpenMeteo options={{ latitude: 1, longitude: 2, units: "imperial", format: {} }} />, {
settings: { target: "_self" },
});
await waitFor(() => {
expect(screen.getByText("72")).toBeInTheDocument();
});
expect(screen.getByText("wmo.1-night")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,141 @@
// @vitest-environment jsdom
import { fireEvent, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
const { useSWR } = vi.hoisted(() => ({ useSWR: vi.fn() }));
vi.mock("swr", () => ({ default: useSWR }));
vi.mock("react-icons/md", () => ({
MdLocationDisabled: (props) => <svg data-testid="location-disabled" {...props} />,
MdLocationSearching: (props) => <svg data-testid="location-searching" {...props} />,
}));
import OpenWeatherMap from "./weather";
describe("components/widgets/openweathermap", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("renders an error state when SWR errors or the API reports an auth error", () => {
useSWR.mockReturnValue({ data: undefined, error: new Error("nope") });
renderWithProviders(<OpenWeatherMap options={{ latitude: 1, longitude: 2 }} />, { settings: { target: "_self" } });
expect(screen.getByText("widget.api_error")).toBeInTheDocument();
useSWR.mockReturnValue({ data: { cod: 401 }, error: undefined });
renderWithProviders(<OpenWeatherMap options={{ latitude: 1, longitude: 2 }} />, { settings: { target: "_self" } });
expect(screen.getAllByText("widget.api_error").length).toBeGreaterThan(0);
});
it("renders a location prompt when no coordinates are available", () => {
renderWithProviders(<OpenWeatherMap options={{}} />, { settings: { target: "_self" } });
expect(screen.getByText("weather.current")).toBeInTheDocument();
expect(screen.getByText("weather.allow")).toBeInTheDocument();
});
it("auto-requests geolocation when permissions are granted", async () => {
const getCurrentPosition = vi.fn((success) => success({ coords: { latitude: 30, longitude: 40 } }));
const query = vi.fn().mockResolvedValue({ state: "granted" });
vi.stubGlobal("navigator", {
permissions: { query },
geolocation: { getCurrentPosition },
});
useSWR.mockReturnValue({ data: undefined, error: undefined });
renderWithProviders(<OpenWeatherMap options={{}} />, { settings: { target: "_self" } });
await waitFor(() => {
expect(query).toHaveBeenCalled();
expect(getCurrentPosition).toHaveBeenCalled();
});
});
it("requests browser geolocation on click and then renders the updating state", async () => {
const getCurrentPosition = vi.fn((success) => success({ coords: { latitude: 10, longitude: 20 } }));
vi.stubGlobal("navigator", {
permissions: { query: vi.fn().mockResolvedValue({ state: "prompt" }) },
geolocation: { getCurrentPosition },
});
useSWR.mockReturnValue({ data: undefined, error: undefined });
renderWithProviders(<OpenWeatherMap options={{}} />, { settings: { target: "_self" } });
screen.getByRole("button").click();
await waitFor(() => {
expect(getCurrentPosition).toHaveBeenCalled();
});
expect(screen.getByText("weather.updating")).toBeInTheDocument();
});
it("clears the requesting state when the browser denies geolocation", async () => {
const getCurrentPosition = vi.fn((_success, failure) => setTimeout(() => failure(), 10));
vi.stubGlobal("navigator", {
permissions: { query: vi.fn().mockResolvedValue({ state: "prompt" }) },
geolocation: { getCurrentPosition },
});
useSWR.mockReturnValue({ data: undefined, error: undefined });
renderWithProviders(<OpenWeatherMap options={{}} />, { settings: { target: "_self" } });
expect(screen.getByTestId("location-disabled")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button"));
expect(screen.getByTestId("location-searching")).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByTestId("location-disabled")).toBeInTheDocument();
});
});
it("renders temperature and description when coordinates are provided", async () => {
useSWR.mockReturnValue({
data: {
main: { temp: 71 },
weather: [{ id: 800, description: "clear sky" }],
dt: 10,
sys: { sunrise: 0, sunset: 100 },
},
error: undefined,
});
renderWithProviders(<OpenWeatherMap options={{ latitude: 1, longitude: 2, label: "Home", format: {} }} />, {
settings: { target: "_self" },
});
await waitFor(() => {
expect(screen.getByText("Home, 71")).toBeInTheDocument();
});
expect(screen.getByText("clear sky")).toBeInTheDocument();
});
it("uses night conditions and celsius units when configured", async () => {
useSWR.mockReturnValue({
data: {
main: { temp: 10 },
weather: [{ id: 800, description: "clear sky" }],
dt: 200,
sys: { sunrise: 0, sunset: 100 },
},
error: undefined,
});
renderWithProviders(<OpenWeatherMap options={{ latitude: 1, longitude: 2, units: "metric", format: {} }} />, {
settings: { target: "_self" },
});
await waitFor(() => {
expect(screen.getByText("10")).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,20 @@
// @vitest-environment jsdom
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import QueueEntry from "./queueEntry";
describe("components/widgets/queue/queueEntry", () => {
it("renders title and progress width", () => {
const { container } = render(
<QueueEntry title="Download" activity="Downloading" timeLeft="1m" progress={42} size="1GB" />,
);
expect(screen.getByText("Download")).toBeInTheDocument();
expect(screen.getByText("1GB - Downloading - 1m")).toBeInTheDocument();
const bar = container.querySelector("div[style]");
expect(bar.style.width).toBe("42%");
});
});

View File

@@ -0,0 +1,55 @@
// @vitest-environment jsdom
import { render } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const { useSWR, Resource, Error } = vi.hoisted(() => ({
useSWR: vi.fn(),
Resource: vi.fn(() => <div data-testid="resource" />),
Error: vi.fn(() => <div data-testid="error" />),
}));
vi.mock("swr", () => ({ default: useSWR }));
vi.mock("../widget/resource", () => ({ default: Resource }));
vi.mock("../widget/error", () => ({ default: Error }));
import Cpu from "./cpu";
describe("components/widgets/resources/cpu", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders a placeholder Resource while loading", () => {
useSWR.mockReturnValue({ data: undefined, error: undefined });
render(<Cpu expanded refresh={1000} />);
expect(Resource).toHaveBeenCalled();
const props = Resource.mock.calls[0][0];
expect(props.value).toBe("-");
expect(props.expanded).toBe(true);
});
it("renders usage/load values when data is present", () => {
useSWR.mockReturnValue({
data: { cpu: { usage: 12.3, load: 1.23 } },
error: undefined,
});
render(<Cpu expanded={false} />);
const props = Resource.mock.calls[0][0];
expect(props.value).toBe("12.3");
expect(props.expandedValue).toBe("1.23");
expect(props.percentage).toBe(12.3);
});
it("renders Error when SWR errors", () => {
useSWR.mockReturnValue({ data: undefined, error: new Error("nope") });
render(<Cpu expanded />);
expect(Error).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,53 @@
// @vitest-environment jsdom
import { render } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const { useSWR, Resource, Error } = vi.hoisted(() => ({
useSWR: vi.fn(),
Resource: vi.fn(() => <div data-testid="resource" />),
Error: vi.fn(() => <div data-testid="error" />),
}));
vi.mock("swr", () => ({ default: useSWR }));
vi.mock("../widget/resource", () => ({ default: Resource }));
vi.mock("../widget/error", () => ({ default: Error }));
import CpuTemp from "./cputemp";
describe("components/widgets/resources/cputemp", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders placeholder when temperature data is missing", () => {
useSWR.mockReturnValue({ data: undefined, error: undefined });
render(<CpuTemp expanded units="metric" />);
const props = Resource.mock.calls[0][0];
expect(props.value).toBe("-");
});
it("averages core temps, converts to fahrenheit and computes percentage", () => {
useSWR.mockReturnValue({
data: { cputemp: { main: 10, cores: [10, 10], max: 20 } },
error: undefined,
});
render(<CpuTemp expanded={false} units="imperial" tempmin={0} tempmax={-1} />);
const props = Resource.mock.calls[0][0];
// common.number mock returns string of value
expect(props.value).toBe("50");
expect(props.expandedValue).toBe("68");
expect(props.percentage).toBe(74);
});
it("renders Error when SWR errors", () => {
useSWR.mockReturnValue({ data: undefined, error: new Error("nope") });
render(<CpuTemp expanded units="metric" />);
expect(Error).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,53 @@
// @vitest-environment jsdom
import { render } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const { useSWR, Resource, Error } = vi.hoisted(() => ({
useSWR: vi.fn(),
Resource: vi.fn(() => <div data-testid="resource" />),
Error: vi.fn(() => <div data-testid="error" />),
}));
vi.mock("swr", () => ({ default: useSWR }));
vi.mock("../widget/resource", () => ({ default: Resource }));
vi.mock("../widget/error", () => ({ default: Error }));
import Disk from "./disk";
describe("components/widgets/resources/disk", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders a placeholder Resource while loading", () => {
useSWR.mockReturnValue({ data: undefined, error: undefined });
render(<Disk options={{ disk: "/" }} expanded />);
const props = Resource.mock.calls[0][0];
expect(props.value).toBe("-");
});
it("computes percent used from size/available and renders bytes", () => {
useSWR.mockReturnValue({
data: { drive: { size: 100, available: 40 } },
error: undefined,
});
render(<Disk options={{ disk: "/data" }} diskUnits="bytes" expanded={false} />);
const props = Resource.mock.calls[0][0];
expect(props.value).toBe("40");
expect(props.expandedValue).toBe("100");
expect(props.percentage).toBe(60);
});
it("renders Error when SWR errors", () => {
useSWR.mockReturnValue({ data: undefined, error: new Error("nope") });
render(<Disk options={{ disk: "/" }} expanded />);
expect(Error).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,53 @@
// @vitest-environment jsdom
import { render } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const { useSWR, Resource, Error } = vi.hoisted(() => ({
useSWR: vi.fn(),
Resource: vi.fn(() => <div data-testid="resource" />),
Error: vi.fn(() => <div data-testid="error" />),
}));
vi.mock("swr", () => ({ default: useSWR }));
vi.mock("../widget/resource", () => ({ default: Resource }));
vi.mock("../widget/error", () => ({ default: Error }));
import Memory from "./memory";
describe("components/widgets/resources/memory", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders a placeholder Resource while loading", () => {
useSWR.mockReturnValue({ data: undefined, error: undefined });
render(<Memory expanded />);
const props = Resource.mock.calls[0][0];
expect(props.value).toBe("-");
});
it("calculates percentage from active/total and renders available/total", () => {
useSWR.mockReturnValue({
data: { memory: { available: 10, total: 20, active: 5 } },
error: undefined,
});
render(<Memory expanded={false} />);
const props = Resource.mock.calls[0][0];
expect(props.value).toBe("10");
expect(props.expandedValue).toBe("20");
expect(props.percentage).toBe(25);
});
it("renders Error when SWR errors", () => {
useSWR.mockReturnValue({ data: undefined, error: new Error("nope") });
render(<Memory expanded />);
expect(Error).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,57 @@
// @vitest-environment jsdom
import { render } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const { useSWR, Resource, Error } = vi.hoisted(() => ({
useSWR: vi.fn(),
Resource: vi.fn(() => <div data-testid="resource" />),
Error: vi.fn(() => <div data-testid="error" />),
}));
vi.mock("swr", () => ({ default: useSWR }));
vi.mock("../widget/resource", () => ({ default: Resource }));
vi.mock("../widget/error", () => ({ default: Error }));
import Network from "./network";
describe("components/widgets/resources/network", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("normalizes options.network=true to default interfaceName in the request", () => {
useSWR.mockReturnValue({ data: undefined, error: undefined });
render(<Network options={{ network: true }} />);
expect(useSWR).toHaveBeenCalledWith(expect.stringContaining("interfaceName=default"), expect.any(Object));
});
it("renders rates and usage percentage when data is present", () => {
useSWR.mockReturnValue({
data: {
network: { rx_sec: 3, tx_sec: 1, rx_bytes: 30, tx_bytes: 10 },
},
error: undefined,
});
render(<Network options={{ network: "en0", expanded: true }} />);
const props = Resource.mock.calls[0][0];
expect(props.value).toContain("1");
expect(props.value).toContain("↑");
expect(props.label).toContain("3");
expect(props.label).toContain("↓");
expect(props.percentage).toBe(75);
expect(props.wide).toBe(true);
});
it("renders Error when SWR errors", () => {
useSWR.mockReturnValue({ data: undefined, error: new Error("nope") });
render(<Network options={{ network: "en0" }} />);
expect(Error).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,49 @@
// @vitest-environment jsdom
import { screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
vi.mock("./cpu", () => ({ default: () => <div data-testid="resources-cpu" /> }));
vi.mock("./memory", () => ({ default: () => <div data-testid="resources-memory" /> }));
vi.mock("./disk", () => ({ default: ({ options }) => <div data-testid="resources-disk" data-disk={options.disk} /> }));
vi.mock("./network", () => ({ default: () => <div data-testid="resources-network" /> }));
vi.mock("./cputemp", () => ({ default: () => <div data-testid="resources-cputemp" /> }));
vi.mock("./uptime", () => ({ default: () => <div data-testid="resources-uptime" /> }));
import Resources from "./resources";
describe("components/widgets/resources", () => {
it("renders selected resource blocks and an optional label", () => {
renderWithProviders(
<Resources
options={{
cpu: true,
memory: true,
disk: ["/", "/data"],
network: true,
cputemp: true,
uptime: true,
label: "Host A",
}}
/>,
{ settings: { target: "_self" } },
);
expect(screen.getByTestId("resources-cpu")).toBeInTheDocument();
expect(screen.getByTestId("resources-memory")).toBeInTheDocument();
expect(screen.getAllByTestId("resources-disk")).toHaveLength(2);
expect(screen.getByTestId("resources-network")).toBeInTheDocument();
expect(screen.getByTestId("resources-cputemp")).toBeInTheDocument();
expect(screen.getByTestId("resources-uptime")).toBeInTheDocument();
expect(screen.getByText("Host A")).toBeInTheDocument();
});
it("renders a single disk block when disk is not an array", () => {
renderWithProviders(<Resources options={{ disk: true }} />, { settings: { target: "_self" } });
expect(screen.getAllByTestId("resources-disk")).toHaveLength(1);
expect(screen.getByTestId("resources-disk").getAttribute("data-disk")).toBe("true");
});
});

View File

@@ -0,0 +1,54 @@
// @vitest-environment jsdom
import { render } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const { useSWR, Resource, Error } = vi.hoisted(() => ({
useSWR: vi.fn(),
Resource: vi.fn(() => <div data-testid="resource" />),
Error: vi.fn(() => <div data-testid="error" />),
}));
vi.mock("swr", () => ({ default: useSWR }));
vi.mock("../widget/resource", () => ({ default: Resource }));
vi.mock("../widget/error", () => ({ default: Error }));
import Uptime from "./uptime";
describe("components/widgets/resources/uptime", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders a placeholder while loading", () => {
useSWR.mockReturnValue({ data: undefined, error: undefined });
render(<Uptime />);
expect(Resource).toHaveBeenCalled();
expect(Resource.mock.calls[0][0].value).toBe("-");
});
it("renders formatted duration and sets percentage based on current seconds", () => {
vi.useFakeTimers();
try {
vi.setSystemTime(new Date("2020-01-01T00:00:30.000Z"));
useSWR.mockReturnValue({ data: { uptime: 1234 }, error: undefined });
render(<Uptime />);
const props = Resource.mock.calls[0][0];
expect(props.value).toBe("1234");
expect(props.percentage).toBe("50");
} finally {
vi.useRealTimers();
}
});
it("renders Error when SWR errors", () => {
useSWR.mockReturnValue({ data: undefined, error: new Error("nope") });
render(<Uptime />);
expect(Error).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,18 @@
// @vitest-environment jsdom
import { render } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import UsageBar from "./usage-bar";
describe("components/widgets/resources/usage-bar", () => {
it("normalizes percent to [0, 100] and applies width style", () => {
const { container: c0 } = render(<UsageBar percent={-5} />);
const inner0 = c0.querySelector("div > div > div");
expect(inner0.style.width).toBe("0%");
const { container: c1 } = render(<UsageBar percent={150} />);
const inner1 = c1.querySelector("div > div > div");
expect(inner1.style.width).toBe("100%");
});
});

View File

@@ -82,12 +82,10 @@ export function getStoredProvider() {
export default function Search({ options }) {
const { t } = useTranslation();
const availableProviderIds = getAvailableProviderIds(options);
const availableProviderIds = getAvailableProviderIds(options) ?? [];
const [query, setQuery] = useState("");
const [selectedProvider, setSelectedProvider] = useState(
searchProviders[availableProviderIds[0] ?? searchProviders.google],
);
const [selectedProvider, setSelectedProvider] = useState(searchProviders[availableProviderIds[0] ?? "google"]);
const [searchSuggestions, setSearchSuggestions] = useState([]);
useEffect(() => {
@@ -153,7 +151,7 @@ export default function Search({ options }) {
}
};
if (!availableProviderIds) {
if (!availableProviderIds.length) {
return null;
}

View File

@@ -0,0 +1,198 @@
// @vitest-environment jsdom
import { fireEvent, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
// HeadlessUI is hard to test reliably; stub the primitives to simple pass-through components.
vi.mock("@headlessui/react", async () => {
const React = await import("react");
const { Fragment, createContext, useContext } = React;
const ListboxContext = createContext(null);
function passthrough({ as: As = "div", children, ...props }) {
if (As === Fragment) return <>{typeof children === "function" ? children({ active: false }) : children}</>;
const content = typeof children === "function" ? children({ active: false }) : children;
return <As {...props}>{content}</As>;
}
return {
Combobox: passthrough,
ComboboxInput: (props) => <input {...props} />,
ComboboxOption: passthrough,
ComboboxOptions: passthrough,
Listbox: ({ value, onChange, children, ...props }) => (
<ListboxContext.Provider value={{ value, onChange }}>
<div {...props}>{typeof children === "function" ? children({}) : children}</div>
</ListboxContext.Provider>
),
ListboxButton: (props) => <button type="button" {...props} />,
ListboxOption: ({ as: _as, value, children, ...props }) => {
const ctx = useContext(ListboxContext);
const content = typeof children === "function" ? children({ active: false }) : children;
return (
<div role="option" data-provider={value?.name} onClick={() => ctx?.onChange?.(value)} {...props}>
{content}
</div>
);
},
ListboxOptions: passthrough,
Transition: ({ children }) => <>{children}</>,
};
});
import Search from "./search";
describe("components/widgets/search", () => {
beforeEach(() => {
localStorage.clear();
});
it("opens a search URL when Enter is pressed", () => {
const openSpy = vi.spyOn(window, "open").mockImplementation(() => null);
renderWithProviders(<Search options={{ provider: ["google"], showSearchSuggestions: false, target: "_self" }} />, {
settings: { target: "_blank" },
});
const input = screen.getByPlaceholderText("search.placeholder");
fireEvent.change(input, { target: { value: "hello world" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(openSpy).toHaveBeenCalledWith("https://www.google.com/search?q=hello%20world", "_self");
openSpy.mockRestore();
});
it("accepts provider configured as a string", () => {
const openSpy = vi.spyOn(window, "open").mockImplementation(() => null);
renderWithProviders(
<Search options={{ provider: "duckduckgo", showSearchSuggestions: false, target: "_self" }} />,
{
settings: {},
},
);
const input = screen.getByPlaceholderText("search.placeholder");
fireEvent.change(input, { target: { value: "hello" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(openSpy).toHaveBeenCalledWith("https://duckduckgo.com/?q=hello", "_self");
openSpy.mockRestore();
});
it("returns null when the configured provider list contains no supported providers", () => {
const { container } = renderWithProviders(<Search options={{ provider: "nope", showSearchSuggestions: false }} />, {
settings: {},
});
expect(container).toBeEmptyDOMElement();
});
it("stores the selected provider in localStorage when it is changed", async () => {
const openSpy = vi.spyOn(window, "open").mockImplementation(() => null);
renderWithProviders(
<Search options={{ provider: ["google", "duckduckgo"], showSearchSuggestions: false, target: "_self" }} />,
{
settings: {},
},
);
const option = document.querySelector('[data-provider="DuckDuckGo"]');
expect(option).not.toBeNull();
fireEvent.click(option);
await waitFor(() => {
expect(localStorage.getItem("search-name")).toBe("DuckDuckGo");
});
const input = screen.getByPlaceholderText("search.placeholder");
fireEvent.change(input, { target: { value: "hello" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(openSpy).toHaveBeenCalledWith("https://duckduckgo.com/?q=hello", "_self");
openSpy.mockRestore();
});
it("uses a stored provider from localStorage when it is available and allowed", () => {
const openSpy = vi.spyOn(window, "open").mockImplementation(() => null);
localStorage.setItem("search-name", "DuckDuckGo");
renderWithProviders(
<Search options={{ provider: ["google", "duckduckgo"], showSearchSuggestions: false, target: "_self" }} />,
{
settings: {},
},
);
const input = screen.getByPlaceholderText("search.placeholder");
fireEvent.change(input, { target: { value: "hello" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(openSpy).toHaveBeenCalledWith("https://duckduckgo.com/?q=hello", "_self");
openSpy.mockRestore();
});
it("uses a custom provider URL when the selected provider is custom", () => {
const openSpy = vi.spyOn(window, "open").mockImplementation(() => null);
renderWithProviders(
<Search
options={{
provider: ["custom"],
url: "https://example.com/search?q=",
showSearchSuggestions: false,
target: "_self",
}}
/>,
{ settings: {} },
);
const input = screen.getByPlaceholderText("search.placeholder");
fireEvent.change(input, { target: { value: "hello world" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(openSpy).toHaveBeenCalledWith("https://example.com/search?q=hello%20world", "_self");
openSpy.mockRestore();
});
it("fetches search suggestions and triggers a search when a suggestion is selected", async () => {
const openSpy = vi.spyOn(window, "open").mockImplementation(() => null);
const originalFetch = globalThis.fetch;
const fetchSpy = vi.fn(async () => ({
json: async () => ["hel", ["hello", "help", "helm", "helium", "held"]],
}));
// eslint-disable-next-line no-global-assign
fetch = fetchSpy;
renderWithProviders(<Search options={{ provider: ["google"], showSearchSuggestions: true, target: "_self" }} />, {
settings: {},
});
const input = screen.getByPlaceholderText("search.placeholder");
fireEvent.change(input, { target: { value: "hel" } });
await waitFor(() => {
expect(fetchSpy).toHaveBeenCalledWith(
expect.stringContaining("/api/search/searchSuggestion?query=hel&providerName=Google"),
expect.objectContaining({ signal: expect.any(AbortSignal) }),
);
});
await waitFor(() => {
expect(document.querySelector('[value="hello"]')).toBeTruthy();
});
expect(document.querySelector('[value="held"]')).toBeNull();
fireEvent.mouseDown(document.querySelector('[value="hello"]'));
expect(openSpy).toHaveBeenCalledWith("https://www.google.com/search?q=hello", "_self");
openSpy.mockRestore();
// eslint-disable-next-line no-global-assign
fetch = originalFetch;
});
});

View File

@@ -0,0 +1,72 @@
// @vitest-environment jsdom
import { fireEvent, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
const { useSWR } = vi.hoisted(() => ({ useSWR: vi.fn() }));
vi.mock("swr", () => ({ default: useSWR }));
import Stocks from "./stocks";
describe("components/widgets/stocks", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders an error widget when the api call fails", () => {
useSWR.mockReturnValue({ data: undefined, error: new Error("nope") });
renderWithProviders(<Stocks options={{}} />, { settings: { target: "_self" } });
expect(screen.getByText("widget.api_error")).toBeInTheDocument();
});
it("renders a loading state while waiting for data", () => {
useSWR.mockReturnValue({ data: undefined, error: undefined });
renderWithProviders(<Stocks options={{}} />, { settings: { target: "_self" } });
expect(screen.getByText(/stocks\.loading/)).toBeInTheDocument();
});
it("toggles between price and percent change on click", () => {
useSWR.mockReturnValue({
data: {
stocks: [
{ ticker: "NASDAQ:AAPL", currentPrice: 123.45, percentChange: 1.23 },
{ ticker: "MSFT", currentPrice: 99.99, percentChange: -0.5 },
],
},
error: undefined,
});
renderWithProviders(<Stocks options={{ color: false }} />, { settings: { target: "_self" } });
expect(screen.getByText("AAPL")).toBeInTheDocument();
expect(screen.getByText("123.45")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button"));
expect(screen.getByText("1.23%")).toBeInTheDocument();
expect(screen.getByText("-0.5%")).toBeInTheDocument();
});
it("shows api_error for null prices and uses colored classes when enabled", () => {
useSWR.mockReturnValue({
data: {
stocks: [{ ticker: "NASDAQ:AAPL", currentPrice: null, percentChange: -1 }],
},
error: undefined,
});
renderWithProviders(<Stocks options={{}} />, { settings: { target: "_self" } });
const apiError = screen.getByText("widget.api_error");
expect(apiError.className).toContain("text-rose");
fireEvent.click(screen.getByRole("button"));
const percent = screen.getByText("-1%");
expect(percent.className).toContain("text-rose");
});
});

View File

@@ -0,0 +1,261 @@
// @vitest-environment jsdom
import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({
default: useWidgetAPI,
}));
vi.mock("react-icons/bi", async (importOriginal) => {
const actual = await importOriginal();
return {
...actual,
BiWifi: (props) => <svg data-testid="bi-wifi" {...props} />,
BiNetworkChart: (props) => <svg data-testid="bi-network-chart" {...props} />,
BiError: (props) => <svg data-testid="bi-error" {...props} />,
BiCheckCircle: (props) => <svg data-testid="bi-check-circle" {...props} />,
BiXCircle: (props) => <svg data-testid="bi-x-circle" {...props} />,
};
});
import UnifiConsole from "./unifi_console";
describe("components/widgets/unifi_console", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders an api error state when the widget api call fails", () => {
useWidgetAPI.mockReturnValue({ data: undefined, error: new Error("nope") });
renderWithProviders(<UnifiConsole options={{ index: 0 }} />, { settings: { target: "_self" } });
expect(screen.getByText("widget.api_error")).toBeInTheDocument();
});
it("renders a wait state when no site is available yet", () => {
useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });
renderWithProviders(<UnifiConsole options={{ index: 0 }} />, { settings: { target: "_self" } });
expect(screen.getByText("unifi.wait")).toBeInTheDocument();
});
it("renders site name and uptime when data is available", () => {
useWidgetAPI.mockReturnValue({
data: {
data: [
{
name: "default",
desc: "Home",
health: [
{
subsystem: "wan",
status: "ok",
gw_name: "Router",
"gw_system-stats": { uptime: 172800 },
},
{ subsystem: "lan", status: "unknown" },
{ subsystem: "wlan", status: "unknown" },
],
},
],
},
error: undefined,
});
renderWithProviders(<UnifiConsole options={{ index: 0 }} />, { settings: { target: "_self" } });
expect(screen.getByText("Router")).toBeInTheDocument();
// common.number is mocked to return the numeric value as a string.
expect(screen.getByText("2")).toBeInTheDocument();
expect(screen.getByText("unifi.days")).toBeInTheDocument();
});
it("selects a site by description when options.site is set", () => {
useWidgetAPI.mockReturnValue({
data: {
data: [
{
name: "default",
desc: "Other",
health: [
{ subsystem: "wan", status: "unknown" },
{ subsystem: "lan", status: "unknown" },
{ subsystem: "wlan", status: "unknown" },
],
},
{
name: "site-2",
desc: "My Site",
health: [
{ subsystem: "wan", status: "ok", gw_name: "My GW", "gw_system-stats": { uptime: 86400 } },
{ subsystem: "lan", status: "unknown" },
{ subsystem: "wlan", status: "unknown" },
],
},
],
},
error: undefined,
});
renderWithProviders(<UnifiConsole options={{ index: 0, site: "My Site" }} />, { settings: { target: "_self" } });
expect(screen.getByText("My GW")).toBeInTheDocument();
expect(screen.getByText("1")).toBeInTheDocument();
});
it("shows wlan user/device counts when wlan is available and lan is unknown", () => {
useWidgetAPI.mockReturnValue({
data: {
data: [
{
name: "default",
desc: "Home",
health: [
{ subsystem: "wan", status: "unknown" },
{ subsystem: "lan", status: "unknown" },
{ subsystem: "wlan", status: "ok", num_user: 3, num_adopted: 10 },
],
},
],
},
error: undefined,
});
renderWithProviders(<UnifiConsole options={{ index: 0 }} />, { settings: { target: "_self" } });
expect(screen.getByText("Home")).toBeInTheDocument();
expect(screen.getByText("unifi.wlan")).toBeInTheDocument();
expect(screen.getByText("3")).toBeInTheDocument();
expect(screen.getByTitle("unifi.devices")).toBeInTheDocument();
expect(screen.getByText("10")).toBeInTheDocument();
});
it("renders an empty data hint when all subsystems are unknown and uptime is missing", () => {
useWidgetAPI.mockReturnValue({
data: {
data: [
{
name: "default",
desc: "Home",
health: [
{ subsystem: "wan", status: "unknown" },
{ subsystem: "lan", status: "unknown" },
{ subsystem: "wlan", status: "unknown" },
],
},
],
},
error: undefined,
});
renderWithProviders(<UnifiConsole options={{ index: 0 }} />, { settings: { target: "_self" } });
expect(screen.getByText("unifi.empty_data")).toBeInTheDocument();
});
it("shows wan state when wan is available but reports a non-ok status", () => {
useWidgetAPI.mockReturnValue({
data: {
data: [
{
name: "default",
desc: "Home",
health: [
{ subsystem: "wan", status: "error", gw_name: "Router" },
{ subsystem: "lan", status: "unknown" },
{ subsystem: "wlan", status: "unknown" },
],
},
],
},
error: undefined,
});
renderWithProviders(<UnifiConsole options={{ index: 0 }} />, { settings: { target: "_self" } });
expect(screen.getByText("Router")).toBeInTheDocument();
expect(screen.getByText("unifi.wan")).toBeInTheDocument();
});
it("shows wlan down state when only wlan is available", () => {
useWidgetAPI.mockReturnValue({
data: {
data: [
{
name: "default",
desc: "Home",
health: [
{ subsystem: "wan", status: "unknown" },
{ subsystem: "lan", status: "unknown" },
{ subsystem: "wlan", status: "error", num_user: 1, num_adopted: 2 },
],
},
],
},
error: undefined,
});
renderWithProviders(<UnifiConsole options={{ index: 0 }} />, { settings: { target: "_self" } });
expect(screen.getByText("unifi.wlan")).toBeInTheDocument();
expect(screen.getByText("1")).toBeInTheDocument();
expect(screen.getByText("2")).toBeInTheDocument();
});
it("shows lan user/device counts when only lan is available", () => {
useWidgetAPI.mockReturnValue({
data: {
data: [
{
name: "default",
desc: "Home",
health: [
{ subsystem: "wan", status: "unknown" },
{ subsystem: "lan", status: "ok", num_user: 2, num_adopted: 5 },
{ subsystem: "wlan", status: "unknown" },
],
},
],
},
error: undefined,
});
renderWithProviders(<UnifiConsole options={{ index: 0 }} />, { settings: { target: "_self" } });
expect(screen.getByText("unifi.lan")).toBeInTheDocument();
expect(screen.getByText("2")).toBeInTheDocument();
expect(screen.getByText("5")).toBeInTheDocument();
expect(screen.getByTitle("unifi.devices")).toBeInTheDocument();
});
it("shows a lan down state when only lan is available and reports a non-ok status", () => {
useWidgetAPI.mockReturnValue({
data: {
data: [
{
name: "default",
desc: "Home",
health: [
{ subsystem: "wan", status: "unknown" },
{ subsystem: "lan", status: "error", num_user: 1, num_adopted: 2 },
{ subsystem: "wlan", status: "unknown" },
],
},
],
},
error: undefined,
});
renderWithProviders(<UnifiConsole options={{ index: 0 }} />, { settings: { target: "_self" } });
expect(screen.getByText("unifi.lan")).toBeInTheDocument();
expect(screen.getByTestId("bi-x-circle")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,146 @@
// @vitest-environment jsdom
import { fireEvent, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
const { useSWR } = vi.hoisted(() => ({ useSWR: vi.fn() }));
vi.mock("swr", () => ({ default: useSWR }));
vi.mock("react-icons/md", () => ({
MdLocationDisabled: (props) => <svg data-testid="location-disabled" {...props} />,
MdLocationSearching: (props) => <svg data-testid="location-searching" {...props} />,
}));
import WeatherApi from "./weather";
describe("components/widgets/weather", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("renders an error state when SWR errors or the API payload indicates an error", () => {
useSWR.mockReturnValue({ data: undefined, error: new Error("nope") });
renderWithProviders(<WeatherApi options={{ latitude: 1, longitude: 2 }} />, { settings: { target: "_self" } });
expect(screen.getByText("widget.api_error")).toBeInTheDocument();
useSWR.mockReturnValue({ data: { error: "nope" }, error: undefined });
renderWithProviders(<WeatherApi options={{ latitude: 1, longitude: 2 }} />, { settings: { target: "_self" } });
expect(screen.getAllByText("widget.api_error").length).toBeGreaterThan(0);
});
it("renders a location prompt when no coordinates are available", () => {
renderWithProviders(<WeatherApi options={{}} />, { settings: { target: "_self" } });
expect(screen.getByText("weather.current")).toBeInTheDocument();
expect(screen.getByText("weather.allow")).toBeInTheDocument();
});
it("auto-requests geolocation when permissions are granted", async () => {
const getCurrentPosition = vi.fn((success) => success({ coords: { latitude: 30, longitude: 40 } }));
const query = vi.fn().mockResolvedValue({ state: "granted" });
vi.stubGlobal("navigator", {
permissions: { query },
geolocation: { getCurrentPosition },
});
useSWR.mockReturnValue({ data: undefined, error: undefined });
renderWithProviders(<WeatherApi options={{}} />, { settings: { target: "_self" } });
await waitFor(() => {
expect(query).toHaveBeenCalled();
expect(getCurrentPosition).toHaveBeenCalled();
});
});
it("requests browser geolocation on click and then renders the updating state", async () => {
const getCurrentPosition = vi.fn((success) => success({ coords: { latitude: 10, longitude: 20 } }));
vi.stubGlobal("navigator", {
permissions: { query: vi.fn().mockResolvedValue({ state: "prompt" }) },
geolocation: { getCurrentPosition },
});
useSWR.mockReturnValue({ data: undefined, error: undefined });
renderWithProviders(<WeatherApi options={{}} />, { settings: { target: "_self" } });
screen.getByRole("button").click();
await waitFor(() => {
expect(getCurrentPosition).toHaveBeenCalled();
});
expect(screen.getByText("weather.updating")).toBeInTheDocument();
});
it("clears the requesting state when the browser denies geolocation", async () => {
const getCurrentPosition = vi.fn((_success, failure) => setTimeout(() => failure(), 10));
vi.stubGlobal("navigator", {
permissions: { query: vi.fn().mockResolvedValue({ state: "prompt" }) },
geolocation: { getCurrentPosition },
});
useSWR.mockReturnValue({ data: undefined, error: undefined });
renderWithProviders(<WeatherApi options={{}} />, { settings: { target: "_self" } });
fireEvent.click(screen.getByRole("button"));
expect(screen.getByTestId("location-searching")).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByTestId("location-disabled")).toBeInTheDocument();
});
});
it("renders temperature and condition when coordinates are provided", async () => {
useSWR.mockReturnValue({
data: {
current: {
temp_c: 21.5,
temp_f: 70.7,
is_day: 1,
condition: { code: 1000, text: "Sunny" },
},
},
error: undefined,
});
renderWithProviders(
<WeatherApi options={{ latitude: 1, longitude: 2, units: "metric", label: "Home", format: {} }} />,
{ settings: { target: "_self" } },
);
await waitFor(() => {
expect(screen.getByText("Home, 21.5")).toBeInTheDocument();
});
expect(screen.getByText("Sunny")).toBeInTheDocument();
});
it("uses fahrenheit and night conditions when configured", async () => {
useSWR.mockReturnValue({
data: {
current: {
temp_c: 21.5,
temp_f: 70.7,
is_day: 0,
condition: { code: 1000, text: "Clear" },
},
},
error: undefined,
});
renderWithProviders(<WeatherApi options={{ latitude: 1, longitude: 2, units: "imperial", format: {} }} />, {
settings: { target: "_self" },
});
await waitFor(() => {
expect(screen.getByText("70.7")).toBeInTheDocument();
});
expect(screen.getByText("Clear")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,58 @@
// @vitest-environment jsdom
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
const { dynamic } = vi.hoisted(() => {
const dynamic = vi.fn((loader, opts) => {
const loaderStr = loader.toString();
const ssr = opts?.ssr === false ? "false" : "true";
return function DynamicWidget({ options }) {
return (
<div
data-testid="dynamic-widget"
data-loader={loaderStr}
data-ssr={ssr}
data-options={JSON.stringify(options)}
/>
);
};
});
return { dynamic };
});
vi.mock("next/dynamic", () => ({
default: dynamic,
}));
vi.mock("components/errorboundry", () => ({
default: ({ children }) => <div data-testid="error-boundary">{children}</div>,
}));
import Widget from "./widget";
describe("components/widgets/widget", () => {
it("renders the mapped widget component and forwards style into options", () => {
render(
<Widget widget={{ type: "search", options: { provider: ["google"] } }} style={{ header: "boxedWidgets" }} />,
);
const boundary = screen.getByTestId("error-boundary");
expect(boundary).toBeInTheDocument();
const el = screen.getByTestId("dynamic-widget");
expect(el.getAttribute("data-loader")).toContain("search/search");
const forwarded = JSON.parse(el.getAttribute("data-options"));
expect(forwarded.provider).toEqual(["google"]);
expect(forwarded.style).toEqual({ header: "boxedWidgets" });
});
it("renders a missing message when widget type is unknown", () => {
render(<Widget widget={{ type: "nope", options: {} }} style={{}} />);
expect(screen.getByText("Missing")).toBeInTheDocument();
expect(screen.getByText("nope")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,76 @@
// @vitest-environment jsdom
import { screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import Container, { getAllClasses } from "./container";
import PrimaryText from "./primary_text";
import Raw from "./raw";
import SecondaryText from "./secondary_text";
import WidgetIcon from "./widget_icon";
function FakeIcon(props) {
return <svg data-testid="fake-icon" {...props} />;
}
describe("components/widgets/widget/container", () => {
it("getAllClasses supports boxedWidgets + cardBlur and right alignment", () => {
const boxed = getAllClasses({ style: { header: "boxedWidgets", cardBlur: "md" } }, "x");
expect(boxed).toContain("backdrop-blur-md");
expect(boxed).toContain("x");
const right = getAllClasses({ style: { isRightAligned: true } }, "y");
expect(right).toContain("justify-center");
expect(right).toContain("y");
expect(right).not.toContain("max-w:full");
});
it("renders an anchor when href is provided and prefers options.target over settings.target", () => {
renderWithProviders(
<Container options={{ href: "http://example", target: "_self" }}>
<WidgetIcon icon={FakeIcon} />
<PrimaryText>P</PrimaryText>
<SecondaryText>S</SecondaryText>
<Raw>
<div data-testid="bottom">B</div>
</Raw>
</Container>,
{ settings: { target: "_blank" } },
);
const link = screen.getByRole("link");
expect(link.getAttribute("href")).toBe("http://example");
expect(link.getAttribute("target")).toBe("_self");
expect(screen.getByTestId("fake-icon")).toBeInTheDocument();
expect(screen.getByText("P")).toBeInTheDocument();
expect(screen.getByText("S")).toBeInTheDocument();
expect(screen.getByTestId("bottom")).toBeInTheDocument();
});
it("renders only bottom content when children are a single Raw element", () => {
const { container } = renderWithProviders(
<Container options={{}}>
<Raw>
<div data-testid="only-bottom">B</div>
</Raw>
</Container>,
{ settings: { target: "_self" } },
);
expect(container.querySelector(".widget-inner")).toBeNull();
expect(screen.getByTestId("only-bottom")).toBeInTheDocument();
});
it("does not crash when clicked (href case is normal link)", () => {
renderWithProviders(
<Container options={{ href: "http://example" }}>
<Raw>
<div>Bottom</div>
</Raw>
</Container>,
{ settings: { target: "_self" } },
);
});
});

View File

@@ -0,0 +1,23 @@
// @vitest-environment jsdom
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import ContainerButton from "./container_button";
import Raw from "./raw";
describe("components/widgets/widget/container_button", () => {
it("invokes callback on click", () => {
const cb = vi.fn();
render(
<ContainerButton options={{}} callback={cb}>
<Raw>
<div>child</div>
</Raw>
</ContainerButton>,
);
fireEvent.click(screen.getByRole("button"));
expect(cb).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,23 @@
// @vitest-environment jsdom
import { fireEvent, render } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import ContainerForm from "./container_form";
describe("components/widgets/widget/container_form", () => {
it("calls callback on submit", () => {
const cb = vi.fn((e) => e.preventDefault());
const { container } = render(
<ContainerForm options={{}} callback={cb}>
{[<div key="c">child</div>]}
</ContainerForm>,
);
const form = container.querySelector("form");
fireEvent.submit(form);
expect(cb).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,24 @@
// @vitest-environment jsdom
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import ContainerLink from "./container_link";
import Raw from "./raw";
describe("components/widgets/widget/container_link", () => {
it("renders an anchor using href or url", () => {
const { rerender } = render(<ContainerLink options={{ href: "http://a" }} target="_self" />);
expect(screen.getByRole("link").getAttribute("href")).toBe("http://a");
expect(screen.getByRole("link").getAttribute("target")).toBe("_self");
rerender(
<ContainerLink options={{ url: "http://b" }} target="_blank">
<Raw>
<div>child</div>
</Raw>
</ContainerLink>,
);
expect(screen.getByRole("link").getAttribute("href")).toBe("http://b");
});
});

View File

@@ -0,0 +1,15 @@
// @vitest-environment jsdom
import { screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
import Error from "./error";
describe("components/widgets/widget/error", () => {
it("renders the api_error message", () => {
renderWithProviders(<Error options={{}} />, { settings: { target: "_self" } });
expect(screen.getByText("widget.api_error")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,13 @@
// @vitest-environment jsdom
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import PrimaryText from "./primary_text";
describe("components/widgets/widget/primary_text", () => {
it("renders children", () => {
render(<PrimaryText>hello</PrimaryText>);
expect(screen.getByText("hello")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,20 @@
// @vitest-environment jsdom
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import Raw from "./raw";
describe("components/widgets/widget/raw", () => {
it("renders nested Raw content", () => {
render(
<Raw>
<Raw>
<div>inner</div>
</Raw>
</Raw>,
);
expect(screen.getByText("inner")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,38 @@
// @vitest-environment jsdom
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
const { UsageBar } = vi.hoisted(() => ({
UsageBar: vi.fn(({ percent }) => <div data-testid="usagebar" data-percent={String(percent)} />),
}));
vi.mock("../resources/usage-bar", () => ({
default: UsageBar,
}));
import Resource from "./resource";
function FakeIcon(props) {
return <svg data-testid="resource-icon" {...props} />;
}
describe("components/widgets/widget/resource", () => {
it("renders icon/value/label and shows usage bar when percentage is set", () => {
render(<Resource icon={FakeIcon} value="v" label="l" percentage={0} />);
expect(screen.getByTestId("resource-icon")).toBeInTheDocument();
expect(screen.getByText("v")).toBeInTheDocument();
expect(screen.getByText("l")).toBeInTheDocument();
expect(screen.getByTestId("usagebar").getAttribute("data-percent")).toBe("0");
});
it("renders expanded values when expanded", () => {
render(
<Resource icon={FakeIcon} value="v" label="l" expanded expandedValue="ev" expandedLabel="el" percentage={10} />,
);
expect(screen.getByText("ev")).toBeInTheDocument();
expect(screen.getByText("el")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,31 @@
// @vitest-environment jsdom
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import Resource from "./resource";
import Resources from "./resources";
import WidgetLabel from "./widget_label";
function FakeIcon() {
return <svg />;
}
describe("components/widgets/widget/resources", () => {
it("filters children to Resource + WidgetLabel and wraps them in a link", () => {
render(
<Resources options={{ href: "http://example" }} target="_self" additionalClassNames="x">
{[
<Resource key="r" icon={FakeIcon} value="v" label="l" />,
<WidgetLabel key="w" label="Label" />,
<div key="o">Other</div>,
]}
</Resources>,
);
expect(screen.getByRole("link").getAttribute("href")).toBe("http://example");
expect(screen.getByText("v")).toBeInTheDocument();
expect(screen.getByText("Label")).toBeInTheDocument();
expect(screen.queryByText("Other")).toBeNull();
});
});

View File

@@ -0,0 +1,13 @@
// @vitest-environment jsdom
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import SecondaryText from "./secondary_text";
describe("components/widgets/widget/secondary_text", () => {
it("renders children", () => {
render(<SecondaryText>world</SecondaryText>);
expect(screen.getByText("world")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,30 @@
// @vitest-environment jsdom
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import WidgetIcon from "./widget_icon";
function FakeIcon(props) {
return <svg data-testid="icon" {...props} />;
}
describe("components/widgets/widget/widget_icon", () => {
it("applies size classes and pulse animation", () => {
render(
<>
<WidgetIcon icon={FakeIcon} size="s" />
<WidgetIcon icon={FakeIcon} size="m" />
<WidgetIcon icon={FakeIcon} size="l" pulse />
<WidgetIcon icon={FakeIcon} size="xl" />
</>,
);
const icons = screen.getAllByTestId("icon");
expect(icons[0].getAttribute("class")).toContain("w-5 h-5");
expect(icons[1].getAttribute("class")).toContain("w-6 h-6");
expect(icons[2].getAttribute("class")).toContain("w-8 h-8");
expect(icons[2].getAttribute("class")).toContain("animate-pulse");
expect(icons[3].getAttribute("class")).toContain("w-10 h-10");
});
});

View File

@@ -0,0 +1,13 @@
// @vitest-environment jsdom
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import WidgetLabel from "./widget_label";
describe("components/widgets/widget/widget_label", () => {
it("renders label text", () => {
render(<WidgetLabel label="Label A" />);
expect(screen.getByText("Label A")).toBeInTheDocument();
});
});