diff --git a/src/widgets/komga/component.test.jsx b/src/widgets/komga/component.test.jsx
new file mode 100644
index 000000000..4459b21bc
--- /dev/null
+++ b/src/widgets/komga/component.test.jsx
@@ -0,0 +1,67 @@
+// @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";
+import { findServiceBlockByLabel } from "test-utils/widget-assertions";
+
+const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
+vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
+
+import Component from "./component";
+
+function expectBlockValue(container, label, value) {
+ const block = findServiceBlockByLabel(container, label);
+ expect(block, `missing block for ${label}`).toBeTruthy();
+ expect(block.textContent).toContain(String(value));
+}
+
+describe("widgets/komga/component", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("renders placeholders while loading", () => {
+ useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });
+
+ const { container } = renderWithProviders(, {
+ settings: { hideErrors: false },
+ });
+
+ expect(container.querySelectorAll(".service-block")).toHaveLength(3);
+ expect(screen.getByText("komga.libraries")).toBeInTheDocument();
+ expect(screen.getByText("komga.series")).toBeInTheDocument();
+ expect(screen.getByText("komga.books")).toBeInTheDocument();
+ });
+
+ it("renders error UI when widget API errors", () => {
+ useWidgetAPI.mockReturnValue({ data: undefined, error: { message: "nope" } });
+
+ renderWithProviders(, {
+ settings: { hideErrors: false },
+ });
+
+ expect(screen.getAllByText(/widget\.api_error/i).length).toBeGreaterThan(0);
+ expect(screen.getByText("nope")).toBeInTheDocument();
+ });
+
+ it("renders library/series/book totals when loaded", () => {
+ useWidgetAPI.mockReturnValue({
+ data: {
+ libraries: [{ id: 1 }, { id: 2 }],
+ series: { totalElements: 10 },
+ books: { totalElements: 20 },
+ },
+ error: undefined,
+ });
+
+ const { container } = renderWithProviders(, {
+ settings: { hideErrors: false },
+ });
+
+ expectBlockValue(container, "komga.libraries", 2);
+ expectBlockValue(container, "komga.series", 10);
+ expectBlockValue(container, "komga.books", 20);
+ });
+});
diff --git a/src/widgets/komodo/component.test.jsx b/src/widgets/komodo/component.test.jsx
new file mode 100644
index 000000000..55856eabc
--- /dev/null
+++ b/src/widgets/komodo/component.test.jsx
@@ -0,0 +1,80 @@
+// @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";
+import { findServiceBlockByLabel } from "test-utils/widget-assertions";
+
+const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
+vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
+
+import Component from "./component";
+
+describe("widgets/komodo/component", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("defaults fields for stacks view and skips containers endpoint when showStacks=true and showSummary=false", () => {
+ useWidgetAPI
+ .mockReturnValueOnce({ data: undefined, error: undefined }) // containers (disabled)
+ .mockReturnValueOnce({ data: undefined, error: undefined }) // stacks
+ .mockReturnValueOnce({ data: undefined, error: undefined }); // servers (disabled)
+
+ const service = { widget: { type: "komodo", showStacks: true, showSummary: false } };
+ const { container } = renderWithProviders(, { settings: { hideErrors: false } });
+
+ expect(service.widget.fields).toEqual(["total", "running", "down", "unhealthy"]);
+ expect(useWidgetAPI.mock.calls[0][1]).toBe(""); // containersEndpoint
+ expect(useWidgetAPI.mock.calls[1][1]).toBe("stacks");
+ expect(useWidgetAPI.mock.calls[2][1]).toBe(""); // serversEndpoint
+
+ // Default fields filter out "unknown" which is rendered but not in widget.fields.
+ expect(container.querySelectorAll(".service-block")).toHaveLength(4);
+ expect(screen.getByText("komodo.total")).toBeInTheDocument();
+ expect(screen.getByText("komodo.running")).toBeInTheDocument();
+ expect(screen.getByText("komodo.down")).toBeInTheDocument();
+ expect(screen.getByText("komodo.unhealthy")).toBeInTheDocument();
+ expect(screen.queryByText("komodo.unknown")).toBeNull();
+ });
+
+ it("renders computed down=stopped+down for stacks view", () => {
+ useWidgetAPI
+ .mockReturnValueOnce({ data: undefined, error: undefined }) // containers (disabled)
+ .mockReturnValueOnce({
+ data: { total: 10, running: 7, stopped: 1, down: 2, unhealthy: 3, unknown: 4 },
+ error: undefined,
+ })
+ .mockReturnValueOnce({ data: undefined, error: undefined }); // servers (disabled)
+
+ const { container } = renderWithProviders(
+ ,
+ { settings: { hideErrors: false } },
+ );
+
+ expect(container.querySelectorAll(".service-block")).toHaveLength(4);
+ expect(screen.getByText("10")).toBeInTheDocument();
+ expect(screen.getByText("7")).toBeInTheDocument();
+ const downBlock = findServiceBlockByLabel(container, "komodo.down");
+ expect(downBlock).toBeTruthy();
+ expect(downBlock.textContent).toContain("3"); // stopped(1) + down(2)
+ });
+
+ it("renders summary view ratios when showSummary=true", () => {
+ useWidgetAPI
+ .mockReturnValueOnce({ data: { total: 5, running: 4 }, error: undefined }) // containers
+ .mockReturnValueOnce({ data: { total: 2, running: 1 }, error: undefined }) // stacks
+ .mockReturnValueOnce({ data: { total: 1, healthy: 1 }, error: undefined }); // servers
+
+ const { container } = renderWithProviders(
+ ,
+ { settings: { hideErrors: false } },
+ );
+
+ expect(container.querySelectorAll(".service-block")).toHaveLength(3);
+ expect(screen.getByText("1 / 1")).toBeInTheDocument();
+ expect(screen.getByText("1 / 2")).toBeInTheDocument();
+ expect(screen.getByText("4 / 5")).toBeInTheDocument();
+ });
+});
diff --git a/src/widgets/kopia/component.test.jsx b/src/widgets/kopia/component.test.jsx
new file mode 100644
index 000000000..96cd4cb36
--- /dev/null
+++ b/src/widgets/kopia/component.test.jsx
@@ -0,0 +1,83 @@
+// @vitest-environment jsdom
+
+import { screen } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import { renderWithProviders } from "test-utils/render-with-providers";
+import { findServiceBlockByLabel } from "test-utils/widget-assertions";
+
+const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
+vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
+
+import Component from "./component";
+
+function expectBlockValue(container, label, value) {
+ const block = findServiceBlockByLabel(container, label);
+ expect(block, `missing block for ${label}`).toBeTruthy();
+ expect(block.textContent).toContain(String(value));
+}
+
+describe("widgets/kopia/component", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date("2020-01-01T00:00:00Z"));
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it("renders placeholders when status data is missing or source filter finds nothing", () => {
+ useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });
+
+ const { container } = renderWithProviders(
+ ,
+ { settings: { hideErrors: false } },
+ );
+
+ expect(container.querySelectorAll(".service-block")).toHaveLength(4);
+ expect(screen.getByText("kopia.status")).toBeInTheDocument();
+ expect(screen.getByText("kopia.size")).toBeInTheDocument();
+ expect(screen.getByText("kopia.lastrun")).toBeInTheDocument();
+ expect(screen.getByText("kopia.nextrun")).toBeInTheDocument();
+ });
+
+ it("renders error UI when widget API errors", () => {
+ useWidgetAPI.mockReturnValue({ data: undefined, error: { message: "nope" } });
+
+ renderWithProviders(, { settings: { hideErrors: false } });
+
+ expect(screen.getAllByText(/widget\.api_error/i).length).toBeGreaterThan(0);
+ expect(screen.getByText("nope")).toBeInTheDocument();
+ });
+
+ it("renders filtered snapshot status, size, and relative last/next run times", () => {
+ useWidgetAPI.mockReturnValue({
+ data: {
+ sources: [
+ {
+ source: { host: "hostA", path: "/data" },
+ status: "OK",
+ lastSnapshot: {
+ startTime: "2019-12-31T22:00:00Z", // 2 hours ago
+ stats: { errorCount: 0, totalSize: 1024 },
+ },
+ nextSnapshotTime: "2020-01-01T00:30:00Z", // 30 minutes ahead
+ },
+ ],
+ },
+ error: undefined,
+ });
+
+ const { container } = renderWithProviders(
+ ,
+ { settings: { hideErrors: false } },
+ );
+
+ expectBlockValue(container, "kopia.status", "OK");
+ expectBlockValue(container, "kopia.size", 1024);
+ expectBlockValue(container, "kopia.lastrun", "2 h");
+ expectBlockValue(container, "kopia.nextrun", "30 m");
+ });
+});
diff --git a/src/widgets/kubernetes/component.test.jsx b/src/widgets/kubernetes/component.test.jsx
new file mode 100644
index 000000000..9c55178b6
--- /dev/null
+++ b/src/widgets/kubernetes/component.test.jsx
@@ -0,0 +1,68 @@
+// @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 Component from "./component";
+
+describe("widgets/kubernetes/component", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("renders placeholders while loading", () => {
+ useSWR.mockReturnValue({ data: undefined, error: undefined });
+
+ const { container } = renderWithProviders(
+ ,
+ { settings: { hideErrors: false } },
+ );
+
+ expect(useSWR.mock.calls[0][0]).toContain("/api/kubernetes/status/ns/app?");
+ expect(useSWR.mock.calls[1][0]).toContain("/api/kubernetes/stats/ns/app?");
+
+ expect(container.querySelectorAll(".service-block")).toHaveLength(2);
+ expect(screen.getByText("docker.cpu")).toBeInTheDocument();
+ expect(screen.getByText("docker.mem")).toBeInTheDocument();
+ });
+
+ it("renders offline status when status endpoint reports non-running state", () => {
+ useSWR.mockImplementation((key) => {
+ if (String(key).includes("/status/")) return { data: { status: "stopped" }, error: undefined };
+ if (String(key).includes("/stats/")) return { data: { stats: { cpu: 0.1, mem: 10 } }, error: undefined };
+ return { data: undefined, error: undefined };
+ });
+
+ renderWithProviders(, {
+ settings: { hideErrors: false },
+ });
+
+ expect(screen.getByText("docker.offline")).toBeInTheDocument();
+ expect(screen.getByText("widget.status")).toBeInTheDocument();
+ });
+
+ it("renders cpu percent when cpuLimit is present, otherwise raw cpu number", () => {
+ useSWR.mockImplementation((key) => {
+ if (String(key).includes("/status/")) return { data: { status: "running" }, error: undefined };
+ if (String(key).includes("/stats/"))
+ return {
+ data: { stats: { cpuLimit: true, cpuUsage: 12.3, cpu: 0.0001, mem: 1024 } },
+ error: undefined,
+ };
+ return { data: undefined, error: undefined };
+ });
+
+ renderWithProviders(, {
+ settings: { hideErrors: false },
+ });
+
+ // With cpuLimit=true, cpuUsage is formatted via common.percent mock -> string value.
+ expect(screen.getByText("12.3")).toBeInTheDocument();
+ expect(screen.getByText("1024")).toBeInTheDocument();
+ });
+});
diff --git a/src/widgets/lidarr/component.test.jsx b/src/widgets/lidarr/component.test.jsx
new file mode 100644
index 000000000..ecbacfd38
--- /dev/null
+++ b/src/widgets/lidarr/component.test.jsx
@@ -0,0 +1,69 @@
+// @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";
+import { findServiceBlockByLabel } from "test-utils/widget-assertions";
+
+const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
+vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
+
+import Component from "./component";
+
+function expectBlockValue(container, label, value) {
+ const block = findServiceBlockByLabel(container, label);
+ expect(block, `missing block for ${label}`).toBeTruthy();
+ expect(block.textContent).toContain(String(value));
+}
+
+describe("widgets/lidarr/component", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("renders placeholders while loading", () => {
+ useWidgetAPI
+ .mockReturnValueOnce({ data: undefined, error: undefined }) // artist
+ .mockReturnValueOnce({ data: undefined, error: undefined }) // wanted/missing
+ .mockReturnValueOnce({ data: undefined, error: undefined }); // queue/status
+
+ const { container } = renderWithProviders(, {
+ settings: { hideErrors: false },
+ });
+
+ expect(container.querySelectorAll(".service-block")).toHaveLength(3);
+ expect(screen.getByText("lidarr.wanted")).toBeInTheDocument();
+ expect(screen.getByText("lidarr.queued")).toBeInTheDocument();
+ expect(screen.getByText("lidarr.artists")).toBeInTheDocument();
+ });
+
+ it("renders error UI when any endpoint errors", () => {
+ useWidgetAPI
+ .mockReturnValueOnce({ data: undefined, error: undefined })
+ .mockReturnValueOnce({ data: undefined, error: { message: "nope" } })
+ .mockReturnValueOnce({ data: undefined, error: undefined });
+
+ renderWithProviders(, {
+ settings: { hideErrors: false },
+ });
+
+ expect(screen.getAllByText(/widget\.api_error/i).length).toBeGreaterThan(0);
+ expect(screen.getByText("nope")).toBeInTheDocument();
+ });
+
+ it("renders wanted/queued/artist counts when loaded", () => {
+ useWidgetAPI
+ .mockReturnValueOnce({ data: [{ id: 1 }, { id: 2 }], error: undefined })
+ .mockReturnValueOnce({ data: { totalRecords: 10 }, error: undefined })
+ .mockReturnValueOnce({ data: { totalCount: 3 }, error: undefined });
+
+ const { container } = renderWithProviders(, {
+ settings: { hideErrors: false },
+ });
+
+ expectBlockValue(container, "lidarr.wanted", 10);
+ expectBlockValue(container, "lidarr.queued", 3);
+ expectBlockValue(container, "lidarr.artists", 2);
+ });
+});
diff --git a/src/widgets/linkwarden/component.test.jsx b/src/widgets/linkwarden/component.test.jsx
new file mode 100644
index 000000000..735b27d2a
--- /dev/null
+++ b/src/widgets/linkwarden/component.test.jsx
@@ -0,0 +1,88 @@
+// @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";
+import { findServiceBlockByLabel } from "test-utils/widget-assertions";
+
+const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
+vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
+
+import Component from "./component";
+
+function expectBlockValue(container, label, value) {
+ const block = findServiceBlockByLabel(container, label);
+ expect(block, `missing block for ${label}`).toBeTruthy();
+ expect(block.textContent).toContain(String(value));
+}
+
+describe("widgets/linkwarden/component", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("renders placeholders while loading", () => {
+ useWidgetAPI
+ .mockReturnValueOnce({ data: undefined, error: undefined }) // collections
+ .mockReturnValueOnce({ data: undefined, error: undefined }); // tags
+
+ const { container } = renderWithProviders(
+ ,
+ { settings: { hideErrors: false } },
+ );
+
+ expect(container.querySelectorAll(".service-block")).toHaveLength(3);
+ expect(screen.getByText("linkwarden.links")).toBeInTheDocument();
+ expect(screen.getByText("linkwarden.collections")).toBeInTheDocument();
+ expect(screen.getByText("linkwarden.tags")).toBeInTheDocument();
+ });
+
+ it("renders error UI when either endpoint errors", () => {
+ useWidgetAPI
+ .mockReturnValueOnce({ data: undefined, error: { message: "nope" } })
+ .mockReturnValueOnce({ data: undefined, error: undefined });
+
+ renderWithProviders(, {
+ settings: { hideErrors: false },
+ });
+
+ expect(screen.getAllByText(/widget\.api_error/i).length).toBeGreaterThan(0);
+ expect(screen.getByText("nope")).toBeInTheDocument();
+ });
+
+ it("computes totalLinks from collection _count.links once both endpoints are loaded", async () => {
+ useWidgetAPI.mockImplementation((widget, endpoint) => {
+ if (endpoint === "collections") {
+ return {
+ data: {
+ response: [
+ // eslint-disable-next-line no-underscore-dangle
+ { _count: { links: 2 } },
+ // eslint-disable-next-line no-underscore-dangle
+ { _count: { links: 4 } },
+ ],
+ },
+ error: undefined,
+ };
+ }
+
+ if (endpoint === "tags") {
+ return { data: { response: [{ id: 1 }, { id: 2 }, { id: 3 }] }, error: undefined };
+ }
+
+ return { data: undefined, error: undefined };
+ });
+
+ const { container } = renderWithProviders(
+ ,
+ { settings: { hideErrors: false } },
+ );
+
+ await waitFor(() => {
+ expectBlockValue(container, "linkwarden.links", 6);
+ });
+ expectBlockValue(container, "linkwarden.collections", 2);
+ expectBlockValue(container, "linkwarden.tags", 3);
+ });
+});
diff --git a/src/widgets/lubelogger/component.test.jsx b/src/widgets/lubelogger/component.test.jsx
new file mode 100644
index 000000000..2890a0f88
--- /dev/null
+++ b/src/widgets/lubelogger/component.test.jsx
@@ -0,0 +1,106 @@
+// @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";
+import { findServiceBlockByLabel } from "test-utils/widget-assertions";
+
+const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
+vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
+
+import Component from "./component";
+
+function expectBlockValue(container, label, value) {
+ const block = findServiceBlockByLabel(container, label);
+ expect(block, `missing block for ${label}`).toBeTruthy();
+ expect(block.textContent).toContain(String(value));
+}
+
+describe("widgets/lubelogger/component", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("renders placeholders while loading", () => {
+ useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });
+
+ const { container } = renderWithProviders(
+ ,
+ { settings: { hideErrors: false } },
+ );
+
+ expect(container.querySelectorAll(".service-block")).toHaveLength(3);
+ expect(screen.getByText("lubelogger.vehicles")).toBeInTheDocument();
+ expect(screen.getByText("lubelogger.serviceRecords")).toBeInTheDocument();
+ expect(screen.getByText("lubelogger.reminders")).toBeInTheDocument();
+ });
+
+ it("renders error UI when widget API errors", () => {
+ useWidgetAPI.mockReturnValue({ data: undefined, error: { message: "nope" } });
+
+ renderWithProviders(, {
+ settings: { hideErrors: false },
+ });
+
+ expect(screen.getAllByText(/widget\.api_error/i).length).toBeGreaterThan(0);
+ expect(screen.getByText("nope")).toBeInTheDocument();
+ });
+
+ it("filters to vehicleID and renders next reminder details when found", () => {
+ useWidgetAPI.mockReturnValue({
+ data: [
+ {
+ vehicleData: { id: 1, year: 2020, model: "Model A" },
+ veryUrgentReminderCount: 1,
+ urgentReminderCount: 2,
+ notUrgentReminderCount: 3,
+ serviceRecordCount: 5,
+ nextReminder: { dueDate: 123 },
+ },
+ {
+ vehicleData: { id: 2, year: 2021, model: "Model B" },
+ veryUrgentReminderCount: 0,
+ urgentReminderCount: 0,
+ notUrgentReminderCount: 0,
+ serviceRecordCount: 1,
+ nextReminder: null,
+ },
+ ],
+ error: undefined,
+ });
+
+ const { container } = renderWithProviders(
+ ,
+ { settings: { hideErrors: false } },
+ );
+
+ expect(container.querySelectorAll(".service-block")).toHaveLength(4);
+ expectBlockValue(container, "lubelogger.vehicle", "2020 Model A");
+ expectBlockValue(container, "lubelogger.serviceRecords", 5);
+ expectBlockValue(container, "lubelogger.reminders", 6);
+ expectBlockValue(container, "lubelogger.nextReminder", 123);
+ });
+
+ it("shows an error when vehicleID is set but not found", () => {
+ useWidgetAPI.mockReturnValue({
+ data: [
+ {
+ vehicleData: { id: 2, year: 2021, model: "Model B" },
+ veryUrgentReminderCount: 0,
+ urgentReminderCount: 0,
+ notUrgentReminderCount: 0,
+ serviceRecordCount: 0,
+ },
+ ],
+ error: undefined,
+ });
+
+ renderWithProviders(, {
+ settings: { hideErrors: false },
+ });
+
+ expect(screen.getAllByText(/widget\.api_error/i).length).toBeGreaterThan(0);
+ expect(screen.getByText("Vehicle not found")).toBeInTheDocument();
+ });
+});
diff --git a/src/widgets/mailcow/component.test.jsx b/src/widgets/mailcow/component.test.jsx
new file mode 100644
index 000000000..adc5794fb
--- /dev/null
+++ b/src/widgets/mailcow/component.test.jsx
@@ -0,0 +1,73 @@
+// @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";
+import { findServiceBlockByLabel } from "test-utils/widget-assertions";
+
+const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
+vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
+
+import Component from "./component";
+
+function expectBlockValue(container, label, value) {
+ const block = findServiceBlockByLabel(container, label);
+ expect(block, `missing block for ${label}`).toBeTruthy();
+ expect(block.textContent).toContain(String(value));
+}
+
+describe("widgets/mailcow/component", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("renders placeholders while loading", () => {
+ useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });
+
+ const { container } = renderWithProviders(
+ ,
+ {
+ settings: { hideErrors: false },
+ },
+ );
+
+ expect(container.querySelectorAll(".service-block")).toHaveLength(3);
+ expect(screen.getByText("mailcow.mailboxes")).toBeInTheDocument();
+ expect(screen.getByText("mailcow.aliases")).toBeInTheDocument();
+ expect(screen.getByText("mailcow.quarantined")).toBeInTheDocument();
+ });
+
+ it("shows a helpful error when the API returns no domains", () => {
+ useWidgetAPI.mockReturnValue({ data: [], error: undefined });
+
+ renderWithProviders(, {
+ settings: { hideErrors: false },
+ });
+
+ expect(screen.getAllByText(/widget\.api_error/i).length).toBeGreaterThan(0);
+ expect(screen.getByText("No domains found")).toBeInTheDocument();
+ });
+
+ it("renders computed totals when loaded", () => {
+ useWidgetAPI.mockReturnValue({
+ data: [
+ { mboxes_in_domain: "2", msgs_total: "10", bytes_total: "100" },
+ { mboxes_in_domain: "1", msgs_total: "5", bytes_total: "50" },
+ ],
+ error: undefined,
+ });
+
+ const { container } = renderWithProviders(
+ ,
+ {
+ settings: { hideErrors: false },
+ },
+ );
+
+ expectBlockValue(container, "mailcow.domains", 2);
+ expectBlockValue(container, "mailcow.mailboxes", 3);
+ expectBlockValue(container, "mailcow.mails", 15);
+ expectBlockValue(container, "mailcow.storage", 150);
+ });
+});
diff --git a/src/widgets/mastodon/component.test.jsx b/src/widgets/mastodon/component.test.jsx
new file mode 100644
index 000000000..589dc6fe1
--- /dev/null
+++ b/src/widgets/mastodon/component.test.jsx
@@ -0,0 +1,69 @@
+// @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";
+import { findServiceBlockByLabel } from "test-utils/widget-assertions";
+
+const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
+vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
+
+import Component from "./component";
+
+function expectBlockValue(container, label, value) {
+ const block = findServiceBlockByLabel(container, label);
+ expect(block, `missing block for ${label}`).toBeTruthy();
+ expect(block.textContent).toContain(String(value));
+}
+
+describe("widgets/mastodon/component", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("renders placeholders while loading", () => {
+ useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });
+
+ const { container } = renderWithProviders(
+ ,
+ {
+ settings: { hideErrors: false },
+ },
+ );
+
+ expect(container.querySelectorAll(".service-block")).toHaveLength(3);
+ expect(screen.getByText("mastodon.user_count")).toBeInTheDocument();
+ expect(screen.getByText("mastodon.status_count")).toBeInTheDocument();
+ expect(screen.getByText("mastodon.domain_count")).toBeInTheDocument();
+ });
+
+ it("renders error UI when widget API errors", () => {
+ useWidgetAPI.mockReturnValue({ data: undefined, error: { message: "nope" } });
+
+ renderWithProviders(, {
+ settings: { hideErrors: false },
+ });
+
+ expect(screen.getAllByText(/widget\.api_error/i).length).toBeGreaterThan(0);
+ expect(screen.getByText("nope")).toBeInTheDocument();
+ });
+
+ it("renders instance stats when loaded", () => {
+ useWidgetAPI.mockReturnValue({
+ data: { stats: { user_count: 1, status_count: 2, domain_count: 3 } },
+ error: undefined,
+ });
+
+ const { container } = renderWithProviders(
+ ,
+ {
+ settings: { hideErrors: false },
+ },
+ );
+
+ expectBlockValue(container, "mastodon.user_count", 1);
+ expectBlockValue(container, "mastodon.status_count", 2);
+ expectBlockValue(container, "mastodon.domain_count", 3);
+ });
+});
diff --git a/src/widgets/mealie/component.test.jsx b/src/widgets/mealie/component.test.jsx
new file mode 100644
index 000000000..7cb2fe660
--- /dev/null
+++ b/src/widgets/mealie/component.test.jsx
@@ -0,0 +1,57 @@
+// @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";
+import { findServiceBlockByLabel } from "test-utils/widget-assertions";
+
+const { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
+vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
+
+import Component from "./component";
+
+function expectBlockValue(container, label, value) {
+ const block = findServiceBlockByLabel(container, label);
+ expect(block, `missing block for ${label}`).toBeTruthy();
+ expect(block.textContent).toContain(String(value));
+}
+
+describe("widgets/mealie/component", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("uses v1 endpoint by default and renders placeholders while loading", () => {
+ useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });
+
+ const { container } = renderWithProviders(, {
+ settings: { hideErrors: false },
+ });
+
+ expect(useWidgetAPI.mock.calls[0][1]).toBe("statisticsv1");
+ expect(container.querySelectorAll(".service-block")).toHaveLength(4);
+ expect(screen.getByText("mealie.recipes")).toBeInTheDocument();
+ expect(screen.getByText("mealie.users")).toBeInTheDocument();
+ expect(screen.getByText("mealie.categories")).toBeInTheDocument();
+ expect(screen.getByText("mealie.tags")).toBeInTheDocument();
+ });
+
+ it("uses v2 endpoint when widget.version === 2 and renders counts", () => {
+ useWidgetAPI.mockReturnValue({
+ data: { totalRecipes: 1, totalUsers: 2, totalCategories: 3, totalTags: 4 },
+ error: undefined,
+ });
+
+ const { container } = renderWithProviders(
+ ,
+ { settings: { hideErrors: false } },
+ );
+
+ expect(useWidgetAPI.mock.calls[0][1]).toBe("statisticsv2");
+ expectBlockValue(container, "mealie.recipes", 1);
+ expectBlockValue(container, "mealie.users", 2);
+ expectBlockValue(container, "mealie.categories", 3);
+ expectBlockValue(container, "mealie.tags", 4);
+ });
+});