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
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user