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

@@ -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/adguard/component", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders placeholders while loading", () => {
useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });
const { container } = renderWithProviders(<Component service={{ widget: { type: "adguard" } }} />, {
settings: { hideErrors: false },
});
expect(container.querySelectorAll(".service-block")).toHaveLength(4);
expect(screen.getByText("adguard.queries")).toBeInTheDocument();
expect(screen.getByText("adguard.blocked")).toBeInTheDocument();
expect(screen.getByText("adguard.filtered")).toBeInTheDocument();
expect(screen.getByText("adguard.latency")).toBeInTheDocument();
});
it("renders error UI when widget API errors", () => {
useWidgetAPI.mockReturnValue({ data: undefined, error: { message: "nope" } });
renderWithProviders(<Component service={{ widget: { type: "adguard" } }} />, { settings: { hideErrors: false } });
expect(screen.getAllByText(/widget\.api_error/i).length).toBeGreaterThan(0);
});
it("renders computed filtered and latency values", () => {
useWidgetAPI.mockReturnValue({
data: {
num_dns_queries: 100,
num_blocked_filtering: 20,
num_replaced_safebrowsing: 1,
num_replaced_safesearch: 2,
num_replaced_parental: 3,
avg_processing_time: 0.01,
},
error: undefined,
});
renderWithProviders(<Component service={{ widget: { type: "adguard" } }} />, { settings: { hideErrors: false } });
expect(screen.getByText("100")).toBeInTheDocument();
expect(screen.getByText("20")).toBeInTheDocument();
expect(screen.getByText("6")).toBeInTheDocument(); // filtered sum
expect(screen.getByText("10")).toBeInTheDocument(); // 0.01s -> 10ms
});
});

View File

@@ -0,0 +1,12 @@
import { describe, expect, it } from "vitest";
import { expectWidgetConfigShape } from "test-utils/widget-config";
import widget from "./widget";
describe("adguard widget config", () => {
it("exports a valid widget config", () => {
expectWidgetConfigShape(widget);
expect(widget.mappings?.stats?.endpoint).toBe("stats");
});
});

View File

@@ -0,0 +1,48 @@
// @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/apcups/component", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders placeholders while loading", () => {
useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });
const { container } = renderWithProviders(<Component service={{ widget: { type: "apcups" } }} />, {
settings: { hideErrors: false },
});
expect(container.querySelectorAll(".service-block")).toHaveLength(4);
expect(screen.getByText("apcups.status")).toBeInTheDocument();
expect(screen.getByText("apcups.load")).toBeInTheDocument();
expect(screen.getByText("apcups.bcharge")).toBeInTheDocument();
expect(screen.getByText("apcups.timeleft")).toBeInTheDocument();
});
it("renders values when loaded", () => {
useWidgetAPI.mockReturnValue({
data: { status: "ONLINE", load: "12", bcharge: "99", timeleft: "30" },
error: undefined,
});
renderWithProviders(<Component service={{ widget: { type: "apcups" } }} />, { settings: { hideErrors: false } });
expect(screen.getByText("ONLINE")).toBeInTheDocument();
expect(screen.getByText("12")).toBeInTheDocument();
expect(screen.getByText("99")).toBeInTheDocument();
expect(screen.getByText("30")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,88 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import createMockRes from "test-utils/create-mock-res";
function encodeLine(line) {
const buf = Buffer.alloc(2 + line.length);
buf.writeUInt16BE(line.length, 0);
buf.write(line, 2, "ascii");
return buf;
}
const { getServiceWidget, logger } = vi.hoisted(() => ({
getServiceWidget: vi.fn(),
logger: { debug: vi.fn(), error: vi.fn() },
}));
vi.mock("utils/config/service-helpers", () => ({
default: getServiceWidget,
}));
vi.mock("utils/logger", () => ({
default: () => logger,
}));
vi.mock("node:net", () => {
class FakeSocket {
constructor() {
this._handlers = new Map();
}
setTimeout() {}
connect() {
queueMicrotask(() => this._emit("connect"));
}
on(event, cb) {
const set = this._handlers.get(event) ?? new Set();
set.add(cb);
this._handlers.set(event, set);
}
write() {
const response = Buffer.concat([
encodeLine("STATUS : ONLINE"),
encodeLine("LOADPCT : 10.0"),
encodeLine("BCHARGE : 99.0"),
encodeLine("TIMELEFT : 12.3"),
encodeLine("END APC"),
Buffer.from([0x00, 0x00]),
]);
queueMicrotask(() => this._emit("data", response));
}
end() {}
destroy() {}
_emit(event, payload) {
const set = this._handlers.get(event);
if (!set) return;
set.forEach((cb) => cb(payload));
}
}
return {
default: {
Socket: FakeSocket,
},
};
});
import apcupsProxyHandler from "./proxy";
describe("widgets/apcups/proxy", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("parses the APCUPSD status response into JSON", async () => {
getServiceWidget.mockResolvedValue({ url: "http://127.0.0.1:3551" });
const req = { query: { group: "g", service: "svc", index: "0" } };
const res = createMockRes();
await apcupsProxyHandler(req, res);
expect(res.statusCode).toBe(200);
expect(res.body).toEqual({
status: "ONLINE",
load: "10.0",
bcharge: "99.0",
timeleft: "12.3",
});
});
});

View File

@@ -0,0 +1,13 @@
import { describe, expect, it } from "vitest";
import { expectWidgetConfigShape } from "test-utils/widget-config";
import widget from "./widget";
describe("apcups widget config", () => {
it("exports a valid widget config", () => {
expectWidgetConfigShape(widget);
// apcups talks TCP directly, so it does not use an `{url}/...` API template.
expect(widget.api).toBeUndefined();
});
});

View File

@@ -0,0 +1,123 @@
// @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";
function expectBlockValue(container, label, value) {
const blocks = Array.from(container.querySelectorAll(".service-block"));
const block = blocks.find((b) => b.textContent?.includes(label));
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/arcane/component", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("shows an environment required error when env is missing", () => {
renderWithProviders(<Component service={{ widget: { type: "arcane" } }} />, {
settings: { hideErrors: false },
});
expect(useWidgetAPI).not.toHaveBeenCalled();
expect(screen.getByText("arcane.environment_required")).toBeInTheDocument();
});
it("renders placeholders while loading data", () => {
useWidgetAPI.mockImplementation(() => ({ data: undefined, error: undefined }));
const { container } = renderWithProviders(<Component service={{ widget: { type: "arcane", env: "prod" } }} />, {
settings: { hideErrors: false },
});
// defaults to the first four fields when none are provided
expect(container.querySelectorAll(".service-block")).toHaveLength(4);
expect(screen.getByText("docker.running")).toBeInTheDocument();
expect(screen.getByText("dockhand.stopped")).toBeInTheDocument();
expect(screen.getByText("dockhand.total")).toBeInTheDocument();
expect(screen.getByText("arcane.image_updates")).toBeInTheDocument();
});
it("truncates custom fields to the max allowed", () => {
useWidgetAPI.mockImplementation(() => ({ data: undefined, error: undefined }));
const service = {
widget: { type: "arcane", env: "prod", fields: ["running", "stopped", "total", "images", "images_unused"] },
};
const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });
// sliced to first four entries
expect(container.querySelectorAll(".service-block")).toHaveLength(4);
expect(screen.getByText("docker.running")).toBeInTheDocument();
expect(screen.getByText("dockhand.stopped")).toBeInTheDocument();
expect(screen.getByText("dockhand.total")).toBeInTheDocument();
expect(screen.getByText("arcane.images")).toBeInTheDocument();
expect(screen.queryByText("arcane.images_unused")).toBeNull();
});
it("renders error UI when any widget call fails", () => {
useWidgetAPI.mockImplementation((widget, endpoint) => {
if (endpoint === "containers") {
return { data: undefined, error: { message: "boom" } };
}
return { data: undefined, error: undefined };
});
const { container } = renderWithProviders(<Component service={{ widget: { type: "arcane", env: "prod" } }} />, {
settings: { hideErrors: false },
});
expect(container.querySelectorAll(".service-block")).toHaveLength(0);
expect(screen.getByText("boom")).toBeInTheDocument();
});
it("renders values when API data is available", () => {
useWidgetAPI.mockImplementation((widget, endpoint) => {
if (endpoint === "containers") {
return { data: { runningContainers: 3, totalContainers: 5, stoppedContainers: 2 }, error: undefined };
}
if (endpoint === "images") {
return { data: { totalImages: 7, imagesInuse: 4, imagesUnused: 3 }, error: undefined };
}
if (endpoint === "updates") {
return { data: { imagesWithUpdates: 2 }, error: undefined };
}
return { data: undefined, error: undefined };
});
const { container } = renderWithProviders(<Component service={{ widget: { type: "arcane", env: "prod" } }} />, {
settings: { hideErrors: false },
});
expect(container.querySelectorAll(".service-block")).toHaveLength(4);
expectBlockValue(container, "docker.running", 3);
expectBlockValue(container, "dockhand.stopped", 2);
expectBlockValue(container, "dockhand.total", 5);
expectBlockValue(container, "arcane.image_updates", 2);
});
it("falls back to zero when counts are missing", () => {
useWidgetAPI.mockImplementation(() => ({ data: {}, error: undefined }));
const { container } = renderWithProviders(<Component service={{ widget: { type: "arcane", env: "prod" } }} />, {
settings: { hideErrors: false },
});
expect(container.querySelectorAll(".service-block")).toHaveLength(4);
expectBlockValue(container, "docker.running", 0);
expectBlockValue(container, "dockhand.stopped", 0);
expectBlockValue(container, "dockhand.total", 0);
expectBlockValue(container, "arcane.image_updates", 0);
});
});

View File

@@ -0,0 +1,11 @@
import { describe, it } from "vitest";
import { expectWidgetConfigShape } from "test-utils/widget-config";
import widget from "./widget";
describe("arcane widget config", () => {
it("exports a valid widget config", () => {
expectWidgetConfigShape(widget);
});
});

View File

@@ -0,0 +1,65 @@
// @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";
function expectBlockValue(container, label, value) {
const blocks = Array.from(container.querySelectorAll(".service-block"));
const block = blocks.find((b) => b.textContent?.includes(label));
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/argocd/component", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("defaults and truncates widget.fields to 4 and renders placeholders while loading", () => {
useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });
const service = { widget: { type: "argocd", fields: ["apps", "synced", "outOfSync", "healthy", "extra"] } };
const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });
expect(service.widget.fields).toEqual(["apps", "synced", "outOfSync", "healthy"]);
expect(container.querySelectorAll(".service-block")).toHaveLength(4);
expect(screen.getByText("argocd.apps")).toBeInTheDocument();
expect(screen.getByText("argocd.synced")).toBeInTheDocument();
expect(screen.getByText("argocd.outOfSync")).toBeInTheDocument();
expect(screen.getByText("argocd.healthy")).toBeInTheDocument();
});
it("renders counts when loaded", () => {
useWidgetAPI.mockReturnValue({
data: {
items: [
{ status: { sync: { status: "Synced" }, health: { status: "Healthy" } } },
{ status: { sync: { status: "OutOfSync" }, health: { status: "Degraded" } } },
{ status: { sync: { status: "Synced" }, health: { status: "Healthy" } } },
],
},
error: undefined,
});
const service = { widget: { type: "argocd" } };
const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });
// Default widget fields: apps/synced/outOfSync/healthy => all 4 should be visible.
expect(container.querySelectorAll(".service-block")).toHaveLength(4);
expectBlockValue(container, "argocd.apps", 3);
expectBlockValue(container, "argocd.synced", 2);
expectBlockValue(container, "argocd.outOfSync", 1);
expectBlockValue(container, "argocd.healthy", 2);
});
});

View File

@@ -0,0 +1,11 @@
import { describe, it } from "vitest";
import { expectWidgetConfigShape } from "test-utils/widget-config";
import widget from "./widget";
describe("argocd widget config", () => {
it("exports a valid widget config", () => {
expectWidgetConfigShape(widget);
});
});

View File

@@ -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/atsumeru/component", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders placeholders while loading", () => {
useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });
const { container } = renderWithProviders(<Component service={{ widget: { type: "atsumeru" } }} />, {
settings: { hideErrors: false },
});
expect(container.querySelectorAll(".service-block")).toHaveLength(4);
expect(screen.getByText("atsumeru.series")).toBeInTheDocument();
expect(screen.getByText("atsumeru.archives")).toBeInTheDocument();
expect(screen.getByText("atsumeru.chapters")).toBeInTheDocument();
expect(screen.getByText("atsumeru.categories")).toBeInTheDocument();
});
it("renders values when loaded", () => {
useWidgetAPI.mockReturnValue({
data: { stats: { total_series: 1, total_archives: 2, total_chapters: 3, total_categories: 4 } },
error: undefined,
});
const { container } = renderWithProviders(<Component service={{ widget: { type: "atsumeru" } }} />, {
settings: { hideErrors: false },
});
expect(container.querySelectorAll(".service-block")).toHaveLength(4);
expect(screen.getByText("1")).toBeInTheDocument();
expect(screen.getByText("2")).toBeInTheDocument();
expect(screen.getByText("3")).toBeInTheDocument();
expect(screen.getByText("4")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,11 @@
import { describe, it } from "vitest";
import { expectWidgetConfigShape } from "test-utils/widget-config";
import widget from "./widget";
describe("atsumeru widget config", () => {
it("exports a valid widget config", () => {
expectWidgetConfigShape(widget);
});
});

View File

@@ -0,0 +1,62 @@
// @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";
function expectBlockValue(container, label, value) {
const blocks = Array.from(container.querySelectorAll(".service-block"));
const block = blocks.find((b) => b.textContent?.includes(label));
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/audiobookshelf/component", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders placeholders while loading", () => {
useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });
const { container } = renderWithProviders(<Component service={{ widget: { type: "audiobookshelf" } }} />, {
settings: { hideErrors: false },
});
expect(container.querySelectorAll(".service-block")).toHaveLength(4);
expect(screen.getByText("audiobookshelf.podcasts")).toBeInTheDocument();
expect(screen.getByText("audiobookshelf.podcastsDuration")).toBeInTheDocument();
expect(screen.getByText("audiobookshelf.books")).toBeInTheDocument();
expect(screen.getByText("audiobookshelf.booksDuration")).toBeInTheDocument();
});
it("aggregates totals across libraries by mediaType", () => {
useWidgetAPI.mockReturnValue({
data: [
{ mediaType: "podcast", stats: { totalItems: "2", totalDuration: "100" } },
{ mediaType: "podcast", stats: { totalItems: "1", totalDuration: "200" } },
{ mediaType: "book", stats: { totalItems: "4", totalDuration: "300" } },
],
error: undefined,
});
const { container } = renderWithProviders(<Component service={{ widget: { type: "audiobookshelf" } }} />, {
settings: { hideErrors: false },
});
expect(container.querySelectorAll(".service-block")).toHaveLength(4);
expectBlockValue(container, "audiobookshelf.podcasts", 3);
expectBlockValue(container, "audiobookshelf.podcastsDuration", 300);
expectBlockValue(container, "audiobookshelf.books", 4);
expectBlockValue(container, "audiobookshelf.booksDuration", 300);
});
});

View File

@@ -0,0 +1,67 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import createMockRes from "test-utils/create-mock-res";
const { httpProxy, getServiceWidget, logger } = vi.hoisted(() => ({
httpProxy: vi.fn(),
getServiceWidget: vi.fn(),
logger: { debug: vi.fn(), error: vi.fn() },
}));
vi.mock("utils/logger", () => ({
default: () => logger,
}));
vi.mock("utils/config/service-helpers", () => ({
default: getServiceWidget,
}));
vi.mock("utils/proxy/http", () => ({
httpProxy,
}));
vi.mock("widgets/widgets", () => ({
default: {
audiobookshelf: {
api: "{url}/api/{endpoint}",
},
},
}));
import audiobookshelfProxyHandler from "./proxy";
describe("widgets/audiobookshelf/proxy", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("retrieves libraries and per-library stats", async () => {
getServiceWidget.mockResolvedValue({ type: "audiobookshelf", url: "http://abs", key: "k" });
httpProxy
.mockResolvedValueOnce([
200,
"application/json",
Buffer.from(
JSON.stringify({
libraries: [
{ id: "l1", name: "A" },
{ id: "l2", name: "B" },
],
}),
),
])
.mockResolvedValueOnce([200, "application/json", Buffer.from(JSON.stringify({ total: 1 }))])
.mockResolvedValueOnce([200, "application/json", Buffer.from(JSON.stringify({ total: 2 }))]);
const req = { query: { group: "g", service: "svc", endpoint: "libraries", index: "0" } };
const res = createMockRes();
await audiobookshelfProxyHandler(req, res);
expect(httpProxy).toHaveBeenCalledTimes(3);
expect(httpProxy.mock.calls[0][1].headers.Authorization).toBe("Bearer k");
expect(res.statusCode).toBe(200);
expect(res.body).toEqual([
{ id: "l1", name: "A", stats: { total: 1 } },
{ id: "l2", name: "B", stats: { total: 2 } },
]);
});
});

View File

