diff --git a/src/components/widgets/datetime/datetime.test.jsx b/src/components/widgets/datetime/datetime.test.jsx
new file mode 100644
index 000000000..da16f9dc6
--- /dev/null
+++ b/src/components/widgets/datetime/datetime.test.jsx
@@ -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(, { 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();
+ }
+ });
+});
diff --git a/src/components/widgets/glances/glances.test.jsx b/src/components/widgets/glances/glances.test.jsx
new file mode 100644
index 000000000..1450120e4
--- /dev/null
+++ b/src/components/widgets/glances/glances.test.jsx
@@ -0,0 +1,51 @@
+// @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 placeholder resources while loading", () => {
+ useSWR.mockReturnValue({ data: undefined, error: undefined });
+
+ renderWithProviders(, {
+ settings: { target: "_self" },
+ });
+
+ // All placeholders use glances.wait.
+ 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(, {
+ 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();
+ });
+});
diff --git a/src/components/widgets/greeting/greeting.test.jsx b/src/components/widgets/greeting/greeting.test.jsx
new file mode 100644
index 000000000..6fd6d8509
--- /dev/null
+++ b/src/components/widgets/greeting/greeting.test.jsx
@@ -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(, { settings: { target: "_self" } });
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it("renders configured greeting text", () => {
+ renderWithProviders(, { settings: { target: "_self" } });
+ expect(screen.getByText("Hello there")).toBeInTheDocument();
+ });
+});
diff --git a/src/components/widgets/kubernetes/kubernetes.test.jsx b/src/components/widgets/kubernetes/kubernetes.test.jsx
new file mode 100644
index 000000000..b8a898ac8
--- /dev/null
+++ b/src/components/widgets/kubernetes/kubernetes.test.jsx
@@ -0,0 +1,45 @@
+// @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 }) =>
,
+}));
+
+import Kubernetes from "./kubernetes";
+
+describe("components/widgets/kubernetes", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("renders placeholder nodes while loading", () => {
+ useSWR.mockReturnValue({ data: undefined, error: undefined });
+
+ renderWithProviders(, {
+ 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(, {
+ settings: { target: "_self" },
+ });
+
+ // cluster + 2 nodes
+ expect(screen.getAllByTestId("kube-node")).toHaveLength(3);
+ });
+});
diff --git a/src/components/widgets/logo/logo.test.jsx b/src/components/widgets/logo/logo.test.jsx
new file mode 100644
index 000000000..931b4e832
--- /dev/null
+++ b/src/components/widgets/logo/logo.test.jsx
@@ -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 }) => ,
+}));
+
+import Logo from "./logo";
+
+describe("components/widgets/logo", () => {
+ it("renders a fallback SVG when no icon is configured", () => {
+ const { container } = renderWithProviders(, { settings: { target: "_self" } });
+ expect(screen.queryByTestId("resolved-icon")).toBeNull();
+ expect(container.querySelector("svg")).not.toBeNull();
+ });
+
+ it("renders the configured icon via ResolvedIcon", () => {
+ renderWithProviders(, { settings: { target: "_self" } });
+ const icon = screen.getByTestId("resolved-icon");
+ expect(icon.getAttribute("data-icon")).toBe("mdi:home");
+ });
+});
diff --git a/src/components/widgets/longhorn/longhorn.test.jsx b/src/components/widgets/longhorn/longhorn.test.jsx
new file mode 100644
index 000000000..8f6c8e757
--- /dev/null
+++ b/src/components/widgets/longhorn/longhorn.test.jsx
@@ -0,0 +1,50 @@
+// @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 }) => ,
+}));
+
+import Longhorn from "./longhorn";
+
+describe("components/widgets/longhorn", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("renders an empty container while loading", () => {
+ useSWR.mockReturnValue({ data: undefined, error: undefined });
+
+ const { container } = renderWithProviders(, {
+ 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(
+ ,
+ { settings: { target: "_self" } },
+ );
+
+ const nodes = screen.getAllByTestId("longhorn-node");
+ expect(nodes.map((n) => n.getAttribute("data-id"))).toEqual(["total", "node1"]);
+ });
+});
diff --git a/src/components/widgets/openmeteo/openmeteo.test.jsx b/src/components/widgets/openmeteo/openmeteo.test.jsx
new file mode 100644
index 000000000..36931ca54
--- /dev/null
+++ b/src/components/widgets/openmeteo/openmeteo.test.jsx
@@ -0,0 +1,43 @@
+// @vitest-environment jsdom
+
+import { screen, waitFor } 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 OpenMeteo from "./openmeteo";
+
+describe("components/widgets/openmeteo", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("renders a location prompt when no coordinates are available", () => {
+ renderWithProviders(, { settings: { target: "_self" } });
+
+ expect(screen.getByText("weather.current")).toBeInTheDocument();
+ expect(screen.getByText("weather.allow")).toBeInTheDocument();
+ });
+
+ 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(, {
+ settings: { target: "_self" },
+ });
+
+ await waitFor(() => {
+ expect(screen.getByText("Home, 22.2")).toBeInTheDocument();
+ });
+ expect(screen.getByText("wmo.0-day")).toBeInTheDocument();
+ });
+});
diff --git a/src/components/widgets/openweathermap/weather.test.jsx b/src/components/widgets/openweathermap/weather.test.jsx
new file mode 100644
index 000000000..3b7deaede
--- /dev/null
+++ b/src/components/widgets/openweathermap/weather.test.jsx
@@ -0,0 +1,45 @@
+// @vitest-environment jsdom
+
+import { screen, waitFor } 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 OpenWeatherMap from "./weather";
+
+describe("components/widgets/openweathermap", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("renders a location prompt when no coordinates are available", () => {
+ renderWithProviders(, { settings: { target: "_self" } });
+
+ expect(screen.getByText("weather.current")).toBeInTheDocument();
+ expect(screen.getByText("weather.allow")).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(, {
+ settings: { target: "_self" },
+ });
+
+ await waitFor(() => {
+ expect(screen.getByText("Home, 71")).toBeInTheDocument();
+ });
+ expect(screen.getByText("clear sky")).toBeInTheDocument();
+ });
+});
diff --git a/src/components/widgets/resources/resources.test.jsx b/src/components/widgets/resources/resources.test.jsx
new file mode 100644
index 000000000..05b1b4e88
--- /dev/null
+++ b/src/components/widgets/resources/resources.test.jsx
@@ -0,0 +1,42 @@
+// @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: () => }));
+vi.mock("./memory", () => ({ default: () => }));
+vi.mock("./disk", () => ({ default: ({ options }) => }));
+vi.mock("./network", () => ({ default: () => }));
+vi.mock("./cputemp", () => ({ default: () => }));
+vi.mock("./uptime", () => ({ default: () => }));
+
+import Resources from "./resources";
+
+describe("components/widgets/resources", () => {
+ it("renders selected resource blocks and an optional label", () => {
+ renderWithProviders(
+ ,
+ { 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();
+ });
+});
diff --git a/src/components/widgets/search/search.test.jsx b/src/components/widgets/search/search.test.jsx
new file mode 100644
index 000000000..4dbaf0691
--- /dev/null
+++ b/src/components/widgets/search/search.test.jsx
@@ -0,0 +1,49 @@
+// @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";
+
+// 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 } = React;
+
+ 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 {content};
+ }
+
+ return {
+ Combobox: passthrough,
+ ComboboxInput: (props) => ,
+ ComboboxOption: passthrough,
+ ComboboxOptions: passthrough,
+ Listbox: passthrough,
+ ListboxButton: (props) => ,
+ ListboxOption: passthrough,
+ ListboxOptions: passthrough,
+ Transition: ({ children }) => <>{children}>,
+ };
+});
+
+import Search from "./search";
+
+describe("components/widgets/search", () => {
+ it("opens a search URL when Enter is pressed", () => {
+ const openSpy = vi.spyOn(window, "open").mockImplementation(() => null);
+
+ renderWithProviders(, {
+ 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();
+ });
+});
diff --git a/src/components/widgets/stocks/stocks.test.jsx b/src/components/widgets/stocks/stocks.test.jsx
new file mode 100644
index 000000000..f4ead5aa6
--- /dev/null
+++ b/src/components/widgets/stocks/stocks.test.jsx
@@ -0,0 +1,46 @@
+// @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 a loading state while waiting for data", () => {
+ useSWR.mockReturnValue({ data: undefined, error: undefined });
+
+ renderWithProviders(, { 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(, { 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();
+ });
+});
diff --git a/src/components/widgets/unifi_console/unifi_console.test.jsx b/src/components/widgets/unifi_console/unifi_console.test.jsx
new file mode 100644
index 000000000..10e62fef0
--- /dev/null
+++ b/src/components/widgets/unifi_console/unifi_console.test.jsx
@@ -0,0 +1,59 @@
+// @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,
+}));
+
+import UnifiConsole from "./unifi_console";
+
+describe("components/widgets/unifi_console", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("renders a wait state when no site is available yet", () => {
+ useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });
+
+ renderWithProviders(, { 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(, { 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();
+ });
+});