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

@@ -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();
});
});