fix: harden assistant authoring and UI
This commit is contained in:
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();
|
||||
});
|
||||
});
|
||||
@@ -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" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user