@@ -0,0 +1,12 @@
import { describe, expect, it } from "vitest";
import { expectWidgetConfigShape } from "test-utils/widget-config";
import widget from "./widget";
describe("audiobookshelf widget config", () => {
it("exports a valid widget config", () => {
expectWidgetConfigShape(widget);
expect(widget.mappings?.libraries?.endpoint).toBe("libraries");
});
});

View File

@@ -0,0 +1,108 @@
// @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";
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 blocks = Array.from(container.querySelectorAll(".service-block"));
const block = blocks.find((b) => b.textContent?.includes(label));
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/authentik/component", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.useRealTimers();
});
it("renders placeholders while loading", () => {
useWidgetAPI.mockImplementation(() => ({ data: undefined, error: undefined }));
const { container } = renderWithProviders(<Component service={{ widget: { type: "authentik", version: 2 } }} />, {
settings: { hideErrors: false },
});
expect(container.querySelectorAll(".service-block")).toHaveLength(3);
expect(screen.getByText("authentik.users")).toBeInTheDocument();
expect(screen.getByText("authentik.loginsLast24H")).toBeInTheDocument();
expect(screen.getByText("authentik.failedLoginsLast24H")).toBeInTheDocument();
});
it("computes v2 login/failed counts from action data", () => {
useWidgetAPI.mockImplementation((widget, endpoint) => {
if (endpoint === "users") return { data: { pagination: { count: 10 } }, error: undefined };
if (endpoint === "loginv2")
return {
data: [
{ action: "login", count: 2 },
{ action: "logout", count: 9 },
],
error: undefined,
};
if (endpoint === "login_failedv2") return { data: [{ count: 3 }, { count: null }], error: undefined };
return { data: undefined, error: undefined };
});
const { container } = renderWithProviders(<Component service={{ widget: { type: "authentik", version: 2 } }} />, {
settings: { hideErrors: false },
});
expect(container.querySelectorAll(".service-block")).toHaveLength(3);
expectBlockValue(container, "authentik.users", 10);
expectBlockValue(container, "authentik.loginsLast24H", 2);
expectBlockValue(container, "authentik.failedLoginsLast24H", 3);
});
it("computes v1 login/failed counts for entries within the last 24h window", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-02T00:00:00Z"));
const now = Date.now();
const oneHourAgo = now - 60 * 60 * 1000;
const twentyFiveHoursAgo = now - 25 * 60 * 60 * 1000;
useWidgetAPI.mockImplementation((widget, endpoint) => {
if (endpoint === "users") return { data: { pagination: { count: 5 } }, error: undefined };
if (endpoint === "login")
return {
data: [
{ x_cord: oneHourAgo, y_cord: 2 },
{ x_cord: twentyFiveHoursAgo, y_cord: 100 },
],
error: undefined,
};
if (endpoint === "login_failed")
return {
data: [
{ x_cord: oneHourAgo, y_cord: 1 },
{ x_cord: twentyFiveHoursAgo, y_cord: 50 },
],
error: undefined,
};
return { data: undefined, error: undefined };
});
const { container } = renderWithProviders(<Component service={{ widget: { type: "authentik", version: 1 } }} />, {
settings: { hideErrors: false },
});
expect(container.querySelectorAll(".service-block")).toHaveLength(3);
expectBlockValue(container, "authentik.users", 5);
expectBlockValue(container, "authentik.loginsLast24H", 2);
expectBlockValue(container, "authentik.failedLoginsLast24H", 1);
});
});

View File

@@ -0,0 +1,13 @@
import { describe, expect, it } from "vitest";
import { expectWidgetConfigShape } from "test-utils/widget-config";
import widget from "./widget";
describe("authentik widget config", () => {
it("exports a valid widget config", () => {
expectWidgetConfigShape(widget);
expect(widget.api).toContain("/api/v3/");
expect(widget.mappings?.users?.endpoint).toContain("core/users");
});
});

View File

@@ -0,0 +1,60 @@
// @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";
function expectBlockValue(container, label, value) {
const blocks = Array.from(container.querySelectorAll(".service-block"));
const block = blocks.find((b) => b.textContent?.includes(label));
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/autobrr/component", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders placeholders while loading", () => {
useWidgetAPI.mockImplementation(() => ({ data: undefined, error: undefined }));
const { container } = renderWithProviders(<Component service={{ widget: { type: "autobrr" } }} />, {
settings: { hideErrors: false },
});
expect(container.querySelectorAll(".service-block")).toHaveLength(4);
expect(screen.getByText("autobrr.approvedPushes")).toBeInTheDocument();
expect(screen.getByText("autobrr.rejectedPushes")).toBeInTheDocument();
expect(screen.getByText("autobrr.filters")).toBeInTheDocument();
expect(screen.getByText("autobrr.indexers")).toBeInTheDocument();
});
it("renders values when loaded", () => {
useWidgetAPI.mockImplementation((widget, endpoint) => {
if (endpoint === "stats") return { data: { push_approved_count: 1, push_rejected_count: 2 }, error: undefined };
if (endpoint === "filters") return { data: [{}, {}], error: undefined };
if (endpoint === "indexers") return { data: [{}], error: undefined };
return { data: undefined, error: undefined };
});
const { container } = renderWithProviders(<Component service={{ widget: { type: "autobrr" } }} />, {
settings: { hideErrors: false },
});
expect(container.querySelectorAll(".service-block")).toHaveLength(4);
expectBlockValue(container, "autobrr.approvedPushes", 1);
expectBlockValue(container, "autobrr.rejectedPushes", 2);
expectBlockValue(container, "autobrr.filters", 2);
expectBlockValue(container, "autobrr.indexers", 1);
});
});

View File

@@ -0,0 +1,11 @@
import { describe, it } from "vitest";
import { expectWidgetConfigShape } from "test-utils/widget-config";
import widget from "./widget";
describe("autobrr widget config", () => {
it("exports a valid widget config", () => {
expectWidgetConfigShape(widget);
});
});

View File

@@ -0,0 +1,101 @@
// @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";
function expectBlockValue(container, label, value) {
const blocks = Array.from(container.querySelectorAll(".service-block"));
const block = blocks.find((b) => b.textContent?.includes(label));
expect(block, `missing block for ${label}`).toBeTruthy();
expect(block.textContent).toContain(String(value));
}
describe("widgets/azuredevops/component", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders placeholders while loading", () => {
useWidgetAPI.mockImplementation(() => ({ data: undefined, error: undefined }));
const { container } = renderWithProviders(<Component service={{ widget: { type: "azuredevops" } }} />, {
settings: { hideErrors: false },
});
expect(container.querySelectorAll(".service-block")).toHaveLength(4);
expect(screen.getByText("azuredevops.result")).toBeInTheDocument();
expect(screen.getByText("azuredevops.totalPrs")).toBeInTheDocument();
expect(screen.getByText("azuredevops.myPrs")).toBeInTheDocument();
expect(screen.getByText("azuredevops.approved")).toBeInTheDocument();
});
it("renders pipeline result without PR blocks when includePR is false", () => {
useWidgetAPI.mockImplementation((widget, endpoint) => {
if (endpoint === null) return { data: undefined, error: undefined };
if (endpoint === "pipeline")
return { data: { value: [{ result: "succeeded", status: "completed" }] }, error: undefined };
return { data: undefined, error: undefined };
});
const { container } = renderWithProviders(<Component service={{ widget: { type: "azuredevops" } }} />, {
settings: { hideErrors: false },
});
expect(container.querySelectorAll(".service-block")).toHaveLength(1);
expectBlockValue(container, "azuredevops.result", "azuredevops.succeeded");
});
it("renders pipeline status and PR aggregates when includePR is true", () => {
useWidgetAPI.mockImplementation((widget, endpoint) => {
if (endpoint === "pipeline") return { data: { value: [{ status: "inProgress" }] }, error: undefined };
if (endpoint === "pr")
return {
data: {
count: 3,
value: [
{ createdBy: { uniqueName: "me@example.com" }, reviewers: [{ vote: 5 }] },
{ createdBy: { uniqueName: "me@example.com" }, reviewers: [{ vote: 0 }] },
{ createdBy: { uniqueName: "other@example.com" }, reviewers: [{ vote: 10 }] },
],
},
error: undefined,
};
return { data: undefined, error: undefined };
});
const service = {
widget: { type: "azuredevops", userEmail: "me@example.com", repositoryId: "repo1" },
};
const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });
expect(container.querySelectorAll(".service-block")).toHaveLength(4);
expectBlockValue(container, "azuredevops.status", "azuredevops.inProgress");
expectBlockValue(container, "azuredevops.totalPrs", 3);
expectBlockValue(container, "azuredevops.myPrs", 2);
expectBlockValue(container, "azuredevops.approved", 1);
});
it("renders PR error message when PR call returns an errorCode", () => {
useWidgetAPI.mockImplementation((widget, endpoint) => {
if (endpoint === "pipeline") return { data: { value: [{ result: "succeeded" }] }, error: undefined };
if (endpoint === "pr") return { data: { errorCode: 1, message: "Bad PR" }, error: undefined };
return { data: undefined, error: undefined };
});
const service = { widget: { type: "azuredevops", userEmail: "me@example.com", repositoryId: "repo1" } };
renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });
expect(screen.getByText("Bad PR")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,11 @@
import { describe, it } from "vitest";
import { expectWidgetConfigShape } from "test-utils/widget-config";
import widget from "./widget";
describe("azuredevops widget config", () => {
it("exports a valid widget config", () => {
expectWidgetConfigShape(widget);
});
});

View File

@@ -0,0 +1,81 @@
// @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/backrest/component", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("defaults widget.fields and filters placeholders down to 4 blocks while loading", () => {
useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });
const service = { widget: { type: "backrest" } };
const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });
expect(service.widget.fields).toEqual([
"num_success_latest",
"num_failure_latest",
"num_failure_30",
"bytes_added_30",
]);
expect(container.querySelectorAll(".service-block")).toHaveLength(4);
expect(screen.getByText("backrest.num_success_latest")).toBeInTheDocument();
expect(screen.getByText("backrest.num_failure_latest")).toBeInTheDocument();
expect(screen.getByText("backrest.num_failure_30")).toBeInTheDocument();
expect(screen.getByText("backrest.bytes_added_30")).toBeInTheDocument();
expect(screen.queryByText("backrest.num_plans")).toBeNull();
});
it("truncates widget.fields to 4", () => {
useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });
const service = {
widget: { type: "backrest", fields: ["a", "b", "c", "d", "e"] },
};
renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });
expect(service.widget.fields).toEqual(["a", "b", "c", "d"]);
});
it("renders values and respects field filtering", () => {
useWidgetAPI.mockReturnValue({
data: {
numPlans: 10,
numSuccessLatest: 1,
numFailureLatest: 2,
numSuccess30Days: 3,
numFailure30Days: 4,
bytesAdded30Days: 500,
},
error: undefined,
});
const { container } = renderWithProviders(<Component service={{ widget: { type: "backrest" } }} />, {
settings: { hideErrors: false },
});
// Default fields exclude num_plans and num_success_30
expect(container.querySelectorAll(".service-block")).toHaveLength(4);
expect(screen.queryByText("backrest.num_plans")).toBeNull();
expect(screen.queryByText("backrest.num_success_30")).toBeNull();
expect(screen.getByText("1")).toBeInTheDocument();
expect(screen.getByText("2")).toBeInTheDocument();
expect(screen.getByText("4")).toBeInTheDocument();
expect(screen.getByText("500")).toBeInTheDocument();
});
});

View File

@@ -7,14 +7,14 @@ import widgets from "widgets/widgets";
const proxyName = "backrestProxyHandler";
const logger = createLogger(proxyName);
function sumField(plans, field) {
export function sumField(plans, field) {
return plans.reduce((sum, plan) => {
const num = Number(plan[field]);
return sum + (Number.isNaN(num) ? 0 : num);
}, 0);
}
function buildResponse(plans) {
export function buildResponse(plans) {
const numSuccess30Days = sumField(plans, "backupsSuccessLast30days");
const numFailure30Days = sumField(plans, "backupsFailed30days");
const bytesAdded30Days = sumField(plans, "bytesAddedLast30days");

View File

@@ -0,0 +1,212 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import createMockRes from "test-utils/create-mock-res";
const { httpProxy, getServiceWidget, logger } = vi.hoisted(() => ({
httpProxy: vi.fn(),
getServiceWidget: vi.fn(),
logger: {
debug: vi.fn(),
error: vi.fn(),
},
}));
vi.mock("utils/logger", () => ({
default: () => logger,
}));
vi.mock("utils/config/service-helpers", () => ({
default: getServiceWidget,
}));
vi.mock("utils/proxy/http", () => ({
httpProxy,
}));
vi.mock("widgets/widgets", () => ({
default: {
backrest: {
api: "{url}/v1.Backrest/{endpoint}",
},
},
}));
import backrestProxyHandler, { buildResponse } from "./proxy";
describe("backrest proxy buildResponse", () => {
it("aggregates plan metrics and latest status counts", () => {
const plans = [
{
backupsSuccessLast30days: 3,
backupsFailed30days: 1,
bytesAddedLast30days: 1000,
recentBackups: { status: ["STATUS_SUCCESS"] },
},
{
backupsSuccessLast30days: 2,
backupsFailed30days: 0,
bytesAddedLast30days: 500,
recentBackups: { status: ["STATUS_ERROR"] },
},
{
backupsSuccessLast30days: "not-a-number",
backupsFailed30days: 4,
bytesAddedLast30days: 250,
recentBackups: { status: [] },
},
];
expect(buildResponse(plans)).toEqual({
numPlans: 3,
numSuccess30Days: 5,
numFailure30Days: 5,
numSuccessLatest: 1,
numFailureLatest: 1,
bytesAdded30Days: 1750,
});
});
});
describe("widgets/backrest/proxy handler", () => {
beforeEach(() => {
httpProxy.mockReset();
getServiceWidget.mockReset();
vi.clearAllMocks();
});
it("returns 400 when the query is missing group or service", async () => {
const req = { query: { service: "svc" } };
const res = createMockRes();
await backrestProxyHandler(req, res);
expect(res.statusCode).toBe(400);
expect(res.body).toEqual({ error: "Invalid proxy service type" });
expect(getServiceWidget).not.toHaveBeenCalled();
});
it("returns 400 when the widget cannot be resolved", async () => {
getServiceWidget.mockResolvedValue(null);
const req = { query: { group: "g", service: "svc", index: "0", endpoint: "GetSummaryDashboard" } };
const res = createMockRes();
await backrestProxyHandler(req, res);
expect(res.statusCode).toBe(400);
expect(res.body).toEqual({ error: "Invalid proxy service type" });
});
it("calls the Backrest API with basic auth and returns the aggregated summary", async () => {
getServiceWidget.mockResolvedValue({
type: "backrest",
url: "http://backrest/",
username: "u",
password: "p",
});
httpProxy.mockResolvedValueOnce([
200,
"application/json",
Buffer.from(
JSON.stringify({
planSummaries: [
{
backupsSuccessLast30days: 1,
backupsFailed30days: 0,
bytesAddedLast30days: 10,
recentBackups: { status: [] },
},
{
backupsSuccessLast30days: 0,
backupsFailed30days: 1,
bytesAddedLast30days: 5,
recentBackups: { status: ["STATUS_ERROR"] },
},
],
}),
),
]);
const req = { query: { group: "g", service: "svc", index: "0", endpoint: "GetSummaryDashboard" } };
const res = createMockRes();
await backrestProxyHandler(req, res);
expect(httpProxy).toHaveBeenCalledTimes(1);
expect(httpProxy.mock.calls[0][0].toString()).toBe("http://backrest/v1.Backrest/GetSummaryDashboard");
expect(httpProxy.mock.calls[0][1]).toEqual(
expect.objectContaining({
method: "POST",
body: "{}",
headers: expect.objectContaining({
"content-type": "application/json",
Authorization: `Basic ${Buffer.from("u:p").toString("base64")}`,
}),
}),
);
expect(res.headers["Content-Type"]).toBe("application/json");
expect(res.statusCode).toBe(200);
expect(res.body).toEqual({
numPlans: 2,
numSuccess30Days: 1,
numFailure30Days: 1,
numSuccessLatest: 0,
numFailureLatest: 1,
bytesAdded30Days: 15,
});
});
it("returns 500 when Backrest responds non-200", async () => {
getServiceWidget.mockResolvedValue({ type: "backrest", url: "http://backrest" });
httpProxy.mockResolvedValueOnce([401, "application/json", Buffer.from("nope")]);
const req = { query: { group: "g", service: "svc", index: "0", endpoint: "GetSummaryDashboard" } };
const res = createMockRes();
await backrestProxyHandler(req, res);
expect(res.statusCode).toBe(500);
expect(res.body).toEqual(
expect.objectContaining({
error: expect.objectContaining({
message: "Error getting data from Backrest",
data: expect.any(Buffer),
}),
}),
);
});
it("returns 500 when the plans payload is invalid", async () => {
getServiceWidget.mockResolvedValue({ type: "backrest", url: "http://backrest" });
httpProxy.mockResolvedValueOnce([200, "application/json", Buffer.from(JSON.stringify({ planSummaries: {} }))]);
const req = { query: { group: "g", service: "svc", index: "0", endpoint: "GetSummaryDashboard" } };
const res = createMockRes();
await backrestProxyHandler(req, res);
expect(res.statusCode).toBe(500);
expect(res.body).toEqual(
expect.objectContaining({
error: expect.objectContaining({
message: "Invalid plans data",
}),
}),
);
});
it("returns 500 when httpProxy throws", async () => {
getServiceWidget.mockResolvedValue({ type: "backrest", url: "http://backrest" });
httpProxy.mockRejectedValueOnce(new Error("boom"));
const req = { query: { group: "g", service: "svc", index: "0", endpoint: "GetSummaryDashboard" } };
const res = createMockRes();
await backrestProxyHandler(req, res);
expect(res.statusCode).toBe(500);
expect(res.body).toEqual(expect.objectContaining({ error: "Backrest API Error", message: "boom" }));
});
});

View File

@@ -0,0 +1,13 @@
import { describe, expect, it } from "vitest";
import { expectWidgetConfigShape } from "test-utils/widget-config";
import widget from "./widget";
describe("backrest widget config", () => {
it("exports a valid widget config", () => {
expectWidgetConfigShape(widget);
expect(widget.api).toContain("/v1.Backrest/");
expect(widget.mappings?.summary?.endpoint).toBe("GetSummaryDashboard");
});
});

View File

@@ -0,0 +1,56 @@
// @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/bazarr/component", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders placeholders while loading", () => {
useWidgetAPI.mockImplementation(() => ({ data: undefined, error: undefined }));
const { container } = renderWithProviders(<Component service={{ widget: { type: "bazarr" } }} />, {
settings: { hideErrors: false },
});
expect(container.querySelectorAll(".service-block")).toHaveLength(2);
expect(screen.getByText("bazarr.missingEpisodes")).toBeInTheDocument();
expect(screen.getByText("bazarr.missingMovies")).toBeInTheDocument();
});
it("renders error UI when either endpoint errors", () => {
useWidgetAPI
.mockReturnValueOnce({ data: undefined, error: { message: "episodes bad" } })
.mockReturnValueOnce({ data: undefined, error: undefined });
renderWithProviders(<Component service={{ widget: { type: "bazarr" } }} />, { settings: { hideErrors: false } });
expect(screen.getAllByText(/widget\.api_error/i).length).toBeGreaterThan(0);
});
it("renders counts when loaded", () => {
useWidgetAPI
.mockReturnValueOnce({ data: { total: 11 }, error: undefined })
.mockReturnValueOnce({ data: { total: 22 }, error: undefined });
const { container } = renderWithProviders(<Component service={{ widget: { type: "bazarr" } }} />, {
settings: { hideErrors: false },
});
expect(container.querySelectorAll(".service-block")).toHaveLength(2);
expect(screen.getByText("11")).toBeInTheDocument();
expect(screen.getByText("22")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { expectWidgetConfigShape } from "test-utils/widget-config";
import widget from "./widget";
describe("bazarr widget config", () => {
it("exports a valid widget config", () => {
expectWidgetConfigShape(widget);
expect(widget.api).toContain("apikey={key}");
const moviesMapping = widget.mappings?.movies;
expect(moviesMapping?.endpoint).toBe("movies");
expect(moviesMapping?.map?.('{"total":123}')).toEqual({ total: 123 });
});
});

View File

@@ -0,0 +1,97 @@
// @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/beszel/component", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders placeholders while loading (systems view)", () => {
useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });
const service = { widget: { type: "beszel" } };
const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });
expect(service.widget.fields).toEqual(["systems", "up"]);
expect(container.querySelectorAll(".service-block")).toHaveLength(2);
expect(screen.getByText("beszel.systems")).toBeInTheDocument();
expect(screen.getByText("beszel.up")).toBeInTheDocument();
});
it("renders system totals when loaded (systems view)", () => {
useWidgetAPI.mockReturnValue({
data: {
totalItems: 3,
items: [{ status: "up" }, { status: "down" }, { status: "up" }],
},
error: undefined,
});
const { container } = renderWithProviders(<Component service={{ widget: { type: "beszel" } }} />, {
settings: { hideErrors: false },
});
expectBlockValue(container, "beszel.systems", 3);
expectBlockValue(container, "beszel.up", "2 / 3");
});
it("renders selected system details and filters to 4 default fields", () => {
useWidgetAPI.mockReturnValue({
data: {
totalItems: 1,
items: [
{
id: "sys1",
name: "MySystem",
status: "up",
updated: 123,
info: { cpu: 10, mp: 20, dp: 30, b: 40 },
},
],
},
error: undefined,
});
const service = { widget: { type: "beszel", systemId: "sys1" } };
const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });
expect(service.widget.fields).toEqual(["name", "status", "cpu", "memory"]);
expect(container.querySelectorAll(".service-block")).toHaveLength(4);
expectBlockValue(container, "beszel.name", "MySystem");
expectBlockValue(container, "beszel.status", "beszel.up");
expectBlockValue(container, "beszel.cpu", 10);
expectBlockValue(container, "beszel.memory", 20);
expect(screen.queryByText("beszel.updated")).toBeNull();
});
it("renders error when systemId is not found", () => {
useWidgetAPI.mockReturnValue({
data: { totalItems: 1, items: [{ id: "sys1", name: "MySystem", status: "up", info: {} }] },
error: undefined,
});
renderWithProviders(<Component service={{ widget: { type: "beszel", systemId: "missing" } }} />, {
settings: { hideErrors: false },
});
expect(screen.getByText("System with id missing not found")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,117 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import createMockRes from "test-utils/create-mock-res";
const { httpProxy, getServiceWidget, cache, logger } = vi.hoisted(() => {
const store = new Map();
return {
httpProxy: vi.fn(),
getServiceWidget: vi.fn(),
cache: {
get: vi.fn((k) => store.get(k)),
put: vi.fn((k, v) => store.set(k, v)),
del: vi.fn((k) => store.delete(k)),
_reset: () => store.clear(),
},
logger: {
debug: vi.fn(),
error: vi.fn(),
},
};
});
vi.mock("utils/logger", () => ({
default: () => logger,
}));
vi.mock("utils/config/service-helpers", () => ({
default: getServiceWidget,
}));
vi.mock("utils/proxy/http", () => ({
httpProxy,
}));
vi.mock("memory-cache", () => ({
default: cache,
...cache,
}));
vi.mock("widgets/widgets", () => ({
default: {
beszel: {
api: "{url}/{endpoint}",
mappings: {
authv1: { endpoint: "api/auth" },
authv2: { endpoint: "api/auth/v2" },
},
},
},
}));
import beszelProxyHandler from "./proxy";
describe("widgets/beszel/proxy", () => {
beforeEach(() => {
vi.clearAllMocks();
cache._reset();
});
it("logs in when token is missing and uses Bearer token for requests", async () => {
getServiceWidget.mockResolvedValue({
type: "beszel",
url: "http://beszel",
username: "u",
password: "p",
});
httpProxy
.mockResolvedValueOnce([200, "application/json", Buffer.from(JSON.stringify({ token: "t1" }))])
.mockResolvedValueOnce([200, "application/json", Buffer.from(JSON.stringify({ items: [1] }))]);
const req = { query: { group: "g", service: "svc", endpoint: "items", index: "0" } };
const res = createMockRes();
await beszelProxyHandler(req, res);
expect(httpProxy).toHaveBeenCalledTimes(2);
expect(httpProxy.mock.calls[0][0]).toBe("http://beszel/api/auth");
expect(httpProxy.mock.calls[1][0].toString()).toBe("http://beszel/items");
expect(httpProxy.mock.calls[1][1]).toEqual({
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer t1",
},
});
expect(res.send).toHaveBeenCalledWith(Buffer.from(JSON.stringify({ items: [1] })));
});
it("retries after receiving an empty list by clearing cache and logging in again", async () => {
cache.put("beszelProxyHandler__token.svc", "old");
getServiceWidget.mockResolvedValue({
type: "beszel",
url: "http://beszel",
username: "u",
password: "p",
});
httpProxy
.mockResolvedValueOnce([200, "application/json", Buffer.from(JSON.stringify({ items: [] }))])
.mockResolvedValueOnce([200, "application/json", Buffer.from(JSON.stringify({ token: "new" }))])
.mockResolvedValueOnce([200, "application/json", Buffer.from(JSON.stringify({ items: [1] }))]);
const req = { query: { group: "g", service: "svc", endpoint: "items", index: "0" } };
const res = createMockRes();
await beszelProxyHandler(req, res);
expect(httpProxy).toHaveBeenCalledTimes(3);
expect(httpProxy.mock.calls[0][1].headers.Authorization).toBe("Bearer old");
expect(httpProxy.mock.calls[1][0]).toBe("http://beszel/api/auth");
expect(httpProxy.mock.calls[2][1].headers.Authorization).toBe("Bearer new");
expect(res.send).toHaveBeenCalledWith(Buffer.from(JSON.stringify({ items: [1] })));
});
});

View File

@@ -0,0 +1,11 @@
import { describe, it } from "vitest";
import { expectWidgetConfigShape } from "test-utils/widget-config";
import widget from "./widget";
describe("beszel widget config", () => {
it("exports a valid widget config", () => {
expectWidgetConfigShape(widget);
});
});

View File

@@ -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 { useWidgetAPI } = vi.hoisted(() => ({
useWidgetAPI: vi.fn(),
}));
vi.mock("utils/proxy/use-widget-api", () => ({
default: useWidgetAPI,
}));
import Component from "./component";
describe("widgets/booklore/component", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders placeholders while loading", () => {
useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });
const { container } = renderWithProviders(<Component service={{ widget: { type: "booklore" } }} />, {
settings: { hideErrors: false },
});
expect(container.querySelectorAll(".service-block")).toHaveLength(4);
expect(screen.getByText("booklore.libraries")).toBeInTheDocument();
expect(screen.getByText("booklore.books")).toBeInTheDocument();
expect(screen.getByText("booklore.reading")).toBeInTheDocument();
expect(screen.getByText("booklore.finished")).toBeInTheDocument();
});
it("renders values with nullish fallback defaults", () => {
useWidgetAPI.mockReturnValue({
data: { libraries: 1, books: 2, finished: 4 }, // reading missing -> 0
error: undefined,
});
renderWithProviders(<Component service={{ widget: { type: "booklore" } }} />, { settings: { hideErrors: false } });
expect(screen.getByText("1")).toBeInTheDocument();
expect(screen.getByText("2")).toBeInTheDocument();
expect(screen.getByText("0")).toBeInTheDocument();
expect(screen.getByText("4")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,98 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import createMockRes from "test-utils/create-mock-res";
const { httpProxy, getServiceWidget, cache, logger } = vi.hoisted(() => {
const store = new Map();
return {
httpProxy: vi.fn(),
getServiceWidget: vi.fn(),
cache: {
get: vi.fn((k) => store.get(k)),
put: vi.fn((k, v) => store.set(k, v)),
del: vi.fn((k) => store.delete(k)),
_reset: () => store.clear(),
},
logger: {
debug: vi.fn(),
error: vi.fn(),
},
};
});
vi.mock("utils/logger", () => ({
default: () => logger,
}));
vi.mock("utils/config/service-helpers", () => ({
default: getServiceWidget,
}));
vi.mock("utils/proxy/http", () => ({
httpProxy,
}));
vi.mock("memory-cache", () => ({
default: cache,
...cache,
}));
vi.mock("widgets/widgets", () => ({
default: {
booklore: {
api: "{url}/{endpoint}",
},
},
}));
import bookloreProxyHandler from "./proxy";
describe("widgets/booklore/proxy", () => {
beforeEach(() => {
vi.clearAllMocks();
cache._reset();
});
it("returns 400 when Booklore credentials are missing", async () => {
getServiceWidget.mockResolvedValue({ type: "booklore", url: "http://booklore" });
const req = { query: { group: "g", service: "svc", index: "0" } };
const res = createMockRes();
await bookloreProxyHandler(req, res);
expect(res.statusCode).toBe(400);
expect(res.body).toEqual({ error: "Missing Booklore credentials" });
});
it("logs in and summarizes libraries and book statuses", async () => {
getServiceWidget.mockResolvedValue({
type: "booklore",
url: "http://booklore",
username: "u",
password: "p",
});
const books = [{ readStatus: "reading" }, { readStatus: "read" }, { readStatus: "READ" }, { readStatus: "other" }];
httpProxy
.mockResolvedValueOnce([200, "application/json", Buffer.from(JSON.stringify({ accessToken: "tok" }))])
.mockResolvedValueOnce([200, "application/json", Buffer.from(JSON.stringify([{ id: 1 }, { id: 2 }]))])
.mockResolvedValueOnce([200, "application/json", Buffer.from(JSON.stringify(books))]);
const req = { query: { group: "g", service: "svc", index: "0" } };
const res = createMockRes();
await bookloreProxyHandler(req, res);
expect(httpProxy).toHaveBeenCalledTimes(3);
expect(res.statusCode).toBe(200);
expect(res.body).toEqual({
libraries: 2,
books: 4,
reading: 1,
finished: 2,
});
});
});

View File

@@ -0,0 +1,11 @@
import { describe, it } from "vitest";
import { expectWidgetConfigShape } from "test-utils/widget-config";
import widget from "./widget";
describe("booklore widget config", () => {
it("exports a valid widget config", () => {
expectWidgetConfigShape(widget);
});
});

View File

@@ -0,0 +1,64 @@
// @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/caddy/component", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders placeholders while loading", () => {
useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });
const { container } = renderWithProviders(<Component service={{ widget: { type: "caddy" } }} />, {
settings: { hideErrors: false },
});
expect(container.querySelectorAll(".service-block")).toHaveLength(3);
expect(screen.getByText("caddy.upstreams")).toBeInTheDocument();
expect(screen.getByText("caddy.requests")).toBeInTheDocument();
expect(screen.getByText("caddy.requests_failed")).toBeInTheDocument();
});
it("renders error UI when widget API errors", () => {
useWidgetAPI.mockReturnValue({ data: undefined, error: { message: "nope" } });
renderWithProviders(<Component service={{ widget: { type: "caddy" } }} />, { settings: { hideErrors: false } });
expect(screen.getByText("nope")).toBeInTheDocument();
});
it("computes upstream/request totals when loaded", () => {
useWidgetAPI.mockReturnValue({
data: [
{ num_requests: 10, fails: 1 },
{ num_requests: 5, fails: 2 },
],
error: undefined,
});
const { container } = renderWithProviders(<Component service={{ widget: { type: "caddy" } }} />, {
settings: { hideErrors: false },
});
expectBlockValue(container, "caddy.upstreams", 2);
expectBlockValue(container, "caddy.requests", 15);
expectBlockValue(container, "caddy.requests_failed", 3);
});
});

View File

@@ -0,0 +1,11 @@
import { describe, it } from "vitest";
import { expectWidgetConfigShape } from "test-utils/widget-config";
import widget from "./widget";
describe("caddy widget config", () => {
it("exports a valid widget config", () => {
expectWidgetConfigShape(widget);
});
});

View File

@@ -0,0 +1,64 @@
// @vitest-environment jsdom
import { render, screen } from "@testing-library/react";
import { DateTime } from "luxon";
import { describe, expect, it, vi } from "vitest";
const { EventStub, compareDateTimezoneStub } = vi.hoisted(() => ({
EventStub: vi.fn(({ event, showDate, showTime }) => (
<div data-testid="event" data-showdate={showDate ? "1" : "0"} data-showtime={showTime ? "1" : "0"}>
{event.title}
</div>
)),
compareDateTimezoneStub: vi.fn(
(date, event) => date.startOf("day").toISODate() === event.date.startOf("day").toISODate(),
),
}));
vi.mock("./event", () => ({
default: EventStub,
compareDateTimezone: compareDateTimezoneStub,
}));
import Agenda from "./agenda";
describe("widgets/calendar/agenda", () => {
it("renders an empty placeholder when showDate is not set", () => {
const { container } = render(<Agenda service={{ widget: {} }} colorVariants={{}} events={{}} showDate={null} />);
expect(container.textContent).toBe("");
});
it("renders a no-events placeholder when there are no events in range", () => {
render(<Agenda service={{ widget: {} }} colorVariants={{}} events={{}} showDate={DateTime.now()} />);
expect(screen.getByText("calendar.noEventsToday")).toBeInTheDocument();
expect(EventStub).toHaveBeenCalled();
});
it("filters by previousDays, sorts, and enforces maxEvents", () => {
const showDate = DateTime.local(2099, 1, 2).startOf("day");
const service = { widget: { previousDays: 0, maxEvents: 2, showTime: true } };
const events = {
old: { title: "Old", date: DateTime.local(2099, 1, 1, 0, 0), color: "gray" },
a: { title: "A", date: DateTime.local(2099, 1, 2, 10, 0), color: "gray" },
b: { title: "B", date: DateTime.local(2099, 1, 3, 10, 0), color: "gray" },
c: { title: "C", date: DateTime.local(2099, 1, 4, 10, 0), color: "gray" },
};
render(<Agenda service={service} colorVariants={{}} events={events} showDate={showDate} />);
// Old is filtered out, C is sliced out by maxEvents.
expect(screen.queryByText("Old")).toBeNull();
expect(screen.getByText("A")).toBeInTheDocument();
expect(screen.getByText("B")).toBeInTheDocument();
expect(screen.queryByText("C")).toBeNull();
const renderedEvents = screen.getAllByTestId("event");
expect(renderedEvents).toHaveLength(2);
// showTime is only true for the selected day.
const [first, second] = renderedEvents;
expect(first).toHaveAttribute("data-showtime", "1");
expect(second).toHaveAttribute("data-showtime", "0");
});
});

View File

@@ -72,7 +72,8 @@ export default function Component({ service }) {
widget.integrations
?.filter((integration) => integration?.type)
.map((integration) => ({
service: dynamic(() => import(`./integrations/${integration.type}`)),
// Include the extension so Vite/Vitest can statically validate the import base.
service: dynamic(() => import(`./integrations/${integration.type}.jsx`)),
widget: { ...widget, ...integration },
})) ?? [],
[widget],

View File

@@ -0,0 +1,89 @@
// @vitest-environment jsdom
import { screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils/render-with-providers";
vi.mock("next/dynamic", () => ({
default: () => (props) => (
<div
data-testid="calendar-integration"
data-type={props.config.type}
data-start={props.params.start}
data-end={props.params.end}
data-timezone={props.timezone || ""}
/>
),
}));
vi.mock("./monthly", () => ({
default: ({ showDate }) => <div data-testid="calendar-monthly" data-show={showDate?.toISODate?.() || ""} />,
}));
vi.mock("./agenda", () => ({
default: ({ showDate }) => <div data-testid="calendar-agenda" data-show={showDate?.toISODate?.() || ""} />,
}));
import Component from "./component";
describe("widgets/calendar/component", () => {
it("renders monthly view by default", async () => {
renderWithProviders(<Component service={{ widget: { type: "calendar", integrations: [] } }} />, {
settings: { hideErrors: false },
});
expect(screen.getByTestId("calendar-monthly")).toBeInTheDocument();
expect(screen.queryByTestId("calendar-agenda")).toBeNull();
// showDate is set asynchronously in an effect; ensure it eventually resolves to a date string.
await waitFor(() => {
expect(screen.getByTestId("calendar-monthly").getAttribute("data-show")).not.toBe("");
});
});
it("renders agenda view when configured", async () => {
renderWithProviders(<Component service={{ widget: { type: "calendar", view: "agenda", integrations: [] } }} />, {
settings: { hideErrors: false },
});
expect(screen.getByTestId("calendar-agenda")).toBeInTheDocument();
expect(screen.queryByTestId("calendar-monthly")).toBeNull();
await waitFor(() => {
expect(screen.getByTestId("calendar-agenda").getAttribute("data-show")).not.toBe("");
});
});
it("loads configured integrations and passes calculated params", async () => {
renderWithProviders(
<Component
service={{
widget: {
type: "calendar",
timezone: "UTC",
integrations: [
{
type: "sonarr",
name: "Sonarr",
service_group: "Media",
service_name: "Sonarr",
},
],
},
}}
/>,
{ settings: { hideErrors: false } },
);
const integration = screen.getByTestId("calendar-integration");
expect(integration.getAttribute("data-type")).toBe("sonarr");
expect(integration.getAttribute("data-timezone")).toBe("UTC");
await waitFor(() => {
// start/end should be yyyy-MM-dd after showDate is set.
expect(integration.getAttribute("data-start")).toMatch(/^\d{4}-\d{2}-\d{2}$/);
expect(integration.getAttribute("data-end")).toMatch(/^\d{4}-\d{2}-\d{2}$/);
});
});
});

View File

@@ -0,0 +1,56 @@
// @vitest-environment jsdom
import { fireEvent, render, screen } from "@testing-library/react";
import { DateTime } from "luxon";
import { describe, expect, it } from "vitest";
import Event, { compareDateTimezone } from "./event";
describe("widgets/calendar/event", () => {
it("renders an anchor when a url is provided and toggles additional text on hover", () => {
const date = DateTime.fromISO("2099-01-01T13:00:00.000Z").setZone("utc");
render(
<Event
event={{
title: "Primary",
additional: "More info",
date,
color: "gray",
url: "https://example.com",
isCompleted: true,
}}
colorVariants={{ gray: "bg-gray-500" }}
showDate
showTime
/>,
);
const link = screen.getByRole("link", { name: /primary/i });
expect(link).toHaveAttribute("href", "https://example.com");
expect(link).toHaveAttribute("target", "_blank");
expect(link).toHaveAttribute("rel", "noopener noreferrer");
// time is rendered when showTime=true
expect(link.textContent).toContain("13:00");
// default shows title, hover shows `additional`
expect(screen.getByText("Primary")).toBeInTheDocument();
expect(screen.queryByText("More info")).toBeNull();
fireEvent.mouseEnter(link);
expect(screen.getByText("More info")).toBeInTheDocument();
fireEvent.mouseLeave(link);
expect(screen.getByText("Primary")).toBeInTheDocument();
// completed icon from react-icons renders an SVG
expect(link.querySelector("svg")).toBeTruthy();
});
it("compareDateTimezone matches dates by day", () => {
const day = DateTime.fromISO("2099-01-01T00:00:00.000Z").setZone("utc");
expect(compareDateTimezone(day, { date: DateTime.fromISO("2099-01-01T23:59:00.000Z").setZone("utc") })).toBe(true);
expect(compareDateTimezone(day, { date: DateTime.fromISO("2099-01-02T00:00:00.000Z").setZone("utc") })).toBe(false);
});
});

View File

@@ -0,0 +1,64 @@
// @vitest-environment jsdom
import { render, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
const { useWidgetAPI } = vi.hoisted(() => ({
useWidgetAPI: vi.fn(),
}));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Integration from "./ical";
describe("widgets/calendar/integrations/ical", () => {
it("adds parsed events within the date range", async () => {
useWidgetAPI.mockReturnValue({
data: {
data: [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//Test//EN",
"BEGIN:VEVENT",
"UID:uid1",
"DTSTAMP:20990101T000000Z",
"DTSTART:20990101T130000Z",
"DTEND:20990101T140000Z",
"SUMMARY:Test Event",
"LOCATION:Office",
"URL:https://example.com",
"END:VEVENT",
"END:VCALENDAR",
"",
].join("\n"),
},
error: undefined,
});
const setEvents = vi.fn();
render(
<Integration
config={{ name: "Work", type: "ical", color: "blue", params: { showName: true } }}
params={{ start: "2099-01-01T00:00:00.000Z", end: "2099-01-02T00:00:00.000Z" }}
setEvents={setEvents}
hideErrors
timezone="utc"
/>,
);
await waitFor(() => expect(setEvents).toHaveBeenCalled());
const updater = setEvents.mock.calls[0][0];
const next = updater({});
const entries = Object.values(next);
expect(entries).toHaveLength(1);
const [event] = entries;
expect(event.title).toBe("Work: Test Event");
expect(event.color).toBe("blue");
expect(event.type).toBe("ical");
expect(event.additional).toBe("Office");
expect(event.url).toBe("https://example.com");
expect(event.isCompleted).toBe(false);
});
});

View File

@@ -0,0 +1,39 @@
// @vitest-environment jsdom
import { render, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
const { useWidgetAPI } = vi.hoisted(() => ({
useWidgetAPI: vi.fn(),
}));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Integration from "./lidarr";
describe("widgets/calendar/integrations/lidarr", () => {
it("adds release events", async () => {
useWidgetAPI.mockReturnValue({
data: [
{ artist: { artistName: "Artist" }, title: "Album", releaseDate: "2099-01-01T00:00:00.000Z", grabbed: true },
],
error: undefined,
});
const setEvents = vi.fn();
render(
<Integration
config={{ type: "lidarr", color: "green" }}
params={{ start: "2099-01-01T00:00:00.000Z", end: "2099-01-02T00:00:00.000Z" }}
setEvents={setEvents}
hideErrors
/>,
);
await waitFor(() => expect(setEvents).toHaveBeenCalled());
const next = setEvents.mock.calls[0][0]({});
expect(Object.keys(next)).toEqual(["Artist - Album"]);
expect(next["Artist - Album"].isCompleted).toBe(true);
});
});

View File

@@ -0,0 +1,49 @@
// @vitest-environment jsdom
import { render, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
const { useWidgetAPI } = vi.hoisted(() => ({
useWidgetAPI: vi.fn(),
}));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Integration from "./radarr";
describe("widgets/calendar/integrations/radarr", () => {
it("adds cinema/physical/digital events", async () => {
useWidgetAPI.mockReturnValue({
data: [
{
title: "Movie",
titleSlug: "movie",
hasFile: false,
inCinemas: "2099-01-01T00:00:00.000Z",
physicalRelease: "2099-01-02T00:00:00.000Z",
digitalRelease: "2099-01-03T00:00:00.000Z",
},
],
error: undefined,
});
const setEvents = vi.fn();
render(
<Integration
config={{ type: "radarr", baseUrl: "https://radarr.example", color: "amber" }}
params={{ start: "2099-01-01T00:00:00.000Z", end: "2099-01-10T00:00:00.000Z" }}
setEvents={setEvents}
hideErrors
/>,
);
await waitFor(() => expect(setEvents).toHaveBeenCalled());
const next = setEvents.mock.calls[0][0]({});
const keys = Object.keys(next);
expect(keys.some((k) => k.includes("calendar.inCinemas"))).toBe(true);
expect(keys.some((k) => k.includes("calendar.physicalRelease"))).toBe(true);
expect(keys.some((k) => k.includes("calendar.digitalRelease"))).toBe(true);
expect(Object.values(next)[0].url).toBe("https://radarr.example/movie/movie");
});
});

View File

@@ -0,0 +1,48 @@
// @vitest-environment jsdom
import { render, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
const { useWidgetAPI } = vi.hoisted(() => ({
useWidgetAPI: vi.fn(),
}));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Integration from "./readarr";
describe("widgets/calendar/integrations/readarr", () => {
it("adds release events with author name", async () => {
useWidgetAPI.mockReturnValue({
data: [
{
title: "Book",
seriesTitle: "Series",
releaseDate: "2099-01-01T00:00:00.000Z",
grabbed: false,
author: { authorName: "Author" },
authorTitle: "Author Book",
},
],
error: undefined,
});
const setEvents = vi.fn();
render(
<Integration
config={{ type: "readarr", color: "rose" }}
params={{ start: "2099-01-01T00:00:00.000Z", end: "2099-01-02T00:00:00.000Z" }}
setEvents={setEvents}
hideErrors
/>,
);
await waitFor(() => expect(setEvents).toHaveBeenCalled());
const next = setEvents.mock.calls[0][0]({});
const [key] = Object.keys(next);
expect(key).toContain("Author");
expect(key).toContain("Book");
expect(key).toContain("(Series)");
});
});

View File

@@ -0,0 +1,48 @@
// @vitest-environment jsdom
import { render, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
const { useWidgetAPI } = vi.hoisted(() => ({
useWidgetAPI: vi.fn(),
}));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
import Integration from "./sonarr";
describe("widgets/calendar/integrations/sonarr", () => {
it("adds episode events", async () => {
useWidgetAPI.mockReturnValue({
data: [
{
series: { title: "Show", titleSlug: "show" },
seasonNumber: 1,
episodeNumber: 2,
airDateUtc: "2099-01-01T00:00:00.000Z",
hasFile: true,
},
],
error: undefined,
});
const setEvents = vi.fn();
render(
<Integration
config={{ type: "sonarr", baseUrl: "https://sonarr.example", color: "teal" }}
params={{ start: "2099-01-01T00:00:00.000Z", end: "2099-01-02T00:00:00.000Z" }}
setEvents={setEvents}
hideErrors
/>,
);
await waitFor(() => expect(setEvents).toHaveBeenCalled());
const next = setEvents.mock.calls[0][0]({});
const [entry] = Object.values(next);
expect(entry.title).toBe("Show");
expect(entry.additional).toBe("S1 E2");
expect(entry.url).toBe("https://sonarr.example/series/show");
expect(entry.isCompleted).toBe(true);
});
});

View File

@@ -0,0 +1,71 @@
// @vitest-environment jsdom
import { fireEvent, render, screen } from "@testing-library/react";
import { DateTime } from "luxon";
import { describe, expect, it, vi } from "vitest";
const { EventStub, compareDateTimezoneStub } = vi.hoisted(() => ({
EventStub: vi.fn(({ event }) => <div data-testid="event">{event.title}</div>),
compareDateTimezoneStub: vi.fn(
(date, event) => date.startOf("day").toISODate() === event.date.startOf("day").toISODate(),
),
}));
vi.mock("./event", () => ({
default: EventStub,
compareDateTimezone: compareDateTimezoneStub,
}));
import Monthly from "./monthly";
describe("widgets/calendar/monthly", () => {
it("renders an empty placeholder when showDate is not set", () => {
const { container } = render(
<Monthly
service={{ widget: {} }}
colorVariants={{}}
events={{}}
showDate={null}
setShowDate={() => {}}
currentDate={DateTime.now()}
/>,
);
expect(container.textContent).toBe("");
});
it("navigates months and renders day events", () => {
const setShowDate = vi.fn();
const showDate = DateTime.local(2099, 2, 15).startOf("day");
const currentDate = DateTime.local(2099, 2, 4).startOf("day");
const service = { widget: { maxEvents: 10, showTime: false } };
const events = {
e1: { title: "Today Event", date: DateTime.local(2099, 2, 15, 10, 0), color: "zinc" },
e2: { title: "Other Event", date: DateTime.local(2099, 2, 16, 10, 0), color: "zinc" },
};
render(
<Monthly
service={service}
colorVariants={{}}
events={events}
showDate={showDate}
setShowDate={setShowDate}
currentDate={currentDate}
/>,
);
expect(screen.getByText("Today Event")).toBeInTheDocument();
expect(screen.queryByText("Other Event")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: ">" }));
expect(setShowDate).toHaveBeenCalled();
expect(setShowDate.mock.calls[0][0].toISODate()).toBe(showDate.plus({ months: 1 }).startOf("day").toISODate());
fireEvent.click(screen.getByRole("button", { name: "<" }));
expect(setShowDate.mock.calls[1][0].toISODate()).toBe(showDate.minus({ months: 1 }).startOf("day").toISODate());
fireEvent.click(screen.getByRole("button", { name: showDate.toFormat("MMMM y") }));
expect(setShowDate.mock.calls[2][0].toISODate()).toBe(currentDate.startOf("day").toISODate());
});
});

View File

@@ -0,0 +1,95 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import createMockRes from "test-utils/create-mock-res";
const { httpProxy, getServiceWidget, logger } = vi.hoisted(() => ({
httpProxy: vi.fn(),
getServiceWidget: vi.fn(),
logger: {
debug: vi.fn(),
},
}));
vi.mock("utils/logger", () => ({
default: () => logger,
}));
vi.mock("utils/config/service-helpers", () => ({
default: getServiceWidget,
}));
vi.mock("utils/proxy/http", () => ({
httpProxy,
}));
import calendarProxyHandler from "./proxy";
describe("widgets/calendar/proxy", () => {
const envVersion = process.env.NEXT_PUBLIC_VERSION;
beforeEach(() => {
vi.clearAllMocks();
process.env.NEXT_PUBLIC_VERSION = envVersion;
});
it("returns 400 when integration is missing", async () => {
getServiceWidget.mockResolvedValue({ integrations: [] });
const req = { query: { group: "g", service: "svc", endpoint: "foo", index: "0" } };
const res = createMockRes();
await calendarProxyHandler(req, res);
expect(res.statusCode).toBe(400);
expect(res.body).toEqual({ error: "Invalid integration" });
});
it("returns 403 when integration has no URL", async () => {
getServiceWidget.mockResolvedValue({ integrations: [{ name: "foo", url: "" }] });
const req = { query: { group: "g", service: "svc", endpoint: "foo", index: "0" } };
const res = createMockRes();
await calendarProxyHandler(req, res);
expect(res.statusCode).toBe(403);
expect(res.body).toEqual({ error: "No integration URL specified" });
});
it("adds a User-Agent for Outlook integrations and returns string data", async () => {
process.env.NEXT_PUBLIC_VERSION = "1.2.3";
getServiceWidget.mockResolvedValue({
integrations: [{ name: "outlook", url: "https://example.com/outlook.ics" }],
});
httpProxy.mockResolvedValueOnce([200, "text/calendar", Buffer.from("CAL")]);
const req = { query: { group: "g", service: "svc", endpoint: "outlook", index: "0" } };
const res = createMockRes();
await calendarProxyHandler(req, res);
expect(httpProxy).toHaveBeenCalledWith("https://example.com/outlook.ics", {
headers: { "User-Agent": "gethomepage/1.2.3" },
});
expect(res.setHeader).toHaveBeenCalledWith("Content-Type", "text/calendar");
expect(res.statusCode).toBe(200);
expect(res.body).toEqual({ data: "CAL" });
});
it("passes through non-200 status codes from integrations", async () => {
getServiceWidget.mockResolvedValue({
integrations: [{ name: "foo", url: "https://example.com/foo.ics" }],
});
httpProxy.mockResolvedValueOnce([503, "text/plain", Buffer.from("nope")]);
const req = { query: { group: "g", service: "svc", endpoint: "foo", index: "0" } };
const res = createMockRes();
await calendarProxyHandler(req, res);
expect(res.statusCode).toBe(503);
expect(res.body).toEqual(Buffer.from("nope"));
});
});

View File

@@ -0,0 +1,12 @@
import { describe, expect, it } from "vitest";
import { expectWidgetConfigShape } from "test-utils/widget-config";
import widget from "./widget";
describe("calendar widget config", () => {
it("exports a valid widget config", () => {
expectWidgetConfigShape(widget);
expect(widget.api).toBe("{url}");
});
});

View File

@@ -0,0 +1,48 @@
// @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/calibreweb/component", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders placeholders while loading", () => {
useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });
const { container } = renderWithProviders(<Component service={{ widget: { type: "calibreweb" } }} />, {
settings: { hideErrors: false },
});
expect(container.querySelectorAll(".service-block")).toHaveLength(4);
expect(screen.getByText("calibreweb.books")).toBeInTheDocument();
expect(screen.getByText("calibreweb.authors")).toBeInTheDocument();
expect(screen.getByText("calibreweb.categories")).toBeInTheDocument();
expect(screen.getByText("calibreweb.series")).toBeInTheDocument();
});
it("renders values when loaded", () => {
useWidgetAPI.mockReturnValue({
data: { books: 1, authors: 2, categories: 3, series: 4 },
error: undefined,
});
renderWithProviders(<Component service={{ widget: { type: "calibreweb" } }} />, {
settings: { hideErrors: false },
});
expect(screen.getByText("1")).toBeInTheDocument();
expect(screen.getByText("2")).toBeInTheDocument();
expect(screen.getByText("3")).toBeInTheDocument();
expect(screen.getByText("4")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,11 @@
import { describe, it } from "vitest";
import { expectWidgetConfigShape } from "test-utils/widget-config";
import widget from "./widget";
describe("calibreweb widget config", () => {
it("exports a valid widget config", () => {
expectWidgetConfigShape(widget);
});
});

View File

@@ -0,0 +1,56 @@
// @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/changedetectionio/component", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders placeholders while loading", () => {
useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });
const { container } = renderWithProviders(<Component service={{ widget: { type: "changedetectionio" } }} />, {
settings: { hideErrors: false },
});
expect(container.querySelectorAll(".service-block")).toHaveLength(2);
expect(screen.getByText("changedetectionio.diffsDetected")).toBeInTheDocument();
expect(screen.getByText("changedetectionio.totalObserved")).toBeInTheDocument();
});
it("computes diffs detected (last_changed > 0 and not viewed)", () => {
useWidgetAPI.mockReturnValue({
data: {
a: { last_changed: 1, viewed: false },
b: { last_changed: 0, viewed: false },
c: { last_changed: 2, viewed: true },
d: { last_changed: 3, viewed: false },
},
error: undefined,
});
const { container } = renderWithProviders(<Component service={{ widget: { type: "changedetectionio" } }} />, {
settings: { hideErrors: false },
});
expectBlockValue(container, "changedetectionio.totalObserved", 4);
expectBlockValue(container, "changedetectionio.diffsDetected", 2);
});
});

View File

@@ -0,0 +1,11 @@
import { describe, it } from "vitest";
import { expectWidgetConfigShape } from "test-utils/widget-config";
import widget from "./widget";
describe("changedetectionio widget config", () => {
it("exports a valid widget config", () => {
expectWidgetConfigShape(widget);
});
});

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";
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/channelsdvrserver/component", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders placeholders while loading", () => {
useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });
const { container } = renderWithProviders(<Component service={{ widget: { type: "channelsdvrserver" } }} />, {
settings: { hideErrors: false },
});
expect(container.querySelectorAll(".service-block")).toHaveLength(4);
expect(screen.getByText("channelsdvrserver.shows")).toBeInTheDocument();
expect(screen.getByText("channelsdvrserver.recordings")).toBeInTheDocument();
expect(screen.getByText("channelsdvrserver.scheduled")).toBeInTheDocument();
expect(screen.getByText("channelsdvrserver.passes")).toBeInTheDocument();
});
it("renders values when loaded", () => {
useWidgetAPI.mockReturnValue({
data: { stats: { groups: 1, files: 2, jobs: 3, rules: 4 } },
error: undefined,
});
const { container } = renderWithProviders(<Component service={{ widget: { type: "channelsdvrserver" } }} />, {
settings: { hideErrors: false },
});
expectBlockValue(container, "channelsdvrserver.shows", 1);
expectBlockValue(container, "channelsdvrserver.recordings", 2);
expectBlockValue(container, "channelsdvrserver.scheduled", 3);
expectBlockValue(container, "channelsdvrserver.passes", 4);
});
});

View File

@@ -0,0 +1,11 @@
import { describe, it } from "vitest";
import { expectWidgetConfigShape } from "test-utils/widget-config";
import widget from "./widget";
describe("channelsdvrserver widget config", () => {
it("exports a valid widget config", () => {
expectWidgetConfigShape(widget);
});
});

View File

@@ -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/checkmk/component", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("calls both endpoints with the expected query params and renders placeholders while loading", () => {
useWidgetAPI.mockImplementation(() => ({ data: undefined, error: undefined }));
const { container } = renderWithProviders(<Component service={{ widget: { type: "checkmk" } }} />, {
settings: { hideErrors: false },
});
expect(useWidgetAPI).toHaveBeenNthCalledWith(
1,
expect.any(Object),
"services_info",
expect.objectContaining({
columns: "state",
query: '{"op": "!=", "left": "state", "right": "0"}',
}),
);
expect(useWidgetAPI).toHaveBeenNthCalledWith(
2,
expect.any(Object),
"hosts_info",
expect.objectContaining({
columns: "state",
query: '{"op": "!=", "left": "state", "right": "0"}',
}),
);
expect(container.querySelectorAll(".service-block")).toHaveLength(2);
expect(screen.getByText("checkmk.serviceErrors")).toBeInTheDocument();
expect(screen.getByText("checkmk.hostErrors")).toBeInTheDocument();
});
it("renders counts when loaded", () => {
useWidgetAPI
.mockReturnValueOnce({ data: { value: [{}, {}] }, error: undefined })
.mockReturnValueOnce({ data: { value: [{}] }, error: undefined });
const { container } = renderWithProviders(<Component service={{ widget: { type: "checkmk" } }} />, {
settings: { hideErrors: false },
});
expectBlockValue(container, "checkmk.serviceErrors", 2);
expectBlockValue(container, "checkmk.hostErrors", 1);
});
});

View File

@@ -0,0 +1,11 @@
import { describe, it } from "vitest";
import { expectWidgetConfigShape } from "test-utils/widget-config";
import widget from "./widget";
describe("checkmk widget config", () => {
it("exports a valid widget config", () => {
expectWidgetConfigShape(widget);
});
});

View File

@@ -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";
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/cloudflared/component", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders placeholders while loading", () => {
useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });
const { container } = renderWithProviders(<Component service={{ widget: { type: "cloudflared" } }} />, {
settings: { hideErrors: false },
});
expect(container.querySelectorAll(".service-block")).toHaveLength(2);
expect(screen.getByText("cloudflared.status")).toBeInTheDocument();
expect(screen.getByText("cloudflared.origin_ip")).toBeInTheDocument();
});
it("renders status capitalization and origin_ip from nested connections", () => {
useWidgetAPI.mockReturnValue({
data: {
result: { status: "healthy", connections: { origin_ip: "1.2.3.4" } },
},
error: undefined,
});
const { container } = renderWithProviders(<Component service={{ widget: { type: "cloudflared" } }} />, {
settings: { hideErrors: false },
});
expectBlockValue(container, "cloudflared.status", "Healthy");
expectBlockValue(container, "cloudflared.origin_ip", "1.2.3.4");
});
it("falls back to origin_ip from first connection entry", () => {
useWidgetAPI.mockReturnValue({
data: {
result: { status: "down", connections: [{ origin_ip: "5.6.7.8" }] },
},
error: undefined,
});
const { container } = renderWithProviders(<Component service={{ widget: { type: "cloudflared" } }} />, {
settings: { hideErrors: false },
});
expectBlockValue(container, "cloudflared.origin_ip", "5.6.7.8");
});
});

View File

@@ -0,0 +1,11 @@
import { describe, it } from "vitest";
import { expectWidgetConfigShape } from "test-utils/widget-config";
import widget from "./widget";
describe("cloudflared widget config", () => {
it("exports a valid widget config", () => {
expectWidgetConfigShape(widget);
});
});

View File

@@ -0,0 +1,86 @@
// @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 { useWidgetAPI } = vi.hoisted(() => ({ useWidgetAPI: vi.fn() }));
vi.mock("utils/proxy/use-widget-api", () => ({ default: useWidgetAPI }));
// HeadlessUI dropdown is hard to test reliably; stub to a simple button.
vi.mock("components/services/dropdown", () => ({
default: ({ value, setValue }) => (
<button type="button" data-testid="cmc-dropdown" onClick={() => setValue("24h")}>
{value}
</button>
),
}));
import Component from "./component";
describe("widgets/coinmarketcap/component", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders a configure message when no symbols/slugs are provided", () => {
useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });
renderWithProviders(<Component service={{ widget: { type: "coinmarketcap" } }} />, {
settings: { hideErrors: false },
});
expect(screen.getByText("coinmarketcap.configure")).toBeInTheDocument();
});
it("renders error UI when widget API errors", () => {
useWidgetAPI.mockReturnValue({ data: undefined, error: { message: "nope" } });
renderWithProviders(<Component service={{ widget: { type: "coinmarketcap", symbols: ["BTC"] } }} />, {
settings: { hideErrors: false },
});
// Error component normalizes the error into a message line we can assert on.
expect(screen.getByText("nope")).toBeInTheDocument();
});
it("renders valid cryptos and updates percent change when date range changes", () => {
useWidgetAPI.mockReturnValue({
data: {
data: {
BTC: {
id: 1,
name: "Bitcoin",
quote: { USD: { price: 30000, percent_change_1h: 1.234, percent_change_24h: -2.5 } },
},
ETH: {
id: 2,
name: "Ethereum",
quote: { USD: { price: 2000, percent_change_1h: null, percent_change_24h: null } },
},
},
},
error: undefined,
});
renderWithProviders(
<Component
service={{ widget: { type: "coinmarketcap", symbols: ["BTC", "ETH"], currency: "USD", defaultinterval: "1h" } }}
/>,
{ settings: { hideErrors: false } },
);
// Only BTC is valid for 1h, ETH is filtered out due to null percent change.
expect(screen.getByTestId("cmc-dropdown")).toHaveTextContent("1h");
expect(screen.getByText("Bitcoin")).toBeInTheDocument();
expect(screen.queryByText("Ethereum")).toBeNull();
expect(screen.getByText("30000")).toBeInTheDocument();
expect(screen.getByText("1.23%")).toBeInTheDocument();
fireEvent.click(screen.getByTestId("cmc-dropdown"));
expect(screen.getByTestId("cmc-dropdown")).toHaveTextContent("24h");
expect(screen.getByText("-2.50%")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,11 @@
import { describe, it } from "vitest";
import { expectWidgetConfigShape } from "test-utils/widget-config";
import widget from "./widget";
describe("coinmarketcap widget config", () => {
it("exports a valid widget config", () => {
expectWidgetConfigShape(widget);
});
});

View File

@@ -0,0 +1,61 @@
// @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/crowdsec/component", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("selects alerts24h endpoint when limit24h is enabled", () => {
useWidgetAPI.mockImplementation(() => ({ data: undefined, error: undefined }));
renderWithProviders(<Component service={{ widget: { type: "crowdsec", limit24h: true } }} />, {
settings: { hideErrors: false },
});
expect(useWidgetAPI).toHaveBeenNthCalledWith(1, expect.any(Object), "alerts24h");
expect(useWidgetAPI).toHaveBeenNthCalledWith(2, expect.any(Object), "bans");
});
it("renders placeholders when both alerts and bans are missing", () => {
useWidgetAPI.mockImplementation(() => ({ data: undefined, error: undefined }));
const { container } = renderWithProviders(<Component service={{ widget: { type: "crowdsec" } }} />, {
settings: { hideErrors: false },
});
expect(container.querySelectorAll(".service-block")).toHaveLength(2);
expect(screen.getByText("crowdsec.alerts")).toBeInTheDocument();
expect(screen.getByText("crowdsec.bans")).toBeInTheDocument();
});
it("renders 0-length arrays as 0 counts", () => {
useWidgetAPI
.mockReturnValueOnce({ data: [], error: undefined })
.mockReturnValueOnce({ data: [], error: undefined });
const { container } = renderWithProviders(<Component service={{ widget: { type: "crowdsec" } }} />, {
settings: { hideErrors: false },
});
expectBlockValue(container, "crowdsec.alerts", 0);
expectBlockValue(container, "crowdsec.bans", 0);
});
});

View File

@@ -0,0 +1,92 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import createMockRes from "test-utils/create-mock-res";
const { httpProxy, getServiceWidget, cache, logger } = vi.hoisted(() => {
const store = new Map();
return {
httpProxy: vi.fn(),
getServiceWidget: vi.fn(),
cache: {
get: vi.fn((k) => store.get(k)),
put: vi.fn((k, v) => store.set(k, v)),
del: vi.fn((k) => store.delete(k)),
_reset: () => store.clear(),
},
logger: {
debug: vi.fn(),
error: vi.fn(),
},
};
});
vi.mock("memory-cache", () => ({
default: cache,
...cache,
}));
vi.mock("utils/logger", () => ({
default: () => logger,
}));
vi.mock("utils/config/service-helpers", () => ({
default: getServiceWidget,
}));
vi.mock("utils/proxy/http", () => ({
httpProxy,
}));
vi.mock("widgets/widgets", () => ({
default: {
crowdsec: {
api: "{url}/{endpoint}",
loginURL: "{url}/login",
},
},
}));
import crowdsecProxyHandler from "./proxy";
describe("widgets/crowdsec/proxy", () => {
beforeEach(() => {
vi.clearAllMocks();
cache._reset();
});
it("logs in, caches a token, and uses it for requests", async () => {
getServiceWidget.mockResolvedValue({
type: "crowdsec",
url: "http://cs",
username: "machine",
password: "pw",
});
httpProxy
.mockResolvedValueOnce([
200,
"application/json",
JSON.stringify({ token: "tok", expire: new Date(Date.now() + 60_000).toISOString() }),
])
.mockResolvedValueOnce([200, "application/json", Buffer.from("data")]);
const req = { query: { group: "g", service: "svc", endpoint: "alerts", index: "0" } };
const res = createMockRes();
await crowdsecProxyHandler(req, res);
expect(httpProxy).toHaveBeenCalledTimes(2);
expect(httpProxy.mock.calls[1][1].headers.Authorization).toBe("Bearer tok");
expect(res.statusCode).toBe(200);
expect(res.body).toEqual(Buffer.from("data"));
});
it("returns 500 if token cannot be obtained", async () => {
getServiceWidget.mockResolvedValue({ type: "crowdsec", url: "http://cs", username: "machine", password: "pw" });
httpProxy.mockResolvedValueOnce([200, "application/json", JSON.stringify({ expire: "2099-01-01T00:00:00Z" })]);
const req = { query: { group: "g", service: "svc", endpoint: "alerts", index: "0" } };
const res = createMockRes();
await crowdsecProxyHandler(req, res);
expect(res.statusCode).toBe(500);
expect(res.body).toEqual({ error: "Failed to authenticate with Crowdsec" });
});
});

View File

@@ -0,0 +1,11 @@
import { describe, it } from "vitest";
import { expectWidgetConfigShape } from "test-utils/widget-config";
import widget from "./widget";
describe("crowdsec widget config", () => {
it("exports a valid widget config", () => {
expectWidgetConfigShape(widget);
});
});

View File

@@ -0,0 +1,202 @@
// @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/customapi/component", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders placeholder blocks for the first 4 mappings while loading (block display)", () => {
useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });
const service = {
widget: {
type: "customapi",
mappings: [{ label: "a" }, { label: "b" }, { label: "c" }, { label: "d" }, { label: "e" }],
},
};
const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });
expect(container.querySelectorAll(".service-block")).toHaveLength(4);
expect(screen.getByText("a")).toBeInTheDocument();
expect(screen.getByText("b")).toBeInTheDocument();
expect(screen.getByText("c")).toBeInTheDocument();
expect(screen.getByText("d")).toBeInTheDocument();
expect(screen.queryByText("e")).toBeNull();
});
it("renders list display, including additionalField and adaptive color", () => {
useWidgetAPI.mockReturnValue({
data: { foo: { bar: 10 }, delta: -1 },
error: undefined,
});
const service = {
widget: {
type: "customapi",
display: "list",
mappings: [
{
label: "Value",
field: "foo.bar",
format: "number",
prefix: "$",
additionalField: { field: "delta", color: "adaptive" },
},
],
},
};
renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });
expect(screen.getByText("Value")).toBeInTheDocument();
expect(screen.getByText("$ 10")).toBeInTheDocument();
const delta = screen.getByText("-1");
expect(delta.className).toContain("text-rose-300");
});
it("shows error UI when widget API errors and mappings do not treat error as data", () => {
useWidgetAPI.mockReturnValue({ data: undefined, error: { message: "nope" } });
renderWithProviders(
<Component service={{ widget: { type: "customapi", mappings: [{ label: "x", field: "x" }] } }} />,
{ settings: { hideErrors: false } },
);
expect(screen.getByText("nope")).toBeInTheDocument();
});
it("treats error payloads as data when a mapping targets the error field", () => {
useWidgetAPI.mockReturnValue({
data: { error: "rate limited" },
error: { message: "ignored" },
});
renderWithProviders(
<Component
service={{
widget: {
type: "customapi",
display: "list",
mappings: [{ label: "Error", field: "error", format: "text" }],
},
}}
/>,
{ settings: { hideErrors: false } },
);
expect(screen.queryByText("ignored")).toBeNull();
expect(screen.getByText("rate limited")).toBeInTheDocument();
});
it("renders dynamic-list placeholders while loading", () => {
useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });
renderWithProviders(
<Component service={{ widget: { type: "customapi", display: "dynamic-list", mappings: {} } }} />,
{ settings: { hideErrors: false } },
);
expect(screen.getByText("Loading...")).toBeInTheDocument();
});
it("renders dynamic-list errors when required mapping properties are missing", () => {
useWidgetAPI.mockReturnValue({ data: { items: [] }, error: undefined });
renderWithProviders(
<Component
service={{
widget: {
type: "customapi",
display: "dynamic-list",
mappings: { items: "items" },
},
}}
/>,
{ settings: { hideErrors: false } },
);
expect(screen.getByText("Name and label properties are required")).toBeInTheDocument();
});
it("renders dynamic-list items with a target link and enforces the limit", () => {
useWidgetAPI.mockReturnValue({
data: {
items: [
{ id: "1", name: "First", value: 2 },
{ id: "2", name: "Second", value: 3 },
],
},
error: undefined,
});
renderWithProviders(
<Component
service={{
widget: {
type: "customapi",
display: "dynamic-list",
mappings: {
items: "items",
name: "name",
label: "value",
target: "https://example.com/items/{id}",
limit: "1",
prefix: "#",
scale: "2/1",
format: "number",
},
},
}}
/>,
{ settings: { hideErrors: false } },
);
expect(screen.getByText("First")).toBeInTheDocument();
expect(screen.getByText("# 4")).toBeInTheDocument();
expect(screen.queryByText("Second")).toBeNull();
const link = screen.getByRole("link", { name: /First/i });
expect(link).toHaveAttribute("href", "https://example.com/items/1");
});
it("supports legacy object field definitions and size formatting", () => {
useWidgetAPI.mockReturnValue({
data: { a: { b: { c: ["x", "y", "z"] } } },
error: undefined,
});
renderWithProviders(
<Component
service={{
widget: {
type: "customapi",
mappings: [
{
label: "Count",
field: { a: { b: "c" } },
format: "size",
suffix: "items",
},
],
},
}}
/>,
{ settings: { hideErrors: false } },
);
expect(screen.getByText("Count")).toBeInTheDocument();
expect(screen.getByText("3 items")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,11 @@
import { describe, it } from "vitest";
import { expectWidgetConfigShape } from "test-utils/widget-config";
import widget from "./widget";
describe("customapi widget config", () => {
it("exports a valid widget config", () => {
expectWidgetConfigShape(widget);
});
});

View File

@@ -0,0 +1,84 @@
// @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 }));
vi.mock("../../components/widgets/queue/queueEntry", () => ({
default: ({ title }) => <div data-testid="queue-entry">{title}</div>,
}));
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/deluge/component", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders placeholders while loading", () => {
useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });
const { container } = renderWithProviders(<Component service={{ widget: { type: "deluge" } }} />, {
settings: { hideErrors: false },
});
expect(container.querySelectorAll(".service-block")).toHaveLength(4);
expect(screen.getByText("deluge.leech")).toBeInTheDocument();
expect(screen.getByText("deluge.download")).toBeInTheDocument();
expect(screen.getByText("deluge.seed")).toBeInTheDocument();
expect(screen.getByText("deluge.upload")).toBeInTheDocument();
});
it("computes leech/seed counts and upload/download rates, and renders leech progress entries", () => {
useWidgetAPI.mockReturnValue({
data: {
torrents: {
a: { download_payload_rate: 10, upload_payload_rate: 1, total_remaining: 0, state: "Seeding", progress: 100 },
b: {
download_payload_rate: 5,
upload_payload_rate: 2,
total_remaining: 5,
state: "Downloading",
progress: 50,
eta: 60,
name: "B",
},
c: {
download_payload_rate: 0,
upload_payload_rate: 3,
total_remaining: 10,
state: "Downloading",
progress: 10,
eta: 120,
name: "C",
},
},
},
error: undefined,
});
const service = { widget: { type: "deluge", enableLeechProgress: true } };
const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });
// keys=3, completed=1 => leech=2
expectBlockValue(container, "deluge.leech", 2);
expectBlockValue(container, "deluge.seed", 1);
expectBlockValue(container, "deluge.download", 15);
expectBlockValue(container, "deluge.upload", 6);
// Only downloading torrents get QueueEntry.
expect(screen.getAllByTestId("queue-entry").map((el) => el.textContent)).toEqual(["B", "C"]);
});
});

View File

@@ -0,0 +1,63 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import createMockRes from "test-utils/create-mock-res";
const { sendJsonRpcRequest, getServiceWidget, logger } = vi.hoisted(() => ({
sendJsonRpcRequest: vi.fn(),
getServiceWidget: vi.fn(),
logger: {
debug: vi.fn(),
},
}));
vi.mock("utils/logger", () => ({
default: () => logger,
}));
vi.mock("utils/config/service-helpers", () => ({
default: getServiceWidget,
}));
vi.mock("utils/proxy/handlers/jsonrpc", () => ({
sendJsonRpcRequest,
}));
vi.mock("widgets/widgets", () => ({
default: {
deluge: {
api: "{url}",
},
},
}));
import delugeProxyHandler from "./proxy";
describe("widgets/deluge/proxy", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("logs in and retries the update call after an auth error", async () => {
getServiceWidget.mockResolvedValue({ type: "deluge", url: "http://deluge", password: "pw" });
sendJsonRpcRequest
// update_ui -> error code 1 => 403
.mockResolvedValueOnce([200, "application/json", Buffer.from(JSON.stringify({ error: { code: 1 } }))])
// auth.login -> ok
.mockResolvedValueOnce([200, "application/json", Buffer.from(JSON.stringify({ result: true }))])
// update_ui retry -> ok
.mockResolvedValueOnce([200, "application/json", Buffer.from(JSON.stringify({ result: { torrents: {} } }))]);
const req = { query: { group: "g", service: "svc", index: "0" } };
const res = createMockRes();
await delugeProxyHandler(req, res);
expect(sendJsonRpcRequest).toHaveBeenCalledTimes(3);
expect(sendJsonRpcRequest.mock.calls[0][1]).toBe("web.update_ui");
expect(sendJsonRpcRequest.mock.calls[1][1]).toBe("auth.login");
expect(sendJsonRpcRequest.mock.calls[2][1]).toBe("web.update_ui");
expect(res.statusCode).toBe(200);
expect(res.body).toEqual(Buffer.from(JSON.stringify({ result: { torrents: {} } })));
});
});

View File

@@ -0,0 +1,11 @@
import { describe, it } from "vitest";
import { expectWidgetConfigShape } from "test-utils/widget-config";
import widget from "./widget";
describe("deluge widget config", () => {
it("exports a valid widget config", () => {
expectWidgetConfigShape(widget);
});
});

View File

@@ -0,0 +1,44 @@
// @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/develancacheui/component", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders placeholders while loading", () => {
useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });
const { container } = renderWithProviders(<Component service={{ widget: { type: "develancacheui" } }} />, {
settings: { hideErrors: false },
});
expect(container.querySelectorAll(".service-block")).toHaveLength(2);
expect(screen.getByText("develancacheui.cachehitbytes")).toBeInTheDocument();
expect(screen.getByText("develancacheui.cachemissbytes")).toBeInTheDocument();
});
it("renders byte totals when loaded", () => {
useWidgetAPI.mockReturnValue({
data: { totalCacheHitBytes: 100, totalCacheMissBytes: 200 },
error: undefined,
});
renderWithProviders(<Component service={{ widget: { type: "develancacheui" } }} />, {
settings: { hideErrors: false },
});
expect(screen.getByText("100")).toBeInTheDocument();
expect(screen.getByText("200")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,11 @@
import { describe, it } from "vitest";
import { expectWidgetConfigShape } from "test-utils/widget-config";
import widget from "./widget";
describe("develancacheui widget config", () => {
it("exports a valid widget config", () => {
expectWidgetConfigShape(widget);
});
});

View File

@@ -0,0 +1,61 @@
// @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/diskstation/component", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders placeholders while loading", () => {
useWidgetAPI.mockImplementation(() => ({ data: undefined, error: undefined }));
const { container } = renderWithProviders(<Component service={{ widget: { type: "diskstation" } }} />, {
settings: { hideErrors: false },
});
expect(container.querySelectorAll(".service-block")).toHaveLength(4);
expect(screen.getByText("diskstation.uptime")).toBeInTheDocument();
expect(screen.getByText("diskstation.volumeAvailable")).toBeInTheDocument();
expect(screen.getByText("resources.cpu")).toBeInTheDocument();
expect(screen.getByText("resources.mem")).toBeInTheDocument();
});
it("computes uptime days, volume free bytes, and CPU/memory usage", () => {
useWidgetAPI
.mockReturnValueOnce({ data: { data: { up_time: "48:00:00" } }, error: undefined })
.mockReturnValueOnce({
data: { data: { vol_info: [{ name: "vol1", used_size: "20", total_size: "100" }] } },
error: undefined,
})
.mockReturnValueOnce({
data: { data: { cpu: { user_load: "10", system_load: "5" }, memory: { real_usage: "25" } } },
error: undefined,
});
const { container } = renderWithProviders(
<Component service={{ widget: { type: "diskstation", volume: "vol1" } }} />,
{ settings: { hideErrors: false } },
);
expectBlockValue(container, "diskstation.uptime", "2 diskstation.days");
expectBlockValue(container, "diskstation.volumeAvailable", 80);
expectBlockValue(container, "resources.cpu", 15);
expectBlockValue(container, "resources.mem", 25);
});
});

View File

@@ -0,0 +1,11 @@
import { describe, it } from "vitest";
import { expectWidgetConfigShape } from "test-utils/widget-config";
import widget from "./widget";
describe("diskstation widget config", () => {
it("exports a valid widget config", () => {
expectWidgetConfigShape(widget);
});
});

View File

@@ -0,0 +1,54 @@
// @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/dispatcharr/component", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders placeholders while loading", () => {
useWidgetAPI.mockImplementation(() => ({ data: undefined, error: undefined }));
const { container } = renderWithProviders(<Component service={{ widget: { type: "dispatcharr" } }} />, {
settings: { hideErrors: false },
});
expect(container.querySelectorAll(".service-block")).toHaveLength(2);
expect(screen.getByText("dispatcharr.channels")).toBeInTheDocument();
expect(screen.getByText("dispatcharr.streams")).toBeInTheDocument();
});
it("renders counts and stream entries when enabled", () => {
useWidgetAPI.mockReturnValueOnce({ data: [{}, {}, {}], error: undefined }).mockReturnValueOnce({
data: {
count: 1,
channels: [{ stream_name: "Stream1", clients: [{}, {}], avg_bitrate: "1000kbps" }],
},
error: undefined,
});
const service = { widget: { type: "dispatcharr", enableActiveStreams: true } };
const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });
expectBlockValue(container, "dispatcharr.channels", 3);
expectBlockValue(container, "dispatcharr.streams", 1);
expect(screen.getByText(/Stream1 - Clients: 2/)).toBeInTheDocument();
expect(screen.getByText("1000kbps")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,108 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import createMockRes from "test-utils/create-mock-res";
const { httpProxy, getServiceWidget, cache, logger } = vi.hoisted(() => {
const store = new Map();
return {
httpProxy: vi.fn(),
getServiceWidget: vi.fn(),
cache: {
get: vi.fn((k) => store.get(k)),
put: vi.fn((k, v) => store.set(k, v)),
del: vi.fn((k) => store.delete(k)),
_reset: () => store.clear(),
},
logger: {
debug: vi.fn(),
error: vi.fn(),
},
};
});
vi.mock("utils/logger", () => ({
default: () => logger,
}));
vi.mock("utils/config/service-helpers", () => ({
default: getServiceWidget,
}));
vi.mock("utils/proxy/http", () => ({
httpProxy,
}));
vi.mock("memory-cache", () => ({
default: cache,
...cache,
}));
vi.mock("widgets/widgets", () => ({
default: {
dispatcharr: {
api: "{url}/{endpoint}",
mappings: {
token: { endpoint: "auth/token" },
},
},
},
}));
import dispatcharrProxyHandler from "./proxy";
describe("widgets/dispatcharr/proxy", () => {
beforeEach(() => {
vi.clearAllMocks();
cache._reset();
});
it("logs in when token is missing and uses Bearer token for requests", async () => {
getServiceWidget.mockResolvedValue({
type: "dispatcharr",
url: "http://dispatcharr",
username: "u",
password: "p",
});
httpProxy
.mockResolvedValueOnce([200, "application/json", Buffer.from(JSON.stringify({ access: "t1" }))])
.mockResolvedValueOnce([200, "application/json", Buffer.from("data")]);
const req = { query: { group: "g", service: "svc", endpoint: "items", index: "0" } };
const res = createMockRes();
await dispatcharrProxyHandler(req, res);
expect(httpProxy).toHaveBeenCalledTimes(2);
expect(httpProxy.mock.calls[0][0].toString()).toBe("http://dispatcharr/auth/token");
expect(httpProxy.mock.calls[1][1].headers.Authorization).toBe("Bearer t1");
expect(res.body).toEqual(Buffer.from("data"));
});
it("retries after a bad response by clearing cache and logging in again", async () => {
cache.put("dispatcharrProxyHandler__token.svc", "old");
getServiceWidget.mockResolvedValue({
type: "dispatcharr",
url: "http://dispatcharr",
username: "u",
password: "p",
});
httpProxy
.mockResolvedValueOnce([400, "application/json", Buffer.from(JSON.stringify({ items: [] }))])
.mockResolvedValueOnce([200, "application/json", Buffer.from(JSON.stringify({ access: "new" }))])
.mockResolvedValueOnce([200, "application/json", Buffer.from("ok")]);
const req = { query: { group: "g", service: "svc", endpoint: "items", index: "0" } };
const res = createMockRes();
await dispatcharrProxyHandler(req, res);
expect(httpProxy).toHaveBeenCalledTimes(3);
expect(httpProxy.mock.calls[1][0].toString()).toBe("http://dispatcharr/auth/token");
expect(httpProxy.mock.calls[2][1].headers.Authorization).toBe("Bearer new");
expect(res.body).toEqual(Buffer.from("ok"));
});
});

View File

@@ -0,0 +1,11 @@
import { describe, it } from "vitest";
import { expectWidgetConfigShape } from "test-utils/widget-config";
import widget from "./widget";
describe("dispatcharr widget config", () => {
it("exports a valid widget config", () => {
expectWidgetConfigShape(widget);
});
});

View File

@@ -0,0 +1,61 @@
// @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/docker/component", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders offline status when container is not running", () => {
useSWR
.mockReturnValueOnce({ data: { status: "exited" }, error: undefined }) // status
.mockReturnValueOnce({ data: undefined, error: undefined }); // stats
renderWithProviders(<Component service={{ widget: { type: "docker", container: "c" } }} />, {
settings: { hideErrors: false },
});
expect(screen.getByText("widget.status")).toBeInTheDocument();
expect(screen.getByText("docker.offline")).toBeInTheDocument();
});
it("renders cpu/mem/rx/tx values when stats are available", () => {
useSWR
.mockReturnValueOnce({ data: { status: "running" }, error: undefined }) // status
.mockReturnValueOnce({
data: {
stats: {
cpu_stats: { cpu_usage: { total_usage: 200 }, system_cpu_usage: 2000, online_cpus: 2 },
precpu_stats: { cpu_usage: { total_usage: 100 }, system_cpu_usage: 1000 },
memory_stats: { usage: 1000, total_inactive_file: 100 },
networks: { eth0: { rx_bytes: 1, tx_bytes: 2 }, eth1: { rx_bytes: 3, tx_bytes: 4 } },
},
},
error: undefined,
});
const { container } = renderWithProviders(<Component service={{ widget: { type: "docker", container: "c" } }} />, {
settings: { hideErrors: false },
});
// cpu: (100/1000)*2*100=20
expect(container.textContent).toContain("20");
// mem used: 1000-100=900
expect(container.textContent).toContain("900");
// rx=4, tx=6
expect(container.textContent).toContain("4");
expect(container.textContent).toContain("6");
});
});

View File

@@ -0,0 +1,55 @@
import { describe, expect, it } from "vitest";
import { calculateCPUPercent, calculateThroughput, calculateUsedMemory } from "./stats-helpers";
describe("widgets/docker/stats-helpers", () => {
it("calculateCPUPercent returns 0 when deltas are not positive", () => {
expect(
calculateCPUPercent({
cpu_stats: { cpu_usage: { total_usage: 100 }, system_cpu_usage: 1000, online_cpus: 2 },
precpu_stats: { cpu_usage: { total_usage: 100 }, system_cpu_usage: 1000 },
}),
).toBe(0);
});
it("calculateCPUPercent computes percent and rounds to 1 decimal", () => {
// cpuDelta=100, systemDelta=1000, cpus=2 => (100/1000)*2*100 = 20.0
expect(
calculateCPUPercent({
cpu_stats: { cpu_usage: { total_usage: 200 }, system_cpu_usage: 2000, online_cpus: 2 },
precpu_stats: { cpu_usage: { total_usage: 100 }, system_cpu_usage: 1000 },
}),
).toBe(20);
});
it("calculateUsedMemory subtracts inactive file (prefers total_inactive_file)", () => {
const stats = {
memory_stats: {
usage: 1000,
total_inactive_file: 100,
stats: { inactive_file: 200 },
},
};
expect(calculateUsedMemory(stats)).toBe(900);
});
it("calculateUsedMemory falls back to stats.inactive_file when total_inactive_file missing", () => {
const stats = {
memory_stats: {
usage: 1000,
stats: { inactive_file: 200 },
},
};
expect(calculateUsedMemory(stats)).toBe(800);
});
it("calculateThroughput uses the special networks.network key when present", () => {
const stats = { networks: { network: { rx_bytes: 5, tx_bytes: 6 }, eth0: { rx_bytes: 1, tx_bytes: 2 } } };
expect(calculateThroughput(stats)).toEqual({ rxBytes: 5, txBytes: 6 });
});
it("calculateThroughput sums all interfaces otherwise", () => {
const stats = { networks: { eth0: { rx_bytes: 1, tx_bytes: 2 }, eth1: { rx_bytes: 3, tx_bytes: 4 } } };
expect(calculateThroughput(stats)).toEqual({ rxBytes: 4, txBytes: 6 });
});
});

View File

@@ -0,0 +1,61 @@
// @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/dockhand/component", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("defaults fields and filters to 4 blocks while loading", () => {
useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });
const service = { widget: { type: "dockhand" } };
const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });
expect(service.widget.fields).toEqual(["running", "total", "cpu", "memory"]);
expect(container.querySelectorAll(".service-block")).toHaveLength(4);
expect(screen.getByText("dockhand.running")).toBeInTheDocument();
expect(screen.getByText("dockhand.total")).toBeInTheDocument();
expect(screen.getByText("dockhand.cpu")).toBeInTheDocument();
expect(screen.getByText("dockhand.memory")).toBeInTheDocument();
});
it("renders environment-specific values when widget.environment matches", () => {
useWidgetAPI.mockReturnValue({
data: [
{
id: "1",
name: "Prod",
containers: { running: 2, total: 5, paused: 1, pendingUpdates: 3 },
metrics: { cpuPercent: 10, memoryPercent: 20 },
},
],
error: undefined,
});
const service = { widget: { type: "dockhand", environment: "prod" } };
const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });
expect(container.querySelectorAll(".service-block")).toHaveLength(4);
expectBlockValue(container, "dockhand.running", 2);
expectBlockValue(container, "dockhand.total", 5);
expectBlockValue(container, "dockhand.cpu", 10);
expectBlockValue(container, "dockhand.memory", 20);
});
});

View File

@@ -0,0 +1,82 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import createMockRes from "test-utils/create-mock-res";
const { httpProxy, getServiceWidget, logger } = vi.hoisted(() => ({
httpProxy: vi.fn(),
getServiceWidget: vi.fn(),
logger: {
debug: vi.fn(),
error: vi.fn(),
},
}));
vi.mock("utils/logger", () => ({
default: () => logger,
}));
vi.mock("utils/config/service-helpers", () => ({
default: getServiceWidget,
}));
vi.mock("utils/proxy/http", () => ({
httpProxy,
}));
vi.mock("widgets/widgets", () => ({
default: {
dockhand: {
api: "{url}/{endpoint}",
},
},
}));
import dockhandProxyHandler from "./proxy";
describe("widgets/dockhand/proxy", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("retries after a 401 by logging in once", async () => {
getServiceWidget.mockResolvedValue({
type: "dockhand",
url: "http://dockhand/",
username: "u",
password: "p",
});
httpProxy
.mockResolvedValueOnce([401, "application/json", Buffer.from("nope")])
.mockResolvedValueOnce([200, "application/json", Buffer.from("ok")]) // login
.mockResolvedValueOnce([200, "application/json", Buffer.from("data")]); // retry
const req = { method: "GET", query: { group: "g", service: "svc", endpoint: "api/v1/status", index: "0" } };
const res = createMockRes();
await dockhandProxyHandler(req, res);
expect(httpProxy).toHaveBeenCalledTimes(3);
expect(httpProxy.mock.calls[1][0]).toBe("http://dockhand/api/auth/login");
expect(res.statusCode).toBe(200);
expect(res.body).toEqual(Buffer.from("data"));
});
it("returns a sanitized error response for HTTP errors", async () => {
getServiceWidget.mockResolvedValue({
type: "dockhand",
url: "http://dockhand",
});
httpProxy.mockResolvedValueOnce([500, "application/json", Buffer.from("boom")]);
const req = {
method: "GET",
query: { group: "g", service: "svc", endpoint: "api/v1/status?token=abc", index: "0" },
};
const res = createMockRes();
await dockhandProxyHandler(req, res);
expect(res.statusCode).toBe(500);
expect(res.body.error.message).toBe("HTTP Error");
expect(res.body.error.url).toContain("token=***");
});
});

View File

@@ -0,0 +1,11 @@
import { describe, it } from "vitest";
import { expectWidgetConfigShape } from "test-utils/widget-config";
import widget from "./widget";
describe("dockhand widget config", () => {
it("exports a valid widget config", () => {
expectWidgetConfigShape(widget);
});
});

View File

@@ -0,0 +1,62 @@
// @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/downloadstation/component", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders placeholders while tasks are missing", () => {
useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });
const { container } = renderWithProviders(<Component service={{ widget: { type: "downloadstation" } }} />, {
settings: { hideErrors: false },
});
expect(container.querySelectorAll(".service-block")).toHaveLength(4);
expect(screen.getByText("downloadstation.leech")).toBeInTheDocument();
expect(screen.getByText("downloadstation.download")).toBeInTheDocument();
expect(screen.getByText("downloadstation.seed")).toBeInTheDocument();
expect(screen.getByText("downloadstation.upload")).toBeInTheDocument();
});
it("computes leech/seed counts and total upload/download rates", () => {
useWidgetAPI.mockReturnValue({
data: {
data: {
tasks: [
{ size: 10, additional: { transfer: { size_downloaded: 10, speed_download: 5, speed_upload: 1 } } },
{ size: 20, additional: { transfer: { size_downloaded: 5, speed_download: 6, speed_upload: 2 } } },
],
},
},
error: undefined,
});
const { container } = renderWithProviders(<Component service={{ widget: { type: "downloadstation" } }} />, {
settings: { hideErrors: false },
});
// completed = 1, leech = 1
expectBlockValue(container, "downloadstation.seed", 1);
expectBlockValue(container, "downloadstation.leech", 1);
expectBlockValue(container, "downloadstation.download", 11);
expectBlockValue(container, "downloadstation.upload", 3);
});
});

View File

@@ -0,0 +1,11 @@
import { describe, it } from "vitest";
import { expectWidgetConfigShape } from "test-utils/widget-config";
import widget from "./widget";
describe("downloadstation widget config", () => {
it("exports a valid widget config", () => {
expectWidgetConfigShape(widget);
});
});

View File

@@ -0,0 +1,103 @@
// @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";
function baseSession(overrides = {}) {
return {
Id: "s1",
UserName: "Alice",
NowPlayingItem: {
Type: "Episode",
Name: "Pilot",
SeriesName: "Show",
ParentIndexNumber: 1,
IndexNumber: 2,
RunTimeTicks: 100000000,
},
PlayState: { PositionTicks: 50000000, IsPaused: true, IsMuted: true },
TranscodingInfo: { IsVideoDirect: true, VideoDecoderIsHardware: true, VideoEncoderIsHardware: true },
...overrides,
};
}
describe("widgets/emby/component", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders loading skeleton when sessions/count are missing", () => {
useWidgetAPI.mockReturnValue({ data: undefined, error: undefined, mutate: vi.fn() });
const { container } = renderWithProviders(
<Component service={{ widget: { type: "emby", enableBlocks: true, enableNowPlaying: true } }} />,
{ settings: { hideErrors: false } },
);
// CountBlocks placeholders should be present.
expect(container.querySelectorAll(".service-block")).toHaveLength(4);
expect(screen.getByText("emby.movies")).toBeInTheDocument();
expect(screen.getByText("emby.series")).toBeInTheDocument();
expect(screen.getByText("emby.episodes")).toBeInTheDocument();
expect(screen.getByText("emby.songs")).toBeInTheDocument();
});
it("renders single-session view with expanded two rows and stream title with user + episode number", () => {
useWidgetAPI
.mockReturnValueOnce({ data: [baseSession()], error: undefined, mutate: vi.fn() }) // Sessions
.mockReturnValueOnce({
data: { MovieCount: 1, SeriesCount: 2, EpisodeCount: 3, SongCount: 4 },
error: undefined,
}); // Count
renderWithProviders(
<Component
service={{
widget: {
type: "emby",
enableBlocks: true,
enableNowPlaying: true,
enableUser: true,
showEpisodeNumber: true,
expandOneStreamToTwoRows: true,
},
}}
/>,
{ settings: { hideErrors: false } },
);
expect(screen.getByText("Show: S01 · E02 - Pilot (Alice)")).toBeInTheDocument();
expect(screen.getByText(/00:05/)).toBeInTheDocument();
expect(screen.getByText(/00:10/)).toBeInTheDocument();
});
it("renders no_active when there are no sessions playing", () => {
useWidgetAPI
.mockReturnValueOnce({
data: [{ Id: "s2", PlayState: { PositionTicks: 0 }, UserName: "Bob" }],
error: undefined,
mutate: vi.fn(),
})
.mockReturnValueOnce({
data: { MovieCount: 0, SeriesCount: 0, EpisodeCount: 0, SongCount: 0 },
error: undefined,
});
renderWithProviders(
<Component service={{ widget: { type: "emby", enableNowPlaying: true, enableBlocks: true } }} />,
{ settings: { hideErrors: false } },
);
expect(screen.getByText("emby.no_active")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,11 @@
import { describe, it } from "vitest";
import { expectWidgetConfigShape } from "test-utils/widget-config";
import widget from "./widget";
describe("emby widget config", () => {
it("exports a valid widget config", () => {
expectWidgetConfigShape(widget);
});
});

View File

@@ -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/esphome/component", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("defaults fields and filters placeholders to 4 blocks while loading", () => {
useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });
const service = { widget: { type: "esphome" } };
const { container } = renderWithProviders(<Component service={service} />, { settings: { hideErrors: false } });
expect(service.widget.fields).toEqual(["online", "offline", "offline_alt", "total"]);
expect(container.querySelectorAll(".service-block")).toHaveLength(4);
expect(screen.getByText("esphome.online")).toBeInTheDocument();
expect(screen.getByText("esphome.offline")).toBeInTheDocument();
expect(screen.getByText("esphome.offline_alt")).toBeInTheDocument();
expect(screen.getByText("esphome.total")).toBeInTheDocument();
expect(screen.queryByText("esphome.unknown")).toBeNull();
});
it("computes online/offline/unknown and filters to default fields", () => {
useWidgetAPI.mockReturnValue({
data: { a: true, b: false, c: null },
error: undefined,
});
const { container } = renderWithProviders(<Component service={{ widget: { type: "esphome" } }} />, {
settings: { hideErrors: false },
});
expect(container.querySelectorAll(".service-block")).toHaveLength(4);
expectBlockValue(container, "esphome.online", 1);
expectBlockValue(container, "esphome.offline", 1);
// offline_alt is count of not-true, i.e. false+null = 2
expectBlockValue(container, "esphome.offline_alt", 2);
expectBlockValue(container, "esphome.total", 3);
});
});

View File

@@ -0,0 +1,11 @@
import { describe, it } from "vitest";
import { expectWidgetConfigShape } from "test-utils/widget-config";
import widget from "./widget";
describe("esphome widget config", () => {
it("exports a valid widget config", () => {
expectWidgetConfigShape(widget);
});
});

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";
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/evcc/component", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders placeholders while loading", () => {
useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });
const { container } = renderWithProviders(<Component service={{ widget: { type: "evcc", url: "http://x" } }} />, {
settings: { hideErrors: false },
});
expect(container.querySelectorAll(".service-block")).toHaveLength(4);
expect(screen.getByText("evcc.pv_power")).toBeInTheDocument();
expect(screen.getByText("evcc.grid_power")).toBeInTheDocument();
expect(screen.getByText("evcc.home_power")).toBeInTheDocument();
expect(screen.getByText("evcc.charge_power")).toBeInTheDocument();
expect(screen.getAllByText("-")).toHaveLength(4);
});
it("renders error UI when widget API errors", () => {
useWidgetAPI.mockReturnValue({ data: undefined, error: { message: "nope" } });
renderWithProviders(<Component service={{ widget: { type: "evcc", url: "http://x" } }} />, {
settings: { hideErrors: false },
});
expect(screen.getAllByText(/widget\.api_error/i).length).toBeGreaterThan(0);
});
it("renders computed kilowatt values (including result wrapper, grid fallback, and loadpoint sum)", () => {
useWidgetAPI.mockReturnValue({
data: {
result: {
pvPower: 1000,
grid: { power: 2000 },
homePower: 3000,
loadpoints: [{ chargePower: 500 }, { chargePower: 1500 }],
},
},
error: undefined,
});
const { container } = renderWithProviders(<Component service={{ widget: { type: "evcc", url: "http://x" } }} />, {
settings: { hideErrors: false },
});
expectBlockValue(container, "evcc.pv_power", "1 evcc.kilowatt");
expectBlockValue(container, "evcc.grid_power", "2 evcc.kilowatt");
expectBlockValue(container, "evcc.home_power", "3 evcc.kilowatt");
expectBlockValue(container, "evcc.charge_power", "2 evcc.kilowatt");
});
});

View File

@@ -0,0 +1,11 @@
import { describe, it } from "vitest";
import { expectWidgetConfigShape } from "test-utils/widget-config";
import widget from "./widget";
describe("evcc widget config", () => {
it("exports a valid widget config", () => {
expectWidgetConfigShape(widget);
});
});

View File

@@ -0,0 +1,62 @@
// @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/filebrowser/component", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders placeholders while loading", () => {
useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });
const { container } = renderWithProviders(
<Component service={{ widget: { type: "filebrowser", url: "http://x" } }} />,
{ settings: { hideErrors: false } },
);
expect(container.querySelectorAll(".service-block")).toHaveLength(3);
expect(screen.getByText("filebrowser.available")).toBeInTheDocument();
expect(screen.getByText("filebrowser.used")).toBeInTheDocument();
expect(screen.getByText("filebrowser.total")).toBeInTheDocument();
expect(screen.getAllByText("-")).toHaveLength(3);
});
it("renders error UI when widget API errors", () => {
useWidgetAPI.mockReturnValue({ data: undefined, error: { message: "nope" } });
renderWithProviders(<Component service={{ widget: { type: "filebrowser", url: "http://x" } }} />, {
settings: { hideErrors: false },
});
expect(screen.getAllByText(/widget\.api_error/i).length).toBeGreaterThan(0);
});
it("renders computed available/used/total bytes", () => {
useWidgetAPI.mockReturnValue({ data: { total: 100, used: 40 }, error: undefined });
const { container } = renderWithProviders(
<Component service={{ widget: { type: "filebrowser", url: "http://x" } }} />,
{ settings: { hideErrors: false } },
);
expectBlockValue(container, "filebrowser.available", 60);
expectBlockValue(container, "filebrowser.used", 40);
expectBlockValue(container, "filebrowser.total", 100);
});
});

View File

@@ -0,0 +1,79 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import createMockRes from "test-utils/create-mock-res";
const { httpProxy, getServiceWidget, logger } = vi.hoisted(() => ({
httpProxy: vi.fn(),
getServiceWidget: vi.fn(),
logger: {
debug: vi.fn(),
error: vi.fn(),
},
}));
vi.mock("utils/logger", () => ({
default: () => logger,
}));
vi.mock("utils/config/service-helpers", () => ({
default: getServiceWidget,
}));
vi.mock("utils/proxy/http", () => ({
httpProxy,
}));
vi.mock("widgets/widgets", () => ({
default: {
filebrowser: {
api: "{url}/{endpoint}",
},
},
}));
import filebrowserProxyHandler from "./proxy";
describe("widgets/filebrowser/proxy", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("logs in and uses X-AUTH token for subsequent requests", async () => {
getServiceWidget.mockResolvedValue({
type: "filebrowser",
url: "http://fb",
username: "u",
password: "p",
authHeader: "X-User",
});
httpProxy
.mockResolvedValueOnce([200, "text/plain", "token123"])
.mockResolvedValueOnce([200, "application/json", Buffer.from("data")]);
const req = { query: { group: "g", service: "svc", endpoint: "api/raw", index: "0" } };
const res = createMockRes();
await filebrowserProxyHandler(req, res);
expect(httpProxy).toHaveBeenCalledTimes(2);
expect(httpProxy.mock.calls[0][0]).toBe("http://fb/login");
expect(httpProxy.mock.calls[0][1].headers).toEqual({ "X-User": "u" });
expect(httpProxy.mock.calls[1][1].headers["X-AUTH"]).toBe("token123");
expect(res.statusCode).toBe(200);
expect(res.body).toEqual(Buffer.from("data"));
});
it("returns 500 when login fails", async () => {
getServiceWidget.mockResolvedValue({ type: "filebrowser", url: "http://fb", username: "u", password: "p" });
httpProxy.mockResolvedValueOnce([401, "text/plain", "nope"]);
const req = { query: { group: "g", service: "svc", endpoint: "api/raw", index: "0" } };
const res = createMockRes();
await filebrowserProxyHandler(req, res);
expect(res.statusCode).toBe(500);
expect(res.body).toEqual({ error: "Failed to authenticate with Filebrowser" });
});
});

View File

@@ -0,0 +1,11 @@
import { describe, it } from "vitest";
import { expectWidgetConfigShape } from "test-utils/widget-config";
import widget from "./widget";
describe("filebrowser widget config", () => {
it("exports a valid widget config", () => {
expectWidgetConfigShape(widget);
});
});

View File

@@ -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/fileflows/component", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders placeholders while loading", () => {
useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });
const { container } = renderWithProviders(
<Component service={{ widget: { type: "fileflows", url: "http://x" } }} />,
{
settings: { hideErrors: false },
},
);
expect(container.querySelectorAll(".service-block")).toHaveLength(4);
expect(screen.getByText("fileflows.queue")).toBeInTheDocument();
expect(screen.getByText("fileflows.processing")).toBeInTheDocument();
expect(screen.getByText("fileflows.processed")).toBeInTheDocument();
expect(screen.getByText("fileflows.time")).toBeInTheDocument();
expect(screen.getAllByText("-")).toHaveLength(4);
});
it("renders error UI when widget API errors", () => {
useWidgetAPI.mockReturnValue({ data: undefined, error: { message: "nope" } });
renderWithProviders(<Component service={{ widget: { type: "fileflows", url: "http://x" } }} />, {
settings: { hideErrors: false },
});
expect(screen.getAllByText(/widget\.api_error/i).length).toBeGreaterThan(0);
});
it("renders values and falls back time to 0:00", () => {
useWidgetAPI.mockReturnValue({
data: { queue: 1, processing: 2, processed: 3, time: "" },
error: undefined,
});
const { container } = renderWithProviders(
<Component service={{ widget: { type: "fileflows", url: "http://x" } }} />,
{
settings: { hideErrors: false },
},
);
expectBlockValue(container, "fileflows.queue", 1);
expectBlockValue(container, "fileflows.processing", 2);
expectBlockValue(container, "fileflows.processed", 3);
expectBlockValue(container, "fileflows.time", "0:00");
});
});

View File

@@ -0,0 +1,11 @@
import { describe, it } from "vitest";
import { expectWidgetConfigShape } from "test-utils/widget-config";
import widget from "./widget";
describe("fileflows widget config", () => {
it("exports a valid widget config", () => {
expectWidgetConfigShape(widget);
});
});

View File

@@ -0,0 +1,74 @@
// @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/firefly/component", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders placeholders while loading", () => {
useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });
const { container } = renderWithProviders(<Component service={{ widget: { type: "firefly" } }} />, {
settings: { hideErrors: false },
});
expect(container.querySelectorAll(".service-block")).toHaveLength(2);
expect(screen.getByText("firefly.networth")).toBeInTheDocument();
expect(screen.getByText("firefly.budget")).toBeInTheDocument();
});
it("renders error UI when either request errors", () => {
useWidgetAPI
.mockReturnValueOnce({ data: undefined, error: { message: "nope" } }) // summary
.mockReturnValueOnce({ data: undefined, error: undefined }); // budgets
renderWithProviders(<Component service={{ widget: { type: "firefly" } }} />, { settings: { hideErrors: false } });
// The widget uses a string error, which Error normalizes to { message }.
expect(screen.getAllByText(/widget\.api_error/i).length).toBeGreaterThan(0);
expect(screen.getByText("Failed to load Firefly account summary and budgets")).toBeInTheDocument();
});
it("renders net worth and budget summary", () => {
useWidgetAPI
.mockReturnValueOnce({
data: { "net-worth-in-EUR": { value_parsed: "100" } },
error: undefined,
})
.mockReturnValueOnce({
data: {
data: [
{
type: "available_budgets",
attributes: {
amount: "100",
currency_symbol: "$",
spent_in_budgets: [{ sum: "-10" }],
},
},
],
},
error: undefined,
});
renderWithProviders(<Component service={{ widget: { type: "firefly" } }} />, { settings: { hideErrors: false } });
expect(screen.getByText("100")).toBeInTheDocument();
expect(screen.getByText("$ 10 / $ 100")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,11 @@
import { describe, it } from "vitest";
import { expectWidgetConfigShape } from "test-utils/widget-config";
import widget from "./widget";
describe("firefly widget config", () => {
it("exports a valid widget config", () => {
expectWidgetConfigShape(widget);
});
});

View File

@@ -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";
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/flood/component", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders error UI when widget API errors", () => {
useWidgetAPI.mockReturnValue({ data: undefined, error: { message: "nope" } });
renderWithProviders(<Component service={{ widget: { type: "flood", url: "http://x" } }} />, {
settings: { hideErrors: false },
});
expect(screen.getAllByText(/widget\.api_error/i).length).toBeGreaterThan(0);
expect(screen.getByText("nope")).toBeInTheDocument();
});
it("renders a helpful error when the API returns no torrent data", () => {
useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });
renderWithProviders(<Component service={{ widget: { type: "flood", url: "http://x" } }} />, {
settings: { hideErrors: false },
});
expect(screen.getAllByText(/widget\.api_error/i).length).toBeGreaterThan(0);
expect(screen.getByText("No torrent data returned")).toBeInTheDocument();
});
it("renders computed leech/seed counts and up/down rates", () => {
useWidgetAPI.mockReturnValue({
data: {
torrents: {
a: { downRate: 10, upRate: 20, status: ["downloading"] },
b: { downRate: 5, upRate: 0, status: ["complete"] },
c: { downRate: 0, upRate: 1, status: ["complete", "downloading"] },
},
},
error: undefined,
});
const { container } = renderWithProviders(<Component service={{ widget: { type: "flood", url: "http://x" } }} />, {
settings: { hideErrors: false },
});
expectBlockValue(container, "flood.leech", 2);
expectBlockValue(container, "flood.download", 15);
expectBlockValue(container, "flood.seed", 2);
expectBlockValue(container, "flood.upload", 21);
});
});

View File

@@ -0,0 +1,69 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import createMockRes from "test-utils/create-mock-res";
const { httpProxy, getServiceWidget, logger } = vi.hoisted(() => ({
httpProxy: vi.fn(),
getServiceWidget: vi.fn(),
logger: {
debug: vi.fn(),
error: vi.fn(),
},
}));
vi.mock("utils/logger", () => ({
default: () => logger,
}));
vi.mock("utils/config/service-helpers", () => ({
default: getServiceWidget,
}));
vi.mock("utils/proxy/http", () => ({
httpProxy,
}));
import floodProxyHandler from "./proxy";
describe("widgets/flood/proxy", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("logs in and retries after a 401 response", async () => {
getServiceWidget.mockResolvedValue({ url: "http://flood" });
httpProxy
.mockResolvedValueOnce([401, "application/json", Buffer.from("nope")])
.mockResolvedValueOnce([200, "application/json", Buffer.from("ok")])
.mockResolvedValueOnce([200, "application/json", Buffer.from("data")]);
const req = { query: { group: "g", service: "svc", endpoint: "stats", index: "0" } };
const res = createMockRes();
await floodProxyHandler(req, res);
expect(httpProxy).toHaveBeenCalledTimes(3);
expect(httpProxy.mock.calls[0][0].toString()).toBe("http://flood/api/stats");
expect(httpProxy.mock.calls[1][0]).toBe("http://flood/api/auth/authenticate");
expect(httpProxy.mock.calls[1][1].body).toBeNull();
expect(httpProxy.mock.calls[2][0].toString()).toBe("http://flood/api/stats");
expect(res.statusCode).toBe(200);
expect(res.body).toEqual(Buffer.from("data"));
});
it("returns the login error status when authentication fails", async () => {
getServiceWidget.mockResolvedValue({ url: "http://flood", username: "u", password: "p" });
httpProxy
.mockResolvedValueOnce([401, "application/json", Buffer.from("nope")])
.mockResolvedValueOnce([500, "application/json", Buffer.from("bad")]);
const req = { query: { group: "g", service: "svc", endpoint: "stats", index: "0" } };
const res = createMockRes();
await floodProxyHandler(req, res);
expect(httpProxy.mock.calls[1][1].body).toBe(JSON.stringify({ username: "u", password: "p" }));
expect(res.statusCode).toBe(500);
expect(res.end).toHaveBeenCalledWith(Buffer.from("bad"));
});
});

View File

@@ -0,0 +1,11 @@
import { describe, it } from "vitest";
import { expectWidgetConfigShape } from "test-utils/widget-config";
import widget from "./widget";
describe("flood widget config", () => {
it("exports a valid widget config", () => {
expectWidgetConfigShape(widget);
});
});

View File

@@ -0,0 +1,64 @@
// @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/freshrss/component", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders placeholders while loading", () => {
useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });
const { container } = renderWithProviders(
<Component service={{ widget: { type: "freshrss", url: "http://x" } }} />,
{
settings: { hideErrors: false },
},
);
expect(container.querySelectorAll(".service-block")).toHaveLength(2);
expect(screen.getByText("freshrss.unread")).toBeInTheDocument();
expect(screen.getByText("freshrss.subscriptions")).toBeInTheDocument();
expect(screen.getAllByText("-")).toHaveLength(2);
});
it("renders error UI when widget API errors", () => {
useWidgetAPI.mockReturnValue({ data: undefined, error: { message: "nope" } });
renderWithProviders(<Component service={{ widget: { type: "freshrss", url: "http://x" } }} />, {
settings: { hideErrors: false },
});
expect(screen.getAllByText(/widget\.api_error/i).length).toBeGreaterThan(0);
});
it("renders unread and subscription counts", () => {
useWidgetAPI.mockReturnValue({ data: { unread: 7, subscriptions: 3 }, error: undefined });
const { container } = renderWithProviders(
<Component service={{ widget: { type: "freshrss", url: "http://x" } }} />,
{
settings: { hideErrors: false },
},
);
expectBlockValue(container, "freshrss.unread", 7);
expectBlockValue(container, "freshrss.subscriptions", 3);
});
});

View File

@@ -0,0 +1,112 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import createMockRes from "test-utils/create-mock-res";
const { httpProxy, getServiceWidget, cache, logger } = vi.hoisted(() => {
const store = new Map();
return {
httpProxy: vi.fn(),
getServiceWidget: vi.fn(),
cache: {
get: vi.fn((k) => store.get(k)),
put: vi.fn((k, v) => store.set(k, v)),
del: vi.fn((k) => store.delete(k)),
_reset: () => store.clear(),
},
logger: {
debug: vi.fn(),
error: vi.fn(),
},
};
});
vi.mock("utils/logger", () => ({
default: () => logger,
}));
vi.mock("utils/config/service-helpers", () => ({
default: getServiceWidget,
}));
vi.mock("utils/proxy/http", () => ({
httpProxy,
}));
vi.mock("memory-cache", () => ({
default: cache,
...cache,
}));
vi.mock("widgets/widgets", () => ({
default: {
freshrss: {
api: "{url}/{endpoint}",
},
},
}));
import freshrssProxyHandler from "./proxy";
describe("widgets/freshrss/proxy", () => {
beforeEach(() => {
vi.clearAllMocks();
cache._reset();
});
it("logs in, caches token, and returns subscription + unread counts", async () => {
getServiceWidget.mockResolvedValue({
type: "freshrss",
url: "http://fresh",
username: "u",
password: "p",
});
httpProxy
.mockResolvedValueOnce([200, "text/plain", Buffer.from("SID=1\nAuth=token123\n")])
.mockResolvedValueOnce([200, "application/json", Buffer.from(JSON.stringify({ subscriptions: [1, 2, 3] }))])
.mockResolvedValueOnce([200, "application/json", Buffer.from(JSON.stringify({ max: 7 }))]);
const req = { query: { group: "g", service: "svc", index: "0" } };
const res = createMockRes();
await freshrssProxyHandler(req, res);
expect(httpProxy).toHaveBeenCalledTimes(3);
expect(httpProxy.mock.calls[0][0].toString()).toBe("http://fresh/accounts/ClientLogin");
expect(httpProxy.mock.calls[1][1].headers.Authorization).toBe("GoogleLogin auth=token123");
expect(httpProxy.mock.calls[2][1].headers.Authorization).toBe("GoogleLogin auth=token123");
expect(res.status).toHaveBeenCalledWith(200);
expect(res.send).toHaveBeenCalledWith({ subscriptions: 3, unread: 7 });
});
it("retries API calls after a 401 by obtaining a new session token", async () => {
cache.put("freshrssProxyHandler__sessionToken.svc", "old");
getServiceWidget.mockResolvedValue({
type: "freshrss",
url: "http://fresh",
username: "u",
password: "p",
});
httpProxy
.mockResolvedValueOnce([401, "application/json", Buffer.from("{}")])
.mockResolvedValueOnce([200, "text/plain", Buffer.from("SID=1\nAuth=newtok\n")])
.mockResolvedValueOnce([200, "application/json", Buffer.from(JSON.stringify({ subscriptions: [1] }))])
.mockResolvedValueOnce([200, "application/json", Buffer.from(JSON.stringify({ max: 2 }))]);
const req = { query: { group: "g", service: "svc", index: "0" } };
const res = createMockRes();
await freshrssProxyHandler(req, res);
expect(httpProxy).toHaveBeenCalledTimes(4);
const loginCalls = httpProxy.mock.calls.filter(([url]) => url.toString().includes("accounts/ClientLogin"));
expect(loginCalls).toHaveLength(1);
const listCalls = httpProxy.mock.calls.filter(([url]) => url.toString().includes("subscription/list"));
expect(listCalls).toHaveLength(2);
expect(res.body).toEqual({ subscriptions: 1, unread: 2 });
});
});

View File

@@ -0,0 +1,11 @@
import { describe, it } from "vitest";
import { expectWidgetConfigShape } from "test-utils/widget-config";
import widget from "./widget";
describe("freshrss widget config", () => {
it("exports a valid widget config", () => {
expectWidgetConfigShape(widget);
});
});

View File

@@ -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/frigate/component", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders placeholders while loading", () => {
useWidgetAPI
.mockReturnValueOnce({ data: undefined, error: undefined }) // stats
.mockReturnValueOnce({ data: undefined, error: undefined }); // events
const { container } = renderWithProviders(
<Component service={{ widget: { type: "frigate", url: "http://x" } }} />,
{
settings: { hideErrors: false },
},
);
expect(container.querySelectorAll(".service-block")).toHaveLength(3);
expect(screen.getByText("frigate.cameras")).toBeInTheDocument();
expect(screen.getByText("frigate.uptime")).toBeInTheDocument();
expect(screen.getByText("frigate.version")).toBeInTheDocument();
expect(screen.getAllByText("-")).toHaveLength(3);
});
it("renders error UI when either endpoint errors", () => {
useWidgetAPI
.mockReturnValueOnce({ data: undefined, error: { message: "nope" } })
.mockReturnValueOnce({ data: undefined, error: undefined });
renderWithProviders(<Component service={{ widget: { type: "frigate", url: "http://x" } }} />, {
settings: { hideErrors: false },
});
expect(screen.getAllByText(/widget\.api_error/i).length).toBeGreaterThan(0);
expect(screen.getByText("nope")).toBeInTheDocument();
});
it("renders stats and recent events when enabled", () => {
useWidgetAPI
.mockReturnValueOnce({
data: { num_cameras: 2, uptime: 3600, version: "1.0.0" },
error: undefined,
})
.mockReturnValueOnce({
data: [{ id: "e1", camera: "Cam1", label: "Person", score: 0.5, start_time: 123 }],
error: undefined,
});
const { container } = renderWithProviders(
<Component service={{ widget: { type: "frigate", url: "http://x", enableRecentEvents: true } }} />,
{ settings: { hideErrors: false } },
);
expectBlockValue(container, "frigate.cameras", 2);
expectBlockValue(container, "frigate.uptime", 3600);
expectBlockValue(container, "frigate.version", "1.0.0");
// The event text is composed of multiple text nodes; match on the element's full textContent.
expect(
screen.getByText((_, el) => el?.classList?.contains("absolute") && el.textContent?.includes("Cam1 (Person 50)")),
).toBeInTheDocument();
expect(screen.getByText("123")).toBeInTheDocument();
});
});

Some files were not shown because too many files have changed in this diff Show More