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" }),
);
});
});
@@ -127,7 +127,11 @@ export const CUSTOM_WIDGET_AUTHORING_PROMPT = AUTHORING_PROMPT;
export const CUSTOM_WIDGET_MCP_AUTHORING_PROMPT = `Author one Homarr Custom JSX v2 widget at a time.
Read the live schema and only the component resources needed for the design. Construct a credential-free widget, validate it, create a preview, test queries and simulated actions, visually inspect it, then create it. Configure deployment-specific source URLs and request credentials through Homarr when needed; never repeat plaintext. The live resources are authoritative for this Homarr release.
First call customWidget_getSkill. Its single lazy response contains the complete installed SKILL.md and every bundled reference; follow all of it. Read the live schema and compact component catalog. Fetch only the named component and example resources needed for the design. Component resources contain component-specific props; if shared props are needed, collect their catalog names and fetch those docs together once with customWidget_getSharedProps. The live resources are authoritative for this Homarr release.
Follow this lifecycle without skipping steps: construct one credential-free definition; call customWidget_validate; repair every reported issue; call customWidget_previewCreate; call customWidget_previewQuery once for every query returned by that preview (supplying required manual parameters); inspect the real response shape and HTTP status; adjust paths and template bindings; validate and preview again after changes; open or provide the returned previewPath for visual review; only then call customWidget_create. Prefer templateLines over a JSON-escaped template string for multiline JSX in validate, preview, and create tool inputs.
Do not claim a query works when it has not returned a successful preview response. Configure deployment-specific source URLs and request credentials through Homarr when needed; never repeat plaintext. Use simulated preview actions unless the user explicitly enables live preview actions.
Use the lean keyed sources, requests, and options contract. Bind saved values directly with $option and invocation values with $param. Load queries cannot use $param. Keep the design responsive, accessible, theme-safe, loading-aware, empty-aware, and error-aware.
@@ -1,4 +1,8 @@
import { customJsxAuthoringCatalog, getCustomJsxComponentProps } from "./component-catalog";
import {
customJsxAuthoringCatalog,
customJsxCatalogGlobalPropByName,
resolveCustomJsxPropDescriptor,
} from "./component-catalog";
import { customJsxExamples } from "./examples";
import { customJsxTablerIconNames } from "./tabler-icons";
@@ -7,7 +11,58 @@ export const CUSTOM_WIDGET_SKILL_SOURCE_URL =
"https://github.com/homarr-labs/homarr/tree/HEAD/.agents/skills/homarr-custom-widget";
export const CUSTOM_WIDGET_SKILL_INSTALL_COMMAND =
"npx skills add https://github.com/homarr-labs/homarr --skill homarr-custom-widget";
export const CUSTOM_WIDGET_SKILL_VERSION = "2.1.0";
export const CUSTOM_WIDGET_SKILL_VERSION = "2.2.0";
export const CUSTOM_WIDGET_SKILL_REFERENCES = {
"references/schema.md": `# Schema
\`\`\`ts
interface HomarrCustomWidgetV2 {
$schema: "homarr-custom-widget-v2";
name: string;
description?: string;
iconUrl?: string;
sources: Record<string, CustomWidgetSource>;
requests: Record<string, CustomWidgetRequest>;
options?: Record<string, CustomWidgetOption>;
template: string;
}
\`\`\`
Sources require \`default\`. A request defaults to the default source, query, GET, load, inherited auth, and view permission. An action defaults to manual and modify permission. DELETE uses full permission and confirmation.
Use stable real URLs for public APIs and clear suggested URLs for self-hosted services. Homarr collects the installer's server URL, network scope, and credentials as source setup; credentials remain outside the manifest.
Paths use \`{option:name}\` and \`{param:name}\`. Query/body data uses \`{ "$option": "name" }\` and \`{ "$param": "name" }\`. Load queries cannot use params. Runtime parameter names and primitive values are inferred from references.
Every option has \`label\`, \`control\`, and \`default\`. Optional fields are \`description\`, \`choices\`, \`choicesFrom\`, \`min\`, \`max\`, \`step\`, \`advanced\`, and \`group\`.
`,
"references/runtime.md": `# Runtime
Templates read \`data.requestId\`, \`status.requestId\`, \`options.name\`, and temporary \`inputs.name\`. Status contains \`loading\`, \`ok\`, \`status\`, \`statusText\`, and \`error\`.
\`bind="search"\` creates an in-memory input. It is never persisted. Supply invocation values only through \`params\`, for example:
\`\`\`jsx
<TextInput bind="search" label="Search" />
<SubFetch requestId="search" params={{ query: inputs.search }}>
{(items) => <Stack>{(items ?? []).map(item => <Text key={item.id}>{item.name}</Text>)}</Stack>}
</SubFetch>
\`\`\`
\`SubFetch\` with \`trigger="manual"\` renders its own load button. To make a card or image launch the request, pass that node through \`triggerContent\` and provide \`triggerAriaLabel\`; Homarr supplies the click and keyboard behavior. Its optional second callback argument contains \`{ ok, status, statusText, loading: false }\`; loading and request failures are rendered by \`SubFetch\` before the callback runs. Do not author \`onClick\` or a fetch callback.
Use expression callbacks for supported collection methods and trusted slots. Do not use callback blocks, IIFEs, authored recursion, or raw events. Bounded regex literals work only with safe string matching/replacement operations.
`,
"references/security.md": `# Security
All requests use Homarr's protected server executor. Source origin, network scope, DNS, redirects, SSRF, rate limits, permissions, size limits, timeouts, and encrypted credential injection remain enforced.
The JSX interpreter blocks imports, hooks, refs, raw event callbacks, browser requests, eval, arbitrary functions, prototype access, unsafe URLs, global CSS escape, arbitrary portals, bigint, statement blocks, IIFEs, and recursion. Regex literals must be bounded and reject backreferences, lookbehind, nested quantifiers, excessive length, and unsupported flags.
Credentials are stored separately and never exported or returned to an agent. A published self-hosted source URL is only a suggestion: installers must confirm or replace private and loopback URLs for their own Homarr deployment. Source origins cannot be controlled through widget options.
`,
} as const;
export const CUSTOM_WIDGET_SKILL_MD = `---
name: homarr-custom-widget
@@ -39,11 +94,19 @@ Preview-scoped configuration expires with the preview. To persist user-entered v
When this skill is installed from the repository, optional offline details are in \`references/schema.md\`, \`references/runtime.md\`, and \`references/security.md\`. MCP-only clients should use the live Homarr resources instead.
`;
const CUSTOM_WIDGET_SKILL_BUNDLE_MD = [
CUSTOM_WIDGET_SKILL_MD.trimEnd(),
...Object.entries(CUSTOM_WIDGET_SKILL_REFERENCES).map(
([file, content]) => `\n\n---\n\n# Bundled file: ${file}\n\n${content.trimEnd()}`,
),
].join("");
export function getCustomWidgetSkill() {
return {
name: "homarr-custom-widget",
version: CUSTOM_WIDGET_SKILL_VERSION,
skillMd: CUSTOM_WIDGET_SKILL_MD,
references: CUSTOM_WIDGET_SKILL_REFERENCES,
skillsShUrl: CUSTOM_WIDGET_SKILLS_SH_URL,
sourceUrl: CUSTOM_WIDGET_SKILL_SOURCE_URL,
installCommand: CUSTOM_WIDGET_SKILL_INSTALL_COMMAND,
@@ -51,11 +114,28 @@ export function getCustomWidgetSkill() {
}
export function getCustomWidgetSkillContent() {
return CUSTOM_WIDGET_SKILL_MD;
return CUSTOM_WIDGET_SKILL_BUNDLE_MD;
}
export function getCustomWidgetComponentCatalog() {
return customJsxAuthoringCatalog;
return {
schemaVersion: customJsxAuthoringCatalog.schemaVersion,
mantineVersion: customJsxAuthoringCatalog.mantineVersion,
customWidgetVersion: customJsxAuthoringCatalog.customWidgetVersion,
components: customJsxAuthoringCatalog.components.map(({ name, category, safety }) => ({ name, category, safety })),
sharedProps: {
count: customJsxAuthoringCatalog.globalProps.length,
names: customJsxAuthoringCatalog.globalProps.map(({ name }) => name),
fetchTool: "customWidget_getSharedProps" as const,
maxPerRequest: 64,
},
blockedCapabilities: customJsxAuthoringCatalog.blockedCapabilities,
examples: getCustomWidgetExampleCatalog(),
};
}
export function getCustomWidgetExampleCatalog() {
return customJsxExamples.map(({ id, title, description }) => ({ id, title, description }));
}
export function getCustomWidgetComponent(name: string) {
@@ -71,17 +151,37 @@ export function getCustomWidgetComponent(name: string) {
category: component.category,
safety: component.safety,
description: component.description,
props: getCustomJsxComponentProps(canonicalName),
props: component.props.map((prop) => resolveCustomJsxPropDescriptor(prop)),
blockedProps: component.blockedProps,
bind: component.bind,
subcomponents: component.subcomponents,
accessibilityRequirements: component.accessibilityRequirements,
documentationUrl: component.documentationUrl,
sharedProps:
component.safety === "denied"
? undefined
: {
catalogField: "sharedProps.names" as const,
fetchTool: "customWidget_getSharedProps" as const,
maxPerRequest: 64,
appliesExceptBlockedProps: true as const,
},
knownValues: component.name === "TablerIcon" ? { name: customJsxTablerIconNames } : undefined,
deniedReason: component.deniedReason,
};
}
export function getCustomWidgetSharedProps(names: readonly string[]) {
const props: ReturnType<typeof resolveCustomJsxPropDescriptor>[] = [];
const notFound: string[] = [];
for (const name of new Set(names)) {
const prop = customJsxCatalogGlobalPropByName.get(name);
if (prop) props.push(resolveCustomJsxPropDescriptor(prop));
else notFound.push(name);
}
return { props, notFound };
}
export function getCustomWidgetExample(name: string) {
return customJsxExamples.find((example) => example.id === name) ?? null;
}
@@ -17,6 +17,7 @@ import { getCustomWidgetHttpUrlIssue } from "./url-policy";
export * from "./options-schema";
export * from "./request-schema";
export const CUSTOM_WIDGET_SCHEMA = "homarr-custom-widget-v2";
const CUSTOM_WIDGET_TEMPLATE_MAX_LENGTH = 50_000;
export const customWidgetBindingIdentifierSchema = z
.string()
@@ -30,7 +31,7 @@ export const customWidgetBindingIdentifierSchema = z
export const customJsxTemplateSchema = z
.string()
.min(1)
.max(50_000)
.max(CUSTOM_WIDGET_TEMPLATE_MAX_LENGTH)
.superRefine((template, ctx) => {
for (const diagnostic of validateCustomJsxTemplate(template)) {
if (diagnostic.severity === "error") {
@@ -193,13 +194,84 @@ function validateTemplateRequests(template: string, requests: Record<string, Cus
export type HomarrCustomWidgetV2 = z.infer<typeof customWidgetDefinitionSchema>;
export type HomarrCustomWidgetV2Input = z.input<typeof customWidgetDefinitionSchema>;
export const customWidgetCreateSchema = customWidgetDefinitionSchema.safeExtend({
const customWidgetTemplateLinesSchema = z
.array(z.string().max(10_000))
.min(1)
.max(2_000)
.refine(
(lines) => lines.reduce((length, line) => length + line.length, Math.max(0, lines.length - 1)) <= 50_000,
`The complete JSX template must contain at most ${CUSTOM_WIDGET_TEMPLATE_MAX_LENGTH} characters`,
)
.describe(
"The complete JSX template split into lines. Prefer this over template for generated multiline JSX. Do not send both fields.",
);
export const customWidgetAuthoringDefinitionSchema = z
.strictObject({
...customWidgetDefinitionSchema.shape,
template: customJsxTemplateSchema
.optional()
.describe("The complete JSX template as one string. For multiline JSX, prefer templateLines instead."),
templateLines: customWidgetTemplateLinesSchema.optional(),
})
.superRefine((definition, ctx) => {
if (definition.template === undefined && definition.templateLines === undefined) {
ctx.addIssue({ code: "custom", path: ["template"], message: "Provide template or templateLines" });
}
if (definition.template !== undefined && definition.templateLines !== undefined) {
ctx.addIssue({
code: "custom",
path: ["templateLines"],
message: "Provide template or templateLines, not both",
});
}
});
export type CustomWidgetAuthoringDefinitionInput = z.input<typeof customWidgetAuthoringDefinitionSchema>;
export function normalizeCustomWidgetAuthoringDefinition(input: CustomWidgetAuthoringDefinitionInput) {
const { templateLines, ...definition } = input;
return customWidgetDefinitionSchema.parse({
...definition,
template: definition.template ?? templateLines?.join("\n"),
});
}
const customWidgetAuthoringUpdateSchema = z
.strictObject({
...customWidgetDefinitionSchema.shape,
options: customWidgetOptionsSchema.removeDefault(),
templateLines: customWidgetTemplateLinesSchema.optional(),
})
.partial()
.superRefine((definition, ctx) => {
if (definition.template !== undefined && definition.templateLines !== undefined) {
ctx.addIssue({
code: "custom",
path: ["templateLines"],
message: "Provide template or templateLines, not both",
});
}
});
export type CustomWidgetAuthoringUpdateInput = z.input<typeof customWidgetAuthoringUpdateSchema>;
export function normalizeCustomWidgetAuthoringUpdate(input: CustomWidgetAuthoringUpdateInput) {
const { templateLines, ...definition } = input;
return {
...definition,
...(templateLines === undefined ? {} : { template: templateLines.join("\n") }),
};
}
export const customWidgetCreateSchema = customWidgetAuthoringDefinitionSchema.safeExtend({
secrets: customWidgetSecretsInputSchema.default([]),
});
export const customWidgetUpdateSchema = z
.strictObject(customWidgetDefinitionSchema.shape)
.partial()
.extend({ id: z.string().min(1), secrets: customWidgetSecretsInputSchema.optional() });
export const customWidgetUpdateSchema = customWidgetAuthoringUpdateSchema.safeExtend({
id: z.string().min(1),
secrets: customWidgetSecretsInputSchema.optional(),
});
export const customWidgetImportSchema = customWidgetDefinitionSchema;
export type CustomWidgetCreateInput = z.infer<typeof customWidgetCreateSchema>;
export type CustomWidgetUpdateInput = z.infer<typeof customWidgetUpdateSchema>;
@@ -1,6 +1,10 @@
import { describe, expect, it } from "vitest";
import { buildCustomWidgetAiPrompt, CUSTOM_WIDGET_FINAL_OUTPUT_INSTRUCTION } from "../core/ai-prompt";
import {
buildCustomWidgetAiPrompt,
CUSTOM_WIDGET_FINAL_OUTPUT_INSTRUCTION,
CUSTOM_WIDGET_MCP_AUTHORING_PROMPT,
} from "../core/ai-prompt";
describe("AI prompt", () => {
it("is self-contained, request-first, and compact", () => {
@@ -36,6 +40,18 @@ describe("AI prompt", () => {
expect(prompt.match(/Recommended components:/gu)).toHaveLength(1);
});
it("makes the connected MCP workflow load the complete skill and test every preview query", () => {
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("First call customWidget_getSkill");
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("customWidget_validate");
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("customWidget_previewCreate");
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("customWidget_previewQuery once for every query");
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("customWidget_getSharedProps");
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("Prefer templateLines");
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT.indexOf("customWidget_previewQuery")).toBeLessThan(
CUSTOM_WIDGET_MCP_AUTHORING_PROMPT.indexOf("customWidget_create"),
);
});
it("redacts secrets and preserves the final instruction when optional context is large", () => {
const prompt = buildCustomWidgetAiPrompt(
undefined,
@@ -5,28 +5,73 @@ import { describe, expect, it } from "vitest";
import {
getCustomWidgetComponent,
getCustomWidgetComponentCatalog,
getCustomWidgetSharedProps,
getCustomWidgetSkill,
getCustomWidgetSkillContent,
} from "../core/authoring-resources";
const skillSourceDirectory = path.resolve(import.meta.dirname, "../../../../.agents/skills/homarr-custom-widget");
describe("authoring resources", () => {
it("returns the concise skills.sh discovery shape", () => {
it("returns the complete portable skill and keeps every bundled source file in sync", () => {
const skill = getCustomWidgetSkill();
expect(skill).toMatchObject({ name: "homarr-custom-widget", version: "2.1.0" });
expect(skill).toMatchObject({ name: "homarr-custom-widget", version: "2.2.0" });
expect(skill.skillsShUrl).toContain("skills.sh/homarr-labs/homarr");
expect(skill.sourceUrl).toContain("/tree/HEAD/.agents/skills/homarr-custom-widget");
expect(skill.installCommand).toContain("--skill homarr-custom-widget");
expect(skill).not.toHaveProperty("files");
expect(skill).not.toHaveProperty("componentCatalog");
expect(getCustomWidgetSkillContent()).toBe(skill.skillMd);
expect(skill.skillMd.trim()).toBe(
readFileSync(path.join(process.cwd(), ".agents/skills/homarr-custom-widget/SKILL.md"), "utf8").trim(),
);
expect(skill).not.toHaveProperty("entrypointMd");
expect(skill).not.toHaveProperty("bundledMarkdown");
expect(skill.skillMd.trim()).toBe(readFileSync(path.join(skillSourceDirectory, "SKILL.md"), "utf8").trim());
const bundledContent = getCustomWidgetSkillContent();
for (const [file, content] of Object.entries(skill.references)) {
expect(content.trim()).toBe(readFileSync(path.join(skillSourceDirectory, file), "utf8").trim());
expect(bundledContent).toContain(`# Bundled file: ${file}`);
expect(bundledContent).toContain(content.trim());
}
expect(bundledContent).toContain(skill.skillMd.trim());
expect(Buffer.byteLength(JSON.stringify(skill), "utf8")).toBeLessThan(12_000);
});
it("keeps the full catalog retrieval-based", () => {
expect(getCustomWidgetComponentCatalog().components.length).toBeGreaterThan(100);
expect(getCustomWidgetComponent("Stack")?.name).toBe("Stack");
it("keeps catalog discovery compact and component details lazy", () => {
const catalog = getCustomWidgetComponentCatalog();
expect(catalog.components.length).toBeGreaterThan(100);
expect(catalog.components).toEqual(
expect.arrayContaining([expect.objectContaining({ name: "Stack", category: expect.any(String) })]),
);
expect(catalog.examples).toEqual(expect.arrayContaining([expect.objectContaining({ id: "service-dashboard" })]));
expect(catalog.sharedProps).toMatchObject({
count: expect.any(Number),
fetchTool: "customWidget_getSharedProps",
maxPerRequest: 64,
});
expect(catalog.sharedProps.names).toContain("p");
expect(Buffer.byteLength(JSON.stringify(catalog), "utf8")).toBeLessThan(45_000);
const stack = getCustomWidgetComponent("Stack");
expect(stack).toMatchObject({
name: "Stack",
sharedProps: { fetchTool: "customWidget_getSharedProps" },
});
expect(stack?.props.some(({ source }) => source === "global")).toBe(false);
expect(Buffer.byteLength(JSON.stringify(stack), "utf8")).toBeLessThan(20_000);
expect(getCustomWidgetComponent("Icon")?.knownValues?.name.length).toBeGreaterThan(100);
const largestComponentPayload = Math.max(
...catalog.components.map(({ name }) =>
Buffer.byteLength(JSON.stringify(getCustomWidgetComponent(name)), "utf8"),
),
);
expect(largestComponentPayload).toBeLessThan(20_000);
});
it("returns only requested shared prop documentation in one bounded payload", () => {
const result = getCustomWidgetSharedProps(["p", "m", "not-a-shared-prop", "p"]);
expect(result.props.map(({ name }) => name)).toEqual(["p", "m"]);
expect(result.notFound).toEqual(["not-a-shared-prop"]);
expect(Buffer.byteLength(JSON.stringify(result), "utf8")).toBeLessThan(4_000);
const boundedBatch = getCustomWidgetSharedProps(getCustomWidgetComponentCatalog().sharedProps.names.slice(0, 64));
expect(Buffer.byteLength(JSON.stringify(boundedBatch), "utf8")).toBeLessThan(15_000);
});
});
@@ -2,13 +2,16 @@ import { describe, expect, it } from "vitest";
import {
CUSTOM_WIDGET_STARTER,
customWidgetAuthoringDefinitionSchema,
customWidgetDefinitionSchema,
customWidgetUpdateSchema,
customJsxRequestSchema,
getCustomWidgetDefaultOptions,
getCustomWidgetSecretRequirements,
getCustomWidgetSourceSetups,
hasSameCustomWidgetSourceAuthentication,
applyCustomWidgetSourceSetup,
normalizeCustomWidgetAuthoringDefinition,
} from "../core";
describe("lean Custom Widget schema", () => {
@@ -61,6 +64,56 @@ describe("lean Custom Widget schema", () => {
expect(customWidgetDefinitionSchema.parse(withoutOptions).options).toEqual({});
});
it("accepts and normalizes realistic multiline JSX without JSON string escaping", () => {
const { template: _template, ...definition } = CUSTOM_WIDGET_STARTER;
const templateLines = [
'<Stack gap="sm">',
' <Group justify="space-between">',
" <Text fw={700}>Upcoming fixtures</Text>",
' <Badge color="blue">{(data.fixtures ?? []).length} matches</Badge>',
" </Group>",
" {(data.fixtures ?? []).map(fixture => (",
' <Paper key={fixture.id} p="sm" withBorder>',
" <Text>{fixture.home_team.name} vs {fixture.away_team.name}</Text>",
" </Paper>",
" ))}",
"</Stack>",
];
const candidate = customWidgetAuthoringDefinitionSchema.parse({
...definition,
requests: { fixtures: { path: "/fixtures" } },
templateLines,
});
expect(normalizeCustomWidgetAuthoringDefinition(candidate).template).toBe(templateLines.join("\n"));
});
it("requires exactly one authoring template representation", () => {
expect(
customWidgetAuthoringDefinitionSchema.safeParse({ ...CUSTOM_WIDGET_STARTER, templateLines: ["<Text />"] })
.success,
).toBe(false);
const { template: _template, ...withoutTemplate } = CUSTOM_WIDGET_STARTER;
expect(customWidgetAuthoringDefinitionSchema.safeParse(withoutTemplate).success).toBe(false);
});
it("keeps omitted update fields absent when using templateLines", () => {
expect(customWidgetUpdateSchema.parse({ id: "widget", templateLines: ["<Text />"] })).toEqual({
id: "widget",
templateLines: ["<Text />"],
});
});
it("applies the template size limit before joining templateLines", () => {
const { template: _template, ...definition } = CUSTOM_WIDGET_STARTER;
expect(
customWidgetAuthoringDefinitionSchema.safeParse({
...definition,
templateLines: Array.from({ length: 6 }, () => "x".repeat(10_000)),
}).success,
).toBe(false);
});
it("rejects source IDs that collide case-insensitively", () => {
const result = customWidgetDefinitionSchema.safeParse({
...CUSTOM_WIDGET_STARTER,
+6 -1
View File
@@ -2100,7 +2100,8 @@
"dismissQuote": "Remove quote",
"attachments": {
"add": "Attach an image or document",
"errorTitle": "Attachment could not be added"
"errorTitle": "Attachment could not be added",
"imageUnsupported": "The selected model does not support image input."
},
"mentions": {
"menu": "Add Homarr context",
@@ -2170,6 +2171,10 @@
},
"toolInput": "Input",
"toolOutput": "Output",
"toolNavigation": {
"openPreview": "Open preview",
"openWidget": "Open widget"
},
"webSearch": {
"title": "Web search",
"completed": "OpenRouter searched the live web for this response.",