feat(custom-widgets): complete Custom JSX v2 (#6390)

This commit is contained in:
Thomas authored and ajnart committed 2026-08-13 19:48:57 +02:00
1 parent 3a1245cbc5
commit acdfcbb18e
416 files changed
+37853 -18066

No files matched your search

@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import {
claimCustomWidgetConfigurationRequest,
completeCustomWidgetConfigurationRequest,
createCustomWidgetConfigurationRequest,
getCustomWidgetConfigurationRequestForUser,
releaseCustomWidgetConfigurationRequest,
} from "../../custom-widget/configuration-requests";
describe("custom widget source configuration requests", () => {
it("is user-scoped, one-time, and reports completion without returning configuration", async () => {
const request = await createCustomWidgetConfigurationRequest({
userId: "user-1",
target: { type: "preview", id: "preview-1" },
widgetName: "Printer",
sourceId: "default",
sourceName: "Printer API",
source: { baseUrl: "http://printer.local", networkScope: "private", auth: "bearer" },
kinds: ["apiKey"],
});
expect(await getCustomWidgetConfigurationRequestForUser(request.id, "user-2")).toBeNull();
expect(await claimCustomWidgetConfigurationRequest(request.id)).toMatchObject({ status: "pending" });
expect(await claimCustomWidgetConfigurationRequest(request.id)).toBeNull();
await releaseCustomWidgetConfigurationRequest(request.id);
expect(await claimCustomWidgetConfigurationRequest(request.id)).not.toBeNull();
await completeCustomWidgetConfigurationRequest(request.id);
expect(await getCustomWidgetConfigurationRequestForUser(request.id, "user-1")).toMatchObject({
status: "completed",
});
expect(await claimCustomWidgetConfigurationRequest(request.id)).toBeNull();
});
});
@@ -0,0 +1,158 @@
import type { Session } from "@homarr/auth";
import { createId } from "@homarr/common";
import { eq, sql } from "@homarr/db";
import { customWidgetDefinitions, customWidgetSecrets, users } from "@homarr/db/schema";
import { createDb } from "@homarr/db/test";
import { BUNDLED_CUSTOM_WIDGETS } from "@homarr/custom-widgets/core";
import { describe, expect, test } from "vitest";
import { customWidgetRouter } from "../../custom-widget/custom-widget-router";
import { getCustomWidgetConfigurationRequestForUser } from "../../custom-widget/configuration-requests";
const userId = createId();
const session = {
user: {
id: userId,
permissions: ["custom-widget-manage", "custom-widget-secret-write"],
colorScheme: "light",
},
expires: new Date().toISOString(),
} satisfies Session;
const jellyfin = BUNDLED_CUSTOM_WIDGETS.find(({ id }) => id === "seed-jellyfin")?.widget;
if (!jellyfin) throw new Error("Jellyfin bundled widget is missing");
function createCaller(db: ReturnType<typeof createDb>) {
return customWidgetRouter.createCaller({ db, deviceType: undefined, session });
}
async function prepareDatabase() {
const db = createDb();
await db.insert(users).values({ id: userId });
return db;
}
function rejectSecretInserts(db: ReturnType<typeof createDb>) {
db.run(
sql.raw(`CREATE TRIGGER reject_custom_widget_secret_insert
BEFORE INSERT ON custom_widget_secret
BEGIN
SELECT RAISE(ABORT, 'forced secret insert failure');
END`),
);
}
const secret = { sourceId: "default", kind: "apiKey" as const, value: "test-secret" };
describe("custom widget definition persistence", () => {
test("rolls back create when its secret cannot be stored", async () => {
const db = await prepareDatabase();
const caller = createCaller(db);
rejectSecretInserts(db);
await expect(caller.create({ ...jellyfin, secrets: [secret] })).rejects.toThrow("forced secret insert failure");
expect(await db.query.customWidgetDefinitions.findMany()).toHaveLength(0);
expect(await db.query.customWidgetSecrets.findMany()).toHaveLength(0);
});
test("rolls back import when its secret cannot be stored", async () => {
const db = await prepareDatabase();
const caller = createCaller(db);
rejectSecretInserts(db);
await expect(caller.import({ widget: jellyfin, secrets: [secret] })).rejects.toThrow(
"forced secret insert failure",
);
expect(await db.query.customWidgetDefinitions.findMany()).toHaveLength(0);
expect(await db.query.customWidgetSecrets.findMany()).toHaveLength(0);
});
test("rolls back definition and secret changes when a replacement secret cannot be stored", async () => {
const db = await prepareDatabase();
const caller = createCaller(db);
const created = await caller.create({ ...jellyfin, secrets: [secret] });
const originalDefinition = await db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, created.id),
});
const originalSecret = await db.query.customWidgetSecrets.findFirst({
where: eq(customWidgetSecrets.definitionId, created.id),
});
rejectSecretInserts(db);
await expect(
caller.update({
id: created.id,
name: "Changed Jellyfin Library",
secrets: [{ ...secret, value: "replacement-secret" }],
}),
).rejects.toThrow("forced secret insert failure");
const storedDefinition = await db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, created.id),
});
const storedSecret = await db.query.customWidgetSecrets.findFirst({
where: eq(customWidgetSecrets.definitionId, created.id),
});
expect(storedDefinition?.name).toBe(originalDefinition?.name);
expect(storedDefinition?.updatedAt).toEqual(originalDefinition?.updatedAt);
expect(storedSecret).toEqual(originalSecret);
});
test("configures a source URL and credential together", async () => {
const db = await prepareDatabase();
const caller = createCaller(db);
const created = await caller.create({ ...jellyfin, secrets: [] });
await caller.sourceConfigure({
definitionId: created.id,
sourceId: "default",
baseUrl: "http://jellyfin.local:8096",
networkScope: "private",
secrets: [secret],
});
const definition = await caller.get({ id: created.id });
expect(definition.sources.default?.baseUrl).toBe("http://jellyfin.local:8096");
expect(definition.secrets).toMatchObject([{ sourceId: "default", kind: "apiKey", hasValue: true }]);
});
test("rolls back a source URL when its credential cannot be stored", async () => {
const db = await prepareDatabase();
const caller = createCaller(db);
const created = await caller.create({ ...jellyfin, secrets: [] });
const before = await caller.get({ id: created.id });
rejectSecretInserts(db);
await expect(
caller.sourceConfigure({
definitionId: created.id,
sourceId: "default",
baseUrl: "http://jellyfin.local:8096",
networkScope: "private",
secrets: [secret],
}),
).rejects.toThrow("forced secret insert failure");
expect((await caller.get({ id: created.id })).sources.default?.baseUrl).toBe(before.sources.default?.baseUrl);
});
test("uses the preview widget name in a user configuration request", async () => {
const db = await prepareDatabase();
const caller = createCaller(db);
const preview = await caller.previewCreate({
definition: { ...jellyfin, name: "Living room Jellyfin" },
secrets: [],
});
const request = await caller.configurationRequestUser({
previewSessionId: preview.previewSession.id,
sourceId: "default",
});
expect(await getCustomWidgetConfigurationRequestForUser(request.requestId, userId)).toMatchObject({
widgetName: "Living room Jellyfin",
});
});
});
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import type { CustomJsxRequest } from "@homarr/custom-widgets/core";
import { resolvePreviewRequestParams } from "../../custom-widget/preview-procedure-helpers";
const loadRequest: CustomJsxRequest = {
source: "default",
kind: "query",
method: "GET",
path: "/endpoints/{option:environmentId}/containers",
auth: "inherit",
permission: "view",
trigger: "load",
};
describe("preview request option bindings", () => {
it("resolves load request parameters from the selected preview options", () => {
expect(resolvePreviewRequestParams(loadRequest, { environmentId: 7 }, {})).toEqual({
"option:environmentId": 7,
});
});
it("preserves invoking component parameters for manual requests", () => {
const manualRequest = {
...loadRequest,
trigger: "manual" as const,
path: "/endpoints/{param:endpointId}/containers",
};
expect(resolvePreviewRequestParams(manualRequest, { environmentId: 7 }, { endpointId: 12 })).toEqual({
endpointId: 12,
});
});
});
@@ -1,67 +0,0 @@
import { describe, expect, test } from "vitest";
import {
appendPreviewJournal,
createPreviewSession,
getPreviewJournal,
getPreviewSession,
getPreviewSessionSecrets,
setPreviewSessionLiveActions,
} from "../../custom-widget/preview-sessions";
const queryRequest = {
id: "status",
kind: "query",
method: "GET",
pathTemplate: "/status/{id}",
parameters: { id: "string" },
auth: "inherit",
minimumBoardPermission: "view",
} as const;
describe("custom widget preview sessions", () => {
test("stores encrypted credentials in a short-lived user-bound manifest", async () => {
const created = await createPreviewSession({
userId: "admin-1",
baseUrl: "https://example.com/api",
authType: "bearer",
secrets: [{ kind: "apiKey", value: "plain-secret" }],
networkScope: "public",
requests: [queryRequest],
});
const session = await getPreviewSession(created.id, "admin-1");
expect(session.secrets[0]?.value).not.toContain("plain-secret");
expect(getPreviewSessionSecrets(session)).toEqual([{ kind: "apiKey", value: "plain-secret" }]);
await expect(getPreviewSession(created.id, "admin-2")).rejects.toMatchObject({ code: "NOT_FOUND" });
});
test("keeps actions simulated until explicitly enabled", async () => {
const created = await createPreviewSession({
userId: "admin-1",
baseUrl: "https://example.com/api",
authType: "none",
secrets: [],
networkScope: "public",
requests: [queryRequest],
});
expect((await getPreviewSession(created.id, "admin-1")).liveActions).toBe(false);
await setPreviewSessionLiveActions(created.id, "admin-1", true);
const session = await getPreviewSession(created.id, "admin-1");
expect(session.liveActions).toBe(true);
await appendPreviewJournal(session, {
requestId: "status",
kind: "query",
method: "GET",
pathTemplate: "/status/{id}",
status: 200,
durationMs: 12,
simulated: false,
});
expect(await getPreviewJournal(created.id, "admin-1")).toEqual([
expect.objectContaining({ requestId: "status", pathTemplate: "/status/{id}", durationMs: 12 }),
]);
});
});
@@ -0,0 +1,48 @@
import { stringify as stringifySuperJson } from "superjson";
import { describe, expect, test } from "vitest";
import {
BUNDLED_CUSTOM_WIDGETS,
CUSTOM_WIDGET_STARTER,
customWidgetDefinitionSchema,
} from "@homarr/custom-widgets/core";
import {
safeParseStoredCustomWidgetDefinition,
serializeCustomWidgetDefinition,
} from "../../custom-widget/stored-definition";
describe("stored custom widget definitions", () => {
test("parses a valid stored definition", () => {
const result = safeParseStoredCustomWidgetDefinition(
serializeCustomWidgetDefinition(customWidgetDefinitionSchema.parse(CUSTOM_WIDGET_STARTER)),
);
expect(result).toMatchObject({ success: true, widget: { $schema: "homarr-custom-widget-v2" } });
});
test("returns useful issues for an invalid stored request instead of throwing", () => {
const currency = BUNDLED_CUSTOM_WIDGETS.find(({ id }) => id === "seed-currency-exchange");
if (!currency) throw new Error("Currency Exchange bundled widget is missing");
const stored = serializeCustomWidgetDefinition(customWidgetDefinitionSchema.parse(currency.widget));
const result = safeParseStoredCustomWidgetDefinition({
...stored,
requests: stringifySuperJson({ rates: { path: "/latest/{option:missing}" } }),
});
expect(result).toEqual({
success: false,
issues: expect.arrayContaining([expect.objectContaining({ path: "requests.rates" })]),
});
});
test("contains malformed serialized data", () => {
const stored = serializeCustomWidgetDefinition(customWidgetDefinitionSchema.parse(CUSTOM_WIDGET_STARTER));
expect(safeParseStoredCustomWidgetDefinition({ ...stored, requests: "not-json" })).toEqual({
success: false,
issues: [{ message: "Stored widget data could not be read" }],
});
});
});