diff --git a/src/widgets/homebox/component.test.jsx b/src/widgets/homebox/component.test.jsx
new file mode 100644
index 000000000..164247d73
--- /dev/null
+++ b/src/widgets/homebox/component.test.jsx
@@ -0,0 +1,78 @@
+// @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, { homeboxDefaultFields } 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/homebox/component", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("defaults fields and filters to 3 blocks while loading", () => {
+ useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });
+
+ const service = { widget: { type: "homebox", url: "http://x" } };
+ const { container } = renderWithProviders(, { settings: { hideErrors: false } });
+
+ expect(service.widget.fields).toEqual(homeboxDefaultFields);
+ expect(container.querySelectorAll(".service-block")).toHaveLength(3);
+ expect(screen.getByText("homebox.items")).toBeInTheDocument();
+ expect(screen.getByText("homebox.locations")).toBeInTheDocument();
+ expect(screen.getByText("homebox.totalValue")).toBeInTheDocument();
+ expect(screen.queryByText("homebox.labels")).toBeNull();
+ expect(screen.queryByText("homebox.users")).toBeNull();
+ expect(screen.queryByText("homebox.totalWithWarranty")).toBeNull();
+ });
+
+ 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 values when loaded (currency formatting delegated to i18n)", () => {
+ useWidgetAPI.mockReturnValue({
+ data: {
+ items: 10,
+ totalWithWarranty: 2,
+ locations: 3,
+ labels: 4,
+ users: 5,
+ totalValue: 123.45,
+ currencyCode: "USD",
+ },
+ error: undefined,
+ });
+
+ const { container } = renderWithProviders(
+ ,
+ {
+ settings: { hideErrors: false },
+ },
+ );
+
+ expect(container.querySelectorAll(".service-block")).toHaveLength(3);
+ expectBlockValue(container, "homebox.items", 10);
+ expectBlockValue(container, "homebox.locations", 3);
+ expectBlockValue(container, "homebox.totalValue", 123.45);
+ });
+});
diff --git a/src/widgets/homebridge/component.test.jsx b/src/widgets/homebridge/component.test.jsx
new file mode 100644
index 000000000..93b33f0e2
--- /dev/null
+++ b/src/widgets/homebridge/component.test.jsx
@@ -0,0 +1,63 @@
+// @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 Component from "./component";
+
+describe("widgets/homebridge/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("widget.status")).toBeInTheDocument();
+ expect(screen.getByText("homebridge.updates")).toBeInTheDocument();
+ expect(screen.getByText("homebridge.child_bridges")).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 update status and child bridge summary when child bridges exist", () => {
+ useWidgetAPI.mockReturnValue({
+ data: {
+ status: "ok",
+ updateAvailable: true,
+ plugins: { updatesAvailable: 0 },
+ childBridges: { total: 2, running: 1 },
+ },
+ error: undefined,
+ });
+
+ renderWithProviders(, {
+ settings: { hideErrors: false },
+ });
+
+ expect(screen.getByText("homebridge.ok")).toBeInTheDocument();
+ expect(screen.getByText("homebridge.update_available")).toBeInTheDocument();
+ // key is returned by the i18n mock; presence indicates the conditional block is rendered.
+ expect(screen.getByText("homebridge.child_bridges_status")).toBeInTheDocument();
+ });
+});
diff --git a/src/widgets/iframe/component.test.jsx b/src/widgets/iframe/component.test.jsx
new file mode 100644
index 000000000..bec86dae3
--- /dev/null
+++ b/src/widgets/iframe/component.test.jsx
@@ -0,0 +1,31 @@
+// @vitest-environment jsdom
+
+import { describe, expect, it } from "vitest";
+
+import { renderWithProviders } from "test-utils/render-with-providers";
+
+import Component from "./component";
+
+describe("widgets/iframe/component", () => {
+ it("renders an iframe with the configured src/title and classes", () => {
+ const service = {
+ widget: {
+ type: "iframe",
+ name: "My Frame",
+ src: "http://example.test",
+ classes: "h-10 w-10",
+ allowScrolling: "no",
+ },
+ };
+
+ const { container } = renderWithProviders(, { settings: { hideErrors: false } });
+
+ const iframe = container.querySelector("iframe");
+ expect(iframe).toBeTruthy();
+ expect(iframe.getAttribute("src")).toBe("http://example.test");
+ expect(iframe.getAttribute("title")).toBe("My Frame");
+ expect(iframe.getAttribute("name")).toBe("My Frame");
+ expect(iframe.getAttribute("scrolling")).toBe("no");
+ expect(iframe.className).toContain("h-10 w-10");
+ });
+});
diff --git a/src/widgets/immich/component.test.jsx b/src/widgets/immich/component.test.jsx
new file mode 100644
index 000000000..fa282ee05
--- /dev/null
+++ b/src/widgets/immich/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/immich/component", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("uses v1 endpoints and renders placeholders while loading", () => {
+ useWidgetAPI
+ .mockReturnValueOnce({ data: undefined, error: undefined }) // version
+ .mockReturnValueOnce({ data: undefined, error: undefined }); // stats
+
+ const { container } = renderWithProviders(, {
+ settings: { hideErrors: false },
+ });
+
+ expect(useWidgetAPI.mock.calls[0][1]).toBe("version");
+ expect(useWidgetAPI.mock.calls[1][1]).toBe("stats");
+ expect(container.querySelectorAll(".service-block")).toHaveLength(4);
+ expect(screen.getByText("immich.users")).toBeInTheDocument();
+ expect(screen.getByText("immich.photos")).toBeInTheDocument();
+ expect(screen.getByText("immich.videos")).toBeInTheDocument();
+ expect(screen.getByText("immich.storage")).toBeInTheDocument();
+ });
+
+ it("selects the v1 statistics endpoint when version is > 1.84", () => {
+ useWidgetAPI.mockReturnValueOnce({ data: { major: 1, minor: 85 }, error: undefined }).mockReturnValueOnce({
+ data: { usageByUser: [{ id: 1 }, { id: 2 }], photos: 3, videos: 4, usage: "9 GiB" },
+ error: undefined,
+ });
+
+ const { container } = renderWithProviders(, {
+ settings: { hideErrors: false },
+ });
+
+ expect(useWidgetAPI.mock.calls[1][1]).toBe("statistics");
+ expectBlockValue(container, "immich.users", 2);
+ expectBlockValue(container, "immich.photos", 3);
+ expectBlockValue(container, "immich.videos", 4);
+ expectBlockValue(container, "immich.storage", "9 GiB");
+ });
+
+ it("uses v2 endpoints when widget.version === 2", () => {
+ useWidgetAPI.mockReturnValueOnce({ data: { major: 2, minor: 0 }, error: undefined }).mockReturnValueOnce({
+ data: { usageByUser: [], photos: 0, videos: 0, usage: 0 },
+ error: undefined,
+ });
+
+ renderWithProviders(, {
+ settings: { hideErrors: false },
+ });
+
+ expect(useWidgetAPI.mock.calls[0][1]).toBe("version_v2");
+ expect(useWidgetAPI.mock.calls[1][1]).toBe("statistics_v2");
+ });
+});
diff --git a/src/widgets/jackett/component.test.jsx b/src/widgets/jackett/component.test.jsx
new file mode 100644
index 000000000..99cb4ce02
--- /dev/null
+++ b/src/widgets/jackett/component.test.jsx
@@ -0,0 +1,71 @@
+// @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/jackett/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(2);
+ expect(screen.getByText("jackett.configured")).toBeInTheDocument();
+ expect(screen.getByText("jackett.errored")).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 configured and errored counts when loaded", () => {
+ useWidgetAPI.mockReturnValue({
+ data: [
+ { id: 1, last_error: "" },
+ { id: 2, last_error: "boom" },
+ { id: 3, last_error: null },
+ ],
+ error: undefined,
+ });
+
+ const { container } = renderWithProviders(
+ ,
+ {
+ settings: { hideErrors: false },
+ },
+ );
+
+ expectBlockValue(container, "jackett.configured", 3);
+ expectBlockValue(container, "jackett.errored", 1);
+ });
+});
diff --git a/src/widgets/jdownloader/component.test.jsx b/src/widgets/jdownloader/component.test.jsx
new file mode 100644
index 000000000..1a3fab6ff
--- /dev/null
+++ b/src/widgets/jdownloader/component.test.jsx
@@ -0,0 +1,83 @@
+// @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/jdownloader/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(4);
+ expect(screen.getByText("jdownloader.downloadCount")).toBeInTheDocument();
+ expect(screen.getByText("jdownloader.downloadTotalBytes")).toBeInTheDocument();
+ expect(screen.getByText("jdownloader.downloadBytesRemaining")).toBeInTheDocument();
+ expect(screen.getByText("jdownloader.downloadSpeed")).toBeInTheDocument();
+ });
+
+ it("calls the unified endpoint with a 30s refresh interval", () => {
+ useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });
+
+ renderWithProviders(, {
+ settings: { hideErrors: false },
+ });
+
+ expect(useWidgetAPI.mock.calls[0][1]).toBe("unified");
+ expect(useWidgetAPI.mock.calls[0][2]?.refreshInterval).toBe(30000);
+ });
+
+ 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 values when loaded", () => {
+ useWidgetAPI.mockReturnValue({
+ data: {
+ downloadCount: 1,
+ totalBytes: 100,
+ bytesRemaining: 40,
+ totalSpeed: 10,
+ },
+ error: undefined,
+ });
+
+ const { container } = renderWithProviders(
+ ,
+ { settings: { hideErrors: false } },
+ );
+
+ expectBlockValue(container, "jdownloader.downloadCount", 1);
+ expectBlockValue(container, "jdownloader.downloadTotalBytes", 100);
+ expectBlockValue(container, "jdownloader.downloadBytesRemaining", 40);
+ expectBlockValue(container, "jdownloader.downloadSpeed", 10);
+ });
+});
diff --git a/src/widgets/jellyfin/component.test.jsx b/src/widgets/jellyfin/component.test.jsx
new file mode 100644
index 000000000..b31193343
--- /dev/null
+++ b/src/widgets/jellyfin/component.test.jsx
@@ -0,0 +1,92 @@
+// @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 Component from "./component";
+
+describe("widgets/jellyfin/component", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("renders CountBlocks placeholders while loading when enableBlocks is true", () => {
+ useWidgetAPI
+ .mockReturnValueOnce({ data: undefined, error: undefined, mutate: vi.fn() }) // sessions
+ .mockReturnValueOnce({ data: undefined, error: undefined }); // count
+
+ renderWithProviders(
+ ,
+ { settings: { hideErrors: false } },
+ );
+
+ expect(screen.getByText("jellyfin.movies")).toBeInTheDocument();
+ expect(screen.getByText("jellyfin.series")).toBeInTheDocument();
+ expect(screen.getByText("jellyfin.episodes")).toBeInTheDocument();
+ expect(screen.getByText("jellyfin.songs")).toBeInTheDocument();
+ expect(screen.getAllByText("-").length).toBeGreaterThan(0);
+ });
+
+ it("renders the no-active message when there are no playing sessions", () => {
+ useWidgetAPI
+ .mockReturnValueOnce({ data: [], error: undefined, mutate: vi.fn() }) // sessions
+ .mockReturnValueOnce({
+ data: { MovieCount: 1, SeriesCount: 2, EpisodeCount: 3, SongCount: 4 },
+ error: undefined,
+ }); // count
+
+ renderWithProviders(
+ ,
+ { settings: { hideErrors: false } },
+ );
+
+ expect(screen.getByText("jellyfin.no_active")).toBeInTheDocument();
+ expect(screen.getByText("1")).toBeInTheDocument();
+ expect(screen.getByText("2")).toBeInTheDocument();
+ expect(screen.getByText("3")).toBeInTheDocument();
+ expect(screen.getByText("4")).toBeInTheDocument();
+ });
+
+ it("renders a single now-playing entry (expanded to two rows by default)", () => {
+ useWidgetAPI
+ .mockReturnValueOnce({
+ data: [
+ {
+ Id: "s1",
+ UserName: "u1",
+ NowPlayingItem: { Name: "Movie1", Type: "Movie", RunTimeTicks: 600000000 },
+ PlayState: { PositionTicks: 0, IsPaused: false, IsMuted: false },
+ TranscodingInfo: { IsVideoDirect: true },
+ },
+ ],
+ error: undefined,
+ mutate: vi.fn(),
+ })
+ .mockReturnValueOnce({
+ data: { MovieCount: 0, SeriesCount: 0, EpisodeCount: 0, SongCount: 0 },
+ error: undefined,
+ });
+
+ renderWithProviders(, {
+ settings: { hideErrors: false },
+ });
+
+ expect(screen.getByText("Movie1")).toBeInTheDocument();
+ // Time strings are rendered in a combined node (e.g. "00:00/01:00").
+ expect(screen.getByText(/00:00/)).toBeInTheDocument();
+ expect(screen.getByText(/01:00/)).toBeInTheDocument();
+ });
+});
diff --git a/src/widgets/jellyseerr/component.test.jsx b/src/widgets/jellyseerr/component.test.jsx
new file mode 100644
index 000000000..16a3fccfb
--- /dev/null
+++ b/src/widgets/jellyseerr/component.test.jsx
@@ -0,0 +1,63 @@
+// @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 Component, { jellyseerrDefaultFields } from "./component";
+
+describe("widgets/jellyseerr/component", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("defaults fields and filters to 3 blocks while loading when issues are not enabled", () => {
+ useWidgetAPI
+ .mockReturnValueOnce({ data: undefined, error: undefined }) // request/count
+ .mockReturnValueOnce({ data: undefined, error: undefined }); // issue/count disabled (endpoint = "")
+
+ const service = { widget: { type: "jellyseerr", url: "http://x" } };
+ const { container } = renderWithProviders(, { settings: { hideErrors: false } });
+
+ expect(service.widget.fields).toEqual(jellyseerrDefaultFields);
+ expect(useWidgetAPI.mock.calls[1][1]).toBe("");
+ expect(container.querySelectorAll(".service-block")).toHaveLength(3);
+ expect(screen.getByText("jellyseerr.pending")).toBeInTheDocument();
+ expect(screen.getByText("jellyseerr.approved")).toBeInTheDocument();
+ expect(screen.getByText("jellyseerr.available")).toBeInTheDocument();
+ expect(screen.queryByText("jellyseerr.issues")).toBeNull();
+ });
+
+ it("renders issues when enabled (and calls the issue/count endpoint)", () => {
+ useWidgetAPI
+ .mockReturnValueOnce({ data: { pending: 1, approved: 2, available: 3 }, error: undefined })
+ .mockReturnValueOnce({ data: { open: 1, total: 2 }, error: undefined });
+
+ const service = {
+ widget: { type: "jellyseerr", url: "http://x", fields: ["pending", "approved", "available", "issues"] },
+ };
+ const { container } = renderWithProviders(, { settings: { hideErrors: false } });
+
+ expect(useWidgetAPI.mock.calls[1][1]).toBe("issue/count");
+ expect(container.querySelectorAll(".service-block")).toHaveLength(4);
+ expect(screen.getByText("1 / 2")).toBeInTheDocument();
+ });
+
+ it("renders error UI when issues are enabled and issue/count errors", () => {
+ useWidgetAPI
+ .mockReturnValueOnce({ data: { pending: 0, approved: 0, available: 0 }, error: undefined })
+ .mockReturnValueOnce({ data: undefined, error: { message: "nope" } });
+
+ renderWithProviders(
+ ,
+ { settings: { hideErrors: false } },
+ );
+
+ expect(screen.getAllByText(/widget\.api_error/i).length).toBeGreaterThan(0);
+ expect(screen.getByText("nope")).toBeInTheDocument();
+ });
+});
diff --git a/src/widgets/karakeep/component.test.jsx b/src/widgets/karakeep/component.test.jsx
new file mode 100644
index 000000000..bf6d7ca2c
--- /dev/null
+++ b/src/widgets/karakeep/component.test.jsx
@@ -0,0 +1,79 @@
+// @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, { karakeepDefaultFields } 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/karakeep/component", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("defaults fields and filters to 4 blocks while loading", () => {
+ useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });
+
+ const service = { widget: { type: "karakeep", url: "http://x" } };
+ const { container } = renderWithProviders(, { settings: { hideErrors: false } });
+
+ expect(service.widget.fields).toEqual(karakeepDefaultFields);
+ expect(container.querySelectorAll(".service-block")).toHaveLength(4);
+ expect(screen.getByText("karakeep.bookmarks")).toBeInTheDocument();
+ expect(screen.getByText("karakeep.favorites")).toBeInTheDocument();
+ expect(screen.getByText("karakeep.archived")).toBeInTheDocument();
+ expect(screen.getByText("karakeep.highlights")).toBeInTheDocument();
+ expect(screen.queryByText("karakeep.lists")).toBeNull();
+ expect(screen.queryByText("karakeep.tags")).toBeNull();
+ });
+
+ it("caps widget.fields at 4 entries", () => {
+ useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });
+
+ const service = { widget: { type: "karakeep", fields: ["tags", "lists", "bookmarks", "favorites", "archived"] } };
+ const { container } = renderWithProviders(, { settings: { hideErrors: false } });
+
+ expect(service.widget.fields).toEqual(["tags", "lists", "bookmarks", "favorites"]);
+ expect(container.querySelectorAll(".service-block")).toHaveLength(4);
+ expect(screen.getByText("karakeep.tags")).toBeInTheDocument();
+ expect(screen.getByText("karakeep.lists")).toBeInTheDocument();
+ });
+
+ it("renders values when loaded", () => {
+ useWidgetAPI.mockReturnValue({
+ data: {
+ numBookmarks: 1,
+ numFavorites: 2,
+ numArchived: 3,
+ numHighlights: 4,
+ numLists: 5,
+ numTags: 6,
+ },
+ error: undefined,
+ });
+
+ const { container } = renderWithProviders(
+ ,
+ {
+ settings: { hideErrors: false },
+ },
+ );
+
+ expect(container.querySelectorAll(".service-block")).toHaveLength(4);
+ expectBlockValue(container, "karakeep.bookmarks", 1);
+ expectBlockValue(container, "karakeep.favorites", 2);
+ expectBlockValue(container, "karakeep.archived", 3);
+ expectBlockValue(container, "karakeep.highlights", 4);
+ });
+});
diff --git a/src/widgets/kavita/component.test.jsx b/src/widgets/kavita/component.test.jsx
new file mode 100644
index 000000000..153715340
--- /dev/null
+++ b/src/widgets/kavita/component.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 { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
+vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
+
+import Component from "./component";
+
+describe("widgets/kavita/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(2);
+ expect(screen.getByText("kavita.seriesCount")).toBeInTheDocument();
+ expect(screen.getByText("kavita.totalFiles")).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 counts when loaded", () => {
+ useWidgetAPI.mockReturnValue({ data: { seriesCount: 12, totalFiles: 34 }, error: undefined });
+
+ renderWithProviders(, {
+ settings: { hideErrors: false },
+ });
+
+ expect(screen.getByText("12")).toBeInTheDocument();
+ expect(screen.getByText("34")).toBeInTheDocument();
+ });
+});