fix: harden assistant authoring and UI

This commit is contained in:
ajnart authored and Thomas Camlong committed 2026-08-09 18:09:11 +02:00
1 parent fa0e55cc93
commit edc7b4d55b
51 files changed
+4118 -916

No files matched your search

@@ -0,0 +1,22 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
/**
* Authoring inputs that use templateLines can only validate the joined JSX after the tRPC input
* parser has run. Preserve those Zod issues as a client-safe BAD_REQUEST instead of letting tRPC
* turn them into a generic internal error.
*/
export function parseCustomWidgetAuthoringInput<T>(parse: () => T): T {
try {
return parse();
} catch (error) {
if (error instanceof z.ZodError) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "The custom widget authoring input is invalid",
cause: error,
});
}
throw error;
}
}
@@ -10,9 +10,12 @@ import {
customWidgetCreateSchema,
customWidgetDefinitionSchema,
customWidgetUpdateSchema,
normalizeCustomWidgetAuthoringDefinition,
normalizeCustomWidgetAuthoringUpdate,
} from "@homarr/custom-widgets/core";
import { createTRPCRouter, permissionRequiredProcedure } from "../../trpc";
import { parseCustomWidgetAuthoringInput } from "./authoring-validation";
import { insertCustomWidgetDefinition } from "./definition-insert";
import { managementQueryProcedures } from "./management-queries";
import { metadataProcedures } from "./metadata-procedures";
@@ -38,20 +41,43 @@ export const customWidgetRouter = createTRPCRouter({
create: permissionRequiredProcedure
.requiresPermission("admin")
.meta({ mcp: { enabled: true, description: "Create one validated Custom JSX widget." } })
.meta({
mcp: {
enabled: true,
description:
"Create a Custom JSX widget only after customWidget_validate, customWidget_previewCreate, and every returned preview query succeed. Prefer templateLines for multiline JSX. Returns a client-navigable edit link.",
},
})
.input(customWidgetCreateSchema)
.mutation(async ({ ctx, input }) => {
const { secrets, ...candidate } = input;
const definition = customWidgetDefinitionSchema.parse(candidate);
const definition = parseCustomWidgetAuthoringInput(() => normalizeCustomWidgetAuthoringDefinition(candidate));
assertSecretSources(definition.sources, secrets);
const id = await insertCustomWidgetDefinition(ctx.db, definition, ctx.session.user.id, secrets);
logger.info("Created custom widget definition", { id, name: definition.name });
return { id };
return {
id,
managementPath: `/manage/custom-widgets/edit/${id}`,
nextAction: {
type: "place-custom-widget" as const,
widgetKind: "customApi" as const,
options: { definitionId: id },
whenTargetIsKnown: "Call configure_widget now with the requested board and these exact widget options.",
whenTargetIsUnknown:
"Call ask_user now with 'Place on a board' and 'Leave unplaced'. Never ask this choice in prose.",
},
};
}),
update: permissionRequiredProcedure
.requiresPermission("admin")
.meta({ mcp: { enabled: true, description: "Update one Custom JSX widget." } })
.meta({
mcp: {
enabled: true,
description:
"Update one Custom JSX widget. Prefer templateLines for multiline JSX changes. Returns a client-navigable edit link.",
},
})
.input(customWidgetUpdateSchema)
.mutation(async ({ ctx, input }) => {
const existing = await ctx.db.query.customWidgetDefinitions.findFirst({
@@ -60,8 +86,11 @@ export const customWidgetRouter = createTRPCRouter({
if (!existing) throw new TRPCError({ code: "NOT_FOUND" });
const current = parseStoredCustomWidgetDefinition(existing);
const { id, secrets, ...changes } = input;
const definition = customWidgetDefinitionSchema.parse({ ...current, ...changes });
const { id, secrets, ...authoringChanges } = input;
const definition = parseCustomWidgetAuthoringInput(() => {
const changes = normalizeCustomWidgetAuthoringUpdate(authoringChanges);
return customWidgetDefinitionSchema.parse({ ...current, ...changes });
});
if (secrets) assertSecretSources(definition.sources, secrets);
const definitionChanges = { ...serializeCustomWidgetDefinition(definition), updatedAt: new Date() };
const secretRows = secrets?.map((secret) => ({
@@ -178,6 +207,7 @@ export const customWidgetRouter = createTRPCRouter({
},
});
logger.info("Updated custom widget definition", { id });
return { id, managementPath: `/manage/custom-widgets/edit/${id}` };
}),
...secretProcedures,
@@ -65,6 +65,7 @@ export const managementQueryProcedures = {
if (!definition) throw new TRPCError({ code: "NOT_FOUND", message: "Legacy custom widget not found" });
return {
id: definition.id,
managementPath: "/manage/custom-widgets",
version: 1 as const,
prompt: buildLegacyCustomWidgetMigrationPrompt(
definition,
@@ -85,6 +86,7 @@ export const managementQueryProcedures = {
if (!definition) throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget definition not found" });
return {
id: definition.id,
managementPath: `/manage/custom-widgets/edit/${definition.id}`,
...parseStoredCustomWidgetDefinition(definition),
enabled: definition.enabled,
createdAt: definition.createdAt,
@@ -26,6 +26,7 @@ export function mapCustomWidgetListItem(definition: StoredDefinition & { secrets
});
return {
id: definition.id,
managementPath: `/manage/custom-widgets/edit/${definition.id}`,
name: definition.name,
description: definition.description ?? undefined,
iconUrl: definition.iconUrl ?? undefined,
@@ -45,6 +46,7 @@ export function mapCustomWidgetListItem(definition: StoredDefinition & { secrets
const configuredSecrets = new Set(definition.secrets.map((secret) => `${secret.sourceId}:${secret.kind}`));
return {
id: definition.id,
managementPath: `/manage/custom-widgets/edit/${definition.id}`,
name: widget.name,
description: widget.description,
iconUrl: widget.iconUrl,
@@ -71,6 +73,7 @@ export function mapCustomWidgetListItem(definition: StoredDefinition & { secrets
export function mapLegacyCustomWidgetListItem(definition: LegacyDefinition) {
return {
id: definition.id,
managementPath: "/manage/custom-widgets",
name: definition.name,
description: definition.description ?? undefined,
iconUrl: definition.iconUrl ?? undefined,
@@ -1,8 +1,19 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { CUSTOM_WIDGET_MCP_AUTHORING_PROMPT } from "@homarr/custom-widgets/authoring-prompt";
import { getCustomWidgetSkill } from "@homarr/custom-widgets/authoring-resources";
import { customWidgetImportSchema, getCustomWidgetJsonSchema } from "@homarr/custom-widgets/core";
import {
getCustomWidgetComponent,
getCustomWidgetComponentCatalog,
getCustomWidgetExample,
getCustomWidgetSharedProps,
getCustomWidgetSkill,
} from "@homarr/custom-widgets/authoring-resources";
import {
customWidgetAuthoringDefinitionSchema,
getCustomWidgetJsonSchema,
normalizeCustomWidgetAuthoringDefinition,
} from "@homarr/custom-widgets/core";
import { permissionRequiredProcedure } from "../../trpc";
@@ -28,25 +39,133 @@ export const metadataProcedures = {
getSkill: permissionRequiredProcedure
.requiresPermission("admin")
.meta({ mcp: { enabled: true, description: "Get the portable Homarr Custom Widget skill." } })
.meta({
mcp: {
enabled: true,
description:
"Load the complete Homarr Custom Widget authoring skill in one response, including SKILL.md and every bundled reference. Call this before authoring, repairing, validating, previewing, or creating a custom widget.",
},
})
.query(() => getCustomWidgetSkill()),
getComponentCatalog: permissionRequiredProcedure
.requiresPermission("admin")
.meta({
mcp: {
enabled: true,
description:
"List a compact index of supported Custom JSX component names, categories, shared prop names, safety policies, and example IDs. Fetch only the named details needed for the widget.",
},
})
.query(() => getCustomWidgetComponentCatalog()),
getComponent: permissionRequiredProcedure
.requiresPermission("admin")
.meta({
mcp: {
enabled: true,
description:
"Get the installed Custom JSX documentation, allowed props, blocked props, and accessibility requirements for one named component.",
},
})
.input(z.object({ name: z.string().trim().min(1).max(128).describe("A component name from the catalog.") }))
.query(({ input }) => {
const component = getCustomWidgetComponent(input.name);
if (!component) throw new TRPCError({ code: "NOT_FOUND", message: "Custom JSX component not found" });
return component;
}),
getSharedProps: permissionRequiredProcedure
.requiresPermission("admin")
.meta({
mcp: {
enabled: true,
description:
"Get documentation for only the named shared Custom JSX props. Collect the shared prop names needed by all selected components and fetch them together once.",
},
})
.input(
z.object({
names: z
.array(z.string().trim().min(1).max(128))
.min(1)
.max(64)
.describe("Shared prop names from customWidget_getComponentCatalog.sharedProps.names."),
}),
)
.query(({ input }) => getCustomWidgetSharedProps(input.names)),
getExample: permissionRequiredProcedure
.requiresPermission("admin")
.meta({
mcp: {
enabled: true,
description:
"Get one installed Custom JSX example by its catalog ID. Fetch only an example relevant to the requested interaction pattern.",
},
})
.input(z.object({ name: z.string().trim().min(1).max(128).describe("An example ID from the catalog.") }))
.query(({ input }) => {
const example = getCustomWidgetExample(input.name);
if (!example) throw new TRPCError({ code: "NOT_FOUND", message: "Custom JSX example not found" });
return example;
}),
validate: permissionRequiredProcedure
.requiresPermission("admin")
.meta({ mcp: { enabled: true, description: "Validate one Custom JSX widget without saving it." } })
.input(z.object({ widget: z.unknown() }))
.meta({
mcp: {
enabled: true,
description:
"Validate one complete Custom JSX widget without saving it. Multiline JSX may be supplied as templateLines; the result returns precise issue paths to repair before previewing.",
},
})
.input(
z.object({
widget: z
.unknown()
.describe("A complete Homarr Custom Widget v2 definition using either template or templateLines."),
}),
)
.query(({ input }) => {
const result = customWidgetImportSchema.safeParse(input.widget);
if (!result.success) {
const authoringResult = customWidgetAuthoringDefinitionSchema.safeParse(input.widget);
if (!authoringResult.success) {
return {
valid: false as const,
issues: result.error.issues.map((issue) => ({
issues: authoringResult.error.issues.map((issue) => ({
path: issue.path.map(String).join("."),
code: issue.code,
message: issue.message,
})),
};
}
return { valid: true as const, issues: [], widget: result.data };
try {
const widget = normalizeCustomWidgetAuthoringDefinition(authoringResult.data);
return {
valid: true as const,
issues: [],
summary: {
name: widget.name,
sourceIds: Object.keys(widget.sources),
requestIds: Object.keys(widget.requests),
optionIds: Object.keys(widget.options),
templateLineCount: widget.template.split("\n").length,
},
nextStep:
"Reuse the validated authoring definition with customWidget_previewCreate, then run every returned query.",
};
} catch (error) {
if (error instanceof z.ZodError) {
return {
valid: false as const,
issues: error.issues.map((issue) => ({
path: issue.path.map(String).join("."),
code: issue.code,
message: issue.message,
})),
};
}
throw error;
}
}),
};
@@ -3,22 +3,25 @@ import { z } from "zod/v4";
import { decryptSecret } from "@homarr/common/server";
import {
customWidgetDefinitionSchema,
collectCustomWidgetRequestReferences,
customWidgetAuthoringDefinitionSchema,
customWidgetSecretsInputSchema,
getCustomWidgetConfirmation,
getCustomWidgetDefaultOptions,
normalizeCustomWidgetAuthoringDefinition,
validateCustomWidgetOptions,
} from "@homarr/custom-widgets/core";
import { eq } from "@homarr/db";
import { customWidgetDefinitions } from "@homarr/db/schema";
import { permissionRequiredProcedure } from "../../trpc";
import { parseCustomWidgetAuthoringInput } from "./authoring-validation";
import { createPreviewSession, getPreviewSession } from "./preview-sessions";
import { hasSameSecretBinding, requiredSecretKinds } from "./secret-policy";
import { parseStoredCustomWidgetDefinition } from "./stored-definition";
const previewCreateInputSchema = z.object({
definition: customWidgetDefinitionSchema,
definition: customWidgetAuthoringDefinitionSchema,
secrets: customWidgetSecretsInputSchema.default([]),
definitionId: z.string().optional(),
options: z.record(z.string(), z.unknown()).optional(),
@@ -26,11 +29,20 @@ const previewCreateInputSchema = z.object({
const previewCreateProcedure = permissionRequiredProcedure
.requiresPermission("admin")
.meta({ mcp: { enabled: true, description: "Create a short-lived preview session for one unsaved custom widget." } })
.meta({
mcp: {
enabled: true,
description:
"Create a short-lived preview for an unsaved custom widget after validation. Prefer templateLines for multiline JSX. The result lists every query that must be tested with customWidget_previewQuery before saving.",
},
})
.input(previewCreateInputSchema)
.mutation(async ({ ctx, input }) => {
const options = input.options ?? getCustomWidgetDefaultOptions(input.definition.options);
const optionIssues = validateCustomWidgetOptions(input.definition.options, options);
const definition = parseCustomWidgetAuthoringInput(() =>
normalizeCustomWidgetAuthoringDefinition(input.definition),
);
const options = input.options ?? getCustomWidgetDefaultOptions(definition.options);
const optionIssues = validateCustomWidgetOptions(definition.options, options);
if (optionIssues.length > 0) {
const issue = optionIssues[0];
throw new TRPCError({
@@ -48,7 +60,7 @@ const previewCreateProcedure = permissionRequiredProcedure
if (!existing) throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget definition not found" });
const existingDefinition = parseStoredCustomWidgetDefinition(existing);
for (const [sourceId, existingSource] of Object.entries(existingDefinition.sources)) {
const submittedSource = input.definition.sources[sourceId];
const submittedSource = definition.sources[sourceId];
const hasStoredSecrets = existing.secrets.some((secret) => secret.sourceId === sourceId);
if (!submittedSource || !hasStoredSecrets || hasSameSecretBinding(existingSource, submittedSource)) continue;
@@ -65,7 +77,7 @@ const previewCreateProcedure = permissionRequiredProcedure
}
for (const secret of existing.secrets) {
const existingSource = existingDefinition.sources[secret.sourceId];
const submittedSource = input.definition.sources[secret.sourceId];
const submittedSource = definition.sources[secret.sourceId];
if (
existingSource &&
submittedSource &&
@@ -78,7 +90,7 @@ const previewCreateProcedure = permissionRequiredProcedure
}
const invalid = secrets.find((secret) => {
const source = input.definition.sources[secret.sourceId];
const source = definition.sources[secret.sourceId];
const authType = typeof source?.auth === "string" ? source.auth : source?.auth.type;
const kinds =
authType === "basic"
@@ -94,26 +106,32 @@ const previewCreateProcedure = permissionRequiredProcedure
const previewSession = await createPreviewSession({
userId: ctx.session.user.id,
sources: input.definition.sources,
requests: input.definition.requests,
name: input.definition.name,
template: input.definition.template,
optionDefinitions: input.definition.options,
sources: definition.sources,
requests: definition.requests,
name: definition.name,
template: definition.template,
optionDefinitions: definition.options,
options,
secrets,
definitionId: input.definitionId,
});
const previewPath = `/manage/custom-widgets/preview/${previewSession.id}`;
return {
success: true as const,
previewSession,
previewUrl: new URL(
`/manage/custom-widgets/preview/${previewSession.id}`,
ctx.baseUrl ?? "http://localhost",
).toString(),
definition: {
template: input.definition.template,
defaultOptions: getCustomWidgetDefaultOptions(input.definition.options),
},
previewPath,
previewUrl: new URL(previewPath, ctx.baseUrl ?? "http://localhost").toString(),
queries: Object.entries(definition.requests).flatMap(([requestId, request]) => {
if (request.kind !== "query") return [];
return [
{
requestId,
trigger: request.trigger,
parameterNames: [...collectCustomWidgetRequestReferences(request).params],
nextStep: `Call customWidget_previewQuery with sessionId '${previewSession.id}' and requestId '${requestId}'.`,
},
];
}),
};
});
@@ -10,9 +10,16 @@ import { resolveCustomWidgetRequestValues } from "./request-manifest";
const logger = createLogger({ module: "custom-widget-preview" });
export const previewSessionRequestSchema = z.object({
sessionId: z.string().min(1),
requestId: z.string().min(1).max(64),
params: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).default({}),
sessionId: z.string().min(1).describe("The previewSession.id returned by customWidget_previewCreate."),
requestId: z
.string()
.min(1)
.max(64)
.describe("One query requestId from customWidget_previewCreate.queries. Test every returned query."),
params: z
.record(z.string(), z.union([z.string(), z.number(), z.boolean()]))
.default({})
.describe("Invocation parameters required by a manual query. Load queries use an empty object."),
});
export const recordPreviewJournal = async (...args: Parameters<typeof appendPreviewJournal>) => {
@@ -19,7 +19,8 @@ export const previewQueryProcedures = {
.meta({
mcp: {
enabled: true,
description: "Run one named query from a short-lived custom widget preview session.",
description:
"Execute one real API query from customWidget_previewCreate.queries and return its HTTP status plus parsed data so request paths and template bindings can be verified. Call once for every returned query before customWidget_create.",
},
})
.input(previewSessionRequestSchema)
@@ -100,6 +100,86 @@ async function insertLegacyJellyfin(db: ReturnType<typeof createDb>) {
const secret = { sourceId: "default", kind: "apiKey" as const, value: "test-secret" };
describe("custom widget definition persistence", () => {
test("creates and previews MCP-friendly multiline templateLines without losing the JSX", async () => {
const db = await prepareDatabase();
const caller = createCaller(db);
const { template, ...definition } = jellyfin;
const templateLines = template.split("\n");
const validation = await caller.validate({ widget: { ...definition, templateLines } });
expect(validation).toMatchObject({
valid: true,
summary: {
name: jellyfin.name,
sourceIds: Object.keys(jellyfin.sources),
requestIds: Object.keys(jellyfin.requests),
templateLineCount: templateLines.length,
},
});
expect(validation).not.toHaveProperty("widget");
expect(validation).not.toHaveProperty("templateLines");
expect(Buffer.byteLength(JSON.stringify(validation), "utf8")).toBeLessThan(2_000);
const preview = await caller.previewCreate({ definition: { ...definition, templateLines }, secrets: [] });
expect(preview).toMatchObject({
success: true,
previewPath: `/manage/custom-widgets/preview/${preview.previewSession.id}`,
});
expect(preview).not.toHaveProperty("definition");
expect(Buffer.byteLength(JSON.stringify(preview), "utf8")).toBeLessThan(4_000);
expect(preview.queries.map(({ requestId }) => requestId)).toEqual(Object.keys(jellyfin.requests));
expect(preview.queries.every(({ nextStep }) => nextStep.includes("customWidget_previewQuery"))).toBe(true);
const created = await caller.create({ ...definition, templateLines, secrets: [] });
expect(created.managementPath).toBe(`/manage/custom-widgets/edit/${created.id}`);
expect(created.nextAction).toEqual({
type: "place-custom-widget",
widgetKind: "customApi",
options: { definitionId: created.id },
whenTargetIsKnown: "Call configure_widget now with the requested board and these exact widget options.",
whenTargetIsUnknown:
"Call ask_user now with 'Place on a board' and 'Leave unplaced'. Never ask this choice in prose.",
});
expect((await caller.get({ id: created.id })).template).toBe(template);
});
test("updates a saved widget with MCP-friendly multiline templateLines", async () => {
const db = await prepareDatabase();
const caller = createCaller(db);
const created = await caller.create({ ...jellyfin, secrets: [] });
const updatedTemplate = jellyfin.template.replace("Jellyfin library", "Updated Jellyfin library");
const updated = await caller.update({ id: created.id, templateLines: updatedTemplate.split("\n") });
expect(updated.managementPath).toBe(`/manage/custom-widgets/edit/${created.id}`);
expect((await caller.get({ id: created.id })).template).toBe(updatedTemplate);
});
test("reports joined templateLines validation issues as BAD_REQUEST for create and update", async () => {
const db = await prepareDatabase();
const caller = createCaller(db);
const { template: _template, ...definition } = jellyfin;
const invalidTemplateLines = ["<Stack>", ' <img src="https://example.com/logo.png" />', "</Stack>"];
const createError = await caller
.create({ ...definition, templateLines: invalidTemplateLines, secrets: [] })
.catch((error: unknown) => error);
expect(createError).toMatchObject({
code: "BAD_REQUEST",
cause: { issues: [expect.objectContaining({ path: ["template"] })] },
});
expect(await db.query.customWidgetDefinitions.findMany()).toHaveLength(0);
const created = await caller.create({ ...jellyfin, secrets: [] });
const updateError = await caller
.update({ id: created.id, templateLines: invalidTemplateLines })
.catch((error: unknown) => error);
expect(updateError).toMatchObject({
code: "BAD_REQUEST",
cause: { issues: [expect.objectContaining({ path: ["template"] })] },
});
expect((await caller.get({ id: created.id })).template).toBe(jellyfin.template);
});
test("atomically creates a v2 replacement while preserving the legacy widget and its stable ID", async () => {
const db = await prepareDatabase();
const legacy = await insertLegacyJellyfin(db);
@@ -0,0 +1,94 @@
import type { Session } from "@homarr/auth";
import { createId } from "@homarr/common";
import { createDb } from "@homarr/db/test";
import { users } from "@homarr/db/schema";
import { CUSTOM_WIDGET_STARTER } from "@homarr/custom-widgets/core";
import { beforeEach, describe, expect, test, vi } from "vitest";
const mocks = vi.hoisted(() => ({
executeRequest: vi.fn(async (_input: unknown) => ({
ok: true,
status: 200,
statusText: "OK",
data: { fixtures: [{ id: "match-1", home_team: { name: "PSG" }, away_team: { name: "Lyon" } }] },
})),
}));
vi.mock("../../custom-widget/request-executor", () => ({
executeCustomWidgetRequest: mocks.executeRequest,
invalidateCustomWidgetResponseCache: vi.fn(),
}));
vi.mock("../../custom-widget/request-limits", () => ({
acquireCustomWidgetRequestLimit: vi.fn(async () => async () => undefined),
}));
import { customWidgetRouter } from "../../custom-widget/custom-widget-router";
describe("custom widget agent preview workflow", () => {
beforeEach(() => mocks.executeRequest.mockClear());
test("runs a named preview query and returns the real response shape to the agent", async () => {
const db = createDb();
const userId = createId();
await db.insert(users).values({ id: userId });
const session = {
user: { id: userId, permissions: ["admin"], colorScheme: "light" },
expires: new Date(Date.now() + 60_000).toISOString(),
} satisfies Session;
const caller = customWidgetRouter.createCaller({ db, deviceType: undefined, session });
const { template, ...definition } = CUSTOM_WIDGET_STARTER;
const preview = await caller.previewCreate({
definition: {
...definition,
requests: { fixtures: { path: "/fixtures", query: { season: 2026 } } },
templateLines: template.split("\n"),
},
secrets: [],
});
expect(preview.queries).toEqual([
expect.objectContaining({ requestId: "fixtures", trigger: "load", parameterNames: [] }),
]);
await expect(
caller.previewQuery({ sessionId: preview.previewSession.id, requestId: "fixtures", params: {} }),
).resolves.toEqual(
expect.objectContaining({
ok: true,
status: 200,
data: { fixtures: [expect.objectContaining({ id: "match-1" })] },
}),
);
const request = mocks.executeRequest.mock.calls[0]?.[0] as { targetUrl: string | URL; kind: string } | undefined;
expect(String(request?.targetUrl)).toBe("https://example.com/fixtures?season=2026");
expect(request?.kind).toBe("query");
});
test("reports joined preview templateLines validation issues as BAD_REQUEST", async () => {
const db = createDb();
const userId = createId();
await db.insert(users).values({ id: userId });
const session = {
user: { id: userId, permissions: ["admin"], colorScheme: "light" },
expires: new Date(Date.now() + 60_000).toISOString(),
} satisfies Session;
const caller = customWidgetRouter.createCaller({ db, deviceType: undefined, session });
const { template: _template, ...definition } = CUSTOM_WIDGET_STARTER;
const error = await caller
.previewCreate({
definition: {
...definition,
templateLines: ["<Stack>", ' <img src="https://example.com/logo.png" />', "</Stack>"],
},
secrets: [],
})
.catch((cause: unknown) => cause);
expect(error).toMatchObject({
code: "BAD_REQUEST",
cause: { issues: [expect.objectContaining({ path: ["template"] })] },
});
expect(mocks.executeRequest).not.toHaveBeenCalled();
});
});
+34
View File
@@ -23,6 +23,10 @@ const MCP_TOOL_ALLOWLIST = {
"calendar_findAllEvents",
"customWidget_get",
"customWidget_getAuthoringPrompt",
"customWidget_getComponent",
"customWidget_getComponentCatalog",
"customWidget_getExample",
"customWidget_getSharedProps",
"customWidget_getSkill",
"customWidget_legacyMigrationPrompt",
"customWidget_list",
@@ -186,6 +190,10 @@ describe("custom widget authoring procedure access", () => {
const calls = [
unauthenticatedCaller.customWidget.schema(),
unauthenticatedCaller.customWidget.getAuthoringPrompt(),
unauthenticatedCaller.customWidget.getComponentCatalog(),
unauthenticatedCaller.customWidget.getComponent({ name: "Stack" }),
unauthenticatedCaller.customWidget.getExample({ name: "service-dashboard" }),
unauthenticatedCaller.customWidget.getSharedProps({ names: ["p"] }),
unauthenticatedCaller.customWidget.getSkill(),
unauthenticatedCaller.customWidget.list(),
unauthenticatedCaller.customWidget.validate({ widget: {} }),
@@ -208,6 +216,10 @@ describe("custom widget authoring procedure access", () => {
const calls = [
nonAdminCaller.customWidget.schema(),
nonAdminCaller.customWidget.getAuthoringPrompt(),
nonAdminCaller.customWidget.getComponentCatalog(),
nonAdminCaller.customWidget.getComponent({ name: "Stack" }),
nonAdminCaller.customWidget.getExample({ name: "service-dashboard" }),
nonAdminCaller.customWidget.getSharedProps({ names: ["p"] }),
nonAdminCaller.customWidget.getSkill(),
nonAdminCaller.customWidget.secretSet({
definitionId: "widget",
@@ -233,5 +245,27 @@ describe("custom widget authoring procedure access", () => {
await expect(adminCaller.customWidget.getSkill()).resolves.toMatchObject({
name: expect.any(String),
});
await expect(adminCaller.customWidget.getComponentCatalog()).resolves.toMatchObject({
components: expect.arrayContaining([expect.objectContaining({ name: "Stack" })]),
examples: expect.arrayContaining([expect.objectContaining({ id: "service-dashboard" })]),
});
await expect(adminCaller.customWidget.getComponent({ name: "Stack" })).resolves.toMatchObject({
name: "Stack",
props: expect.anything(),
});
await expect(adminCaller.customWidget.getExample({ name: "service-dashboard" })).resolves.toMatchObject({
id: "service-dashboard",
widget: expect.objectContaining({ $schema: "homarr-custom-widget-v2" }),
});
await expect(adminCaller.customWidget.getSharedProps({ names: ["p", "m"] })).resolves.toMatchObject({
props: [expect.objectContaining({ name: "p" }), expect.objectContaining({ name: "m" })],
notFound: [],
});
await expect(adminCaller.customWidget.getComponent({ name: "NotAComponent" })).rejects.toEqual(
expect.objectContaining<Partial<TRPCError>>({ code: "NOT_FOUND" }),
);
await expect(adminCaller.customWidget.getExample({ name: "not-an-example" })).rejects.toEqual(
expect.objectContaining<Partial<TRPCError>>({ code: "NOT_FOUND" }),
);
});
});