154 lines
6.1 KiB
TypeScript
154 lines
6.1 KiB
TypeScript
import type { QueryKey } from "@tanstack/react-query";
|
|
import { describe, expect, it } from "vitest";
|
|
|
|
import { widgetIntegrationSupport, widgetKinds as definedWidgetKinds } from "@homarr/definitions";
|
|
|
|
import {
|
|
createRetryableLoader,
|
|
loadAllWidgetDefinitions,
|
|
loadWidgetComponent,
|
|
loadWidgetDefinition,
|
|
loadWidgetModule,
|
|
loadWidgetResources,
|
|
reduceWidgetOptionsWithDefinition,
|
|
widgetKinds,
|
|
} from "./manifest";
|
|
import { widgetQueryRefetchIntervals } from "./refetch-intervals";
|
|
import { widgetCatalogIcons } from "./catalog";
|
|
import { getWidgetQueryKeys } from "./definition";
|
|
|
|
describe("widget manifest promise stability", () => {
|
|
it("returns the same module promise for every render", () => {
|
|
expect(loadWidgetModule("clock")).toBe(loadWidgetModule("clock"));
|
|
});
|
|
|
|
it("returns the same derived definition promise for every render", () => {
|
|
expect(loadWidgetDefinition("clock")).toBe(loadWidgetDefinition("clock"));
|
|
});
|
|
|
|
it("returns stable component and combined resource promises", () => {
|
|
expect(loadWidgetComponent("clock")).toBe(loadWidgetComponent("clock"));
|
|
expect(loadWidgetResources("clock")).toBe(loadWidgetResources("clock"));
|
|
});
|
|
|
|
it("returns the same all-definitions promise for every modal render", () => {
|
|
expect(loadAllWidgetDefinitions()).toBe(loadAllWidgetDefinitions());
|
|
});
|
|
|
|
it("retries a failed loader instead of retaining its rejected promise", async () => {
|
|
let attempts = 0;
|
|
const loader = createRetryableLoader(
|
|
new Map([
|
|
[
|
|
"example",
|
|
() => {
|
|
attempts += 1;
|
|
if (attempts === 1) return Promise.reject(new Error("temporary failure"));
|
|
return Promise.resolve("loaded");
|
|
},
|
|
],
|
|
]),
|
|
);
|
|
|
|
await expect(loader("example")).rejects.toThrow("temporary failure");
|
|
await expect(loader("example")).resolves.toBe("loaded");
|
|
expect(attempts).toBe(2);
|
|
});
|
|
|
|
it("covers every declared widget kind", () => {
|
|
expect(new Set(widgetKinds)).toEqual(new Set(definedWidgetKinds));
|
|
});
|
|
|
|
it("loads matching definitions and component loaders for every widget", async () => {
|
|
const definitions = await loadAllWidgetDefinitions();
|
|
|
|
await Promise.all(
|
|
widgetKinds.map(async (kind) => {
|
|
const module = await loadWidgetModule(kind);
|
|
const canonicalComponent = await module.componentLoader();
|
|
const component = await loadWidgetComponent(kind);
|
|
const resources = await loadWidgetResources(kind);
|
|
expect(definitions.get(kind)).toBe(module.definition);
|
|
expect(component.default).toBe(canonicalComponent.default);
|
|
expect(component.default).toBeDefined();
|
|
expect(resources.definition).toBe(module.definition);
|
|
expect(resources.Component).toBe(component.default);
|
|
}),
|
|
);
|
|
}, 30_000);
|
|
|
|
it("preserves every widget option default", async () => {
|
|
const definitions = await loadAllWidgetDefinitions();
|
|
const settings = { enableStatusByDefault: true, forceDisableStatus: false };
|
|
|
|
for (const definition of definitions.values()) {
|
|
const options = definition.createOptions(settings);
|
|
const reduced = reduceWidgetOptionsWithDefinition(definition, settings);
|
|
expect(Object.keys(reduced)).toEqual(Object.keys(options));
|
|
expect(Object.values(reduced).every((value) => value !== undefined)).toBe(true);
|
|
}
|
|
});
|
|
|
|
it("keeps the lightweight integration support map aligned with widget definitions", async () => {
|
|
const definitions = await loadAllWidgetDefinitions();
|
|
|
|
for (const kind of widgetKinds) {
|
|
const definition = definitions.get(kind);
|
|
const supportedIntegrations =
|
|
definition && "supportedIntegrations" in definition ? (definition.supportedIntegrations ?? []) : [];
|
|
expect([...(widgetIntegrationSupport[kind] ?? [])].toSorted()).toEqual([...supportedIntegrations].toSorted());
|
|
}
|
|
});
|
|
|
|
it("keeps the lightweight catalog icons aligned with widget definitions", async () => {
|
|
const definitions = await loadAllWidgetDefinitions();
|
|
|
|
for (const kind of widgetKinds) {
|
|
expect(widgetCatalogIcons[kind]).toBe(definitions.get(kind)?.icon);
|
|
}
|
|
});
|
|
|
|
it("declares the real query prefix for widget kinds that share a router", async () => {
|
|
const definitions = await loadAllWidgetDefinitions();
|
|
const expectedQueryKeys = new Map([
|
|
["anchorNote", [["widget", "anchorNotes"]]],
|
|
["beszelAlerts", [["widget", "beszel", "getAlerts"]]],
|
|
["clock", [["widget", "weather", "atLocation"]]],
|
|
["mediaMissing", [["widget", "mediaOrganizer", "getData"]]],
|
|
["mediaRequests-requestList", [["widget", "mediaRequests", "getLatestRequests"]]],
|
|
["mediaRequests-requestStats", [["widget", "mediaRequests", "getStats"]]],
|
|
["smartHome-entityState", [["widget", "smartHome"]]],
|
|
] as const);
|
|
|
|
for (const [kind, queryKey] of expectedQueryKeys) {
|
|
expect(definitions.get(kind)?.queryKey).toEqual(queryKey);
|
|
}
|
|
});
|
|
|
|
it("keeps the client polling policy aligned with widget definitions", async () => {
|
|
const definitions = await loadAllWidgetDefinitions();
|
|
type PollingPolicy = { queryKey: QueryKey; intervalSeconds: number | null };
|
|
const expectedByQueryKey = new Map<string, PollingPolicy>();
|
|
|
|
for (const kind of widgetKinds) {
|
|
const definition = definitions.get(kind);
|
|
if (definition?.refetchInterval === undefined) continue;
|
|
|
|
for (const queryKey of getWidgetQueryKeys(definition, kind)) {
|
|
const path = queryKey[0];
|
|
if (!Array.isArray(path) || (path[0] !== "widget" && path[0] !== "docker")) continue;
|
|
const entry = { queryKey, intervalSeconds: definition.refetchInterval } as const;
|
|
const serializedQueryKey = JSON.stringify(entry.queryKey);
|
|
const existing = expectedByQueryKey.get(serializedQueryKey);
|
|
expect(existing?.intervalSeconds ?? entry.intervalSeconds).toBe(entry.intervalSeconds);
|
|
expectedByQueryKey.set(serializedQueryKey, entry);
|
|
}
|
|
}
|
|
|
|
const serializePolicy = (entry: PollingPolicy) => JSON.stringify(entry);
|
|
expect(widgetQueryRefetchIntervals.map(serializePolicy).toSorted()).toEqual(
|
|
[...expectedByQueryKey.values()].map(serializePolicy).toSorted(),
|
|
);
|
|
});
|
|
});
|