fix(custom-widgets): refine v2 authoring

This commit is contained in:
Thomas Camlong committed 2026-07-20 14:14:53 +02:00
1 parent b050c267b6
commit f9863b8402
124 files changed
+2089 -913

No files matched your search

@@ -1,15 +1,9 @@
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { describe, expect, test } from "vitest";
import {
CUSTOM_WIDGET_SKILL_MD,
CUSTOM_WIDGET_SKILL_SHA256,
getCustomWidgetComponent,
getCustomWidgetSkill,
} from "../core/authoring-resources";
import { CUSTOM_WIDGET_SKILL_MD, getCustomWidgetComponent, getCustomWidgetSkill } from "../core/authoring-resources";
const repositoryRoot = resolve(import.meta.dirname, "../../../..");
@@ -20,11 +14,15 @@ describe("custom widget Agent Skill", () => {
expect(getCustomWidgetSkill().skillMd).toBe(source);
});
test("publishes the exact archive hash", async () => {
const archive = await readFile(
resolve(repositoryRoot, "apps/docs/static/downloads/homarr-custom-widget-2.0.0.zip"),
test("publishes repository-based skills.sh installation metadata", () => {
const skill = getCustomWidgetSkill();
expect(skill.skillsShUrl).toBe("https://www.skills.sh/homarr-labs/homarr/homarr-custom-widget");
expect(skill.sourceUrl).toContain("github.com/homarr-labs/homarr/tree/v2/.agents/skills/homarr-custom-widget");
expect(skill.installCommand).toBe(
"npx skills add https://github.com/homarr-labs/homarr --skill homarr-custom-widget",
);
expect(createHash("sha256").update(archive).digest("hex")).toBe(CUSTOM_WIDGET_SKILL_SHA256);
expect(skill).not.toHaveProperty("sha256");
expect(skill).not.toHaveProperty("downloadUrl");
});
test("publishes safe icon names with the Tabler icon component", () => {
@@ -1,6 +1,10 @@
import { describe, expect, test } from "vitest";
import { customJsxComponentByName, customJsxComponentRegistry } from "../core/component-registry";
import {
customJsxComponentByName,
customJsxComponentRegistry,
getCustomJsxBindingType,
} from "../core/component-registry";
const mantineCore941ComponentExports = [
"RemoveScroll",
@@ -231,4 +235,25 @@ describe("customJsxComponentRegistry", () => {
])("classifies the Mantine 9.4.1 export %s", (name) => {
expect(customJsxComponentByName.has(name), `${name} is not classified`).toBe(true);
});
test.each([
["TextInput", "string"],
["NumberInput", "number"],
["Switch", "boolean"],
["Select", "string"],
["MultiSelect", "string[]"],
["DateInput", "string"],
["Calendar", "string"],
["Tabs", "string"],
["Popover", "boolean"],
["RangeSlider", "number[]"],
] as const)("infers %s bindings as %s", (component, type) => {
expect(getCustomJsxBindingType(component)).toBe(type);
});
test("infers range and multiple date controls from their authored props", () => {
expect(getCustomJsxBindingType("Calendar", { type: "range" })).toBe("string[]");
expect(getCustomJsxBindingType("Chip.Group", { multiple: true })).toBe("string[]");
expect(getCustomJsxBindingType("Text", {})).toBeNull();
});
});
@@ -0,0 +1,88 @@
```json
{
"$schema": "homarr-custom-widget-v2",
"name": "Pokédex",
"description": "Browse Pokémon from PokeAPI, view stats/types/abilities, and keep a list of favorites. Every section can be shown or hidden.",
"iconUrl": "https://raw.githubusercontent.com/PokeAPI/media/master/logo/pokeapi_256.png",
"sources": [
{
"id": "default",
"name": "PokeAPI",
"baseUrl": "https://pokeapi.co/api/v2",
"networkScope": "public",
"auth": { "type": "none" }
}
],
"requests": [
{
"id": "list",
"sourceId": "default",
"kind": "query",
"method": "GET",
"pathTemplate": "/pokemon",
"parameters": { "pokemonLimit": { "type": "number" } },
"queryTemplate": { "limit": { "$param": "pokemonLimit" }, "offset": 0 },
"auth": "inherit",
"minimumBoardPermission": "view",
"trigger": "load",
"invalidates": []
},
{
"id": "detail",
"sourceId": "default",
"kind": "query",
"method": "GET",
"pathTemplate": "/pokemon/{name}",
"parameters": { "name": { "type": "string" } },
"auth": "inherit",
"minimumBoardPermission": "view",
"trigger": "manual",
"invalidates": []
}
],
"optionsSchema": {
"type": "object",
"properties": {
"pokemonLimit": {
"type": "number",
"title": "Number of Pokémon to load",
"minimum": 1,
"maximum": 1000,
"x-homarr": { "control": "number" }
},
"showSprite": { "type": "boolean", "title": "Show sprite image", "x-homarr": { "control": "switch" } },
"showTypes": { "type": "boolean", "title": "Show type badges", "x-homarr": { "control": "switch" } },
"showStats": { "type": "boolean", "title": "Show base stats", "x-homarr": { "control": "switch" } },
"showAbilities": { "type": "boolean", "title": "Show abilities", "x-homarr": { "control": "switch" } },
"showPhysical": { "type": "boolean", "title": "Show height and weight", "x-homarr": { "control": "switch" } },
"showFavoritesBar": {
"type": "boolean",
"title": "Show favorites-only toggle",
"x-homarr": { "control": "switch" }
},
"accentColor": { "type": "string", "title": "Accent color", "x-homarr": { "control": "color" } }
},
"additionalProperties": false
},
"defaultOptions": {
"pokemonLimit": 151,
"showSprite": true,
"showTypes": true,
"showStats": true,
"showAbilities": true,
"showPhysical": true,
"showFavoritesBar": true,
"accentColor": "#e3350d"
},
"stateSchema": {
"type": "object",
"properties": {
"selectedPokemon": { "type": "string" },
"favorites": { "type": "array", "items": { "type": "string" } },
"favoritesOnly": { "type": "boolean" }
}
},
"defaultState": { "selectedPokemon": "", "favorites": [], "favoritesOnly": false },
"template": "**HOMARR\_TEMPLATE**"
}
```
+69 -11
View File
@@ -1,31 +1,89 @@
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
import { CUSTOM_WIDGET_STARTER } from "../core/examples";
import { parseCustomWidgetClipboard, parseCustomWidgetClipboardDetailed } from "../core/import";
import {
parseCustomWidgetAiResponse,
parseCustomWidgetClipboard,
parseCustomWidgetClipboardDetailed,
} from "../core/import";
describe("parseCustomWidgetClipboard", () => {
describe("Custom Widget imports", () => {
it("accepts the canonical raw JSON export", () => {
expect(parseCustomWidgetClipboard(JSON.stringify(CUSTOM_WIDGET_STARTER))?.$schema).toBe("homarr-custom-widget-v2");
});
it("combines widget.json and widget.jsx blocks", () => {
it("combines one widget.json block and one widget.jsx block", () => {
const manifest = { ...CUSTOM_WIDGET_STARTER, template: "__HOMARR_TEMPLATE__" };
const result = parseCustomWidgetClipboard(
const result = parseCustomWidgetAiResponse(
`\`\`\`json\n${JSON.stringify(manifest)}\n\`\`\`\n\`\`\`jsx\n<Text>{options.title}</Text>\n\`\`\``,
);
expect(result?.template).toBe("<Text>{options.title}</Text>");
expect(result).toMatchObject({ success: true, widget: { template: "<Text>{options.title}</Text>" } });
});
it("rejects removed display types and old exports", () => {
it("normalizes common AI parameter objects but keeps strict imports strict", () => {
const request = {
id: "detail",
sourceId: "default",
kind: "query",
method: "GET",
pathTemplate: "/pokemon/{name}",
parameters: { name: { type: "string" } },
auth: "inherit",
minimumBoardPermission: "view",
trigger: "manual",
};
const manifest = { ...CUSTOM_WIDGET_STARTER, requests: [request], template: "__HOMARR_TEMPLATE__" };
expect(parseCustomWidgetClipboardDetailed(JSON.stringify(manifest)).success).toBe(false);
const result = parseCustomWidgetAiResponse(
`\`\`\`json\n${JSON.stringify(manifest)}\n\`\`\`\n\`\`\`jsx\n<Text>Pokemon</Text>\n\`\`\``,
);
expect(result).toMatchObject({
success: true,
widget: { requests: [{ parameters: { name: "string" } }] },
warnings: [{ code: "NORMALIZED_PARAMETER_TYPE" }],
});
});
it("returns multiple actionable issues for the supplied malformed Pokemon response", async () => {
const response = await readFile(resolve(import.meta.dirname, "fixtures/pokedex-ai-response.md"), "utf8");
const result = parseCustomWidgetAiResponse(response);
expect(result).toMatchObject({ success: false });
if (result.success) throw new Error("Expected malformed response to fail");
expect(result.issues).toEqual(
expect.arrayContaining([
expect.objectContaining({ code: "INVALID_JSON" }),
expect.objectContaining({ code: "INVALID_JSON_ESCAPE" }),
expect.objectContaining({ code: "AI_JSX_BLOCK_REQUIRED" }),
expect.objectContaining({ code: "REMOVED_LOCAL_STATE" }),
]),
);
});
it("rejects removed fields, legacy displays, and missing JSX fences explicitly", () => {
expect(
parseCustomWidgetClipboard(JSON.stringify({ displayType: "raw", displayConfig: { type: "raw" } })),
).toBeNull();
const result = parseCustomWidgetAiResponse(
`\`\`\`json\n${JSON.stringify({ ...CUSTOM_WIDGET_STARTER, stateSchema: {} })}\n\`\`\``,
);
expect(result).toMatchObject({ success: false });
if (result.success) throw new Error("Expected removed state to fail");
expect(result.issues).toEqual(
expect.arrayContaining([
expect.objectContaining({ code: "REMOVED_LOCAL_STATE" }),
expect.objectContaining({ code: "AI_JSX_BLOCK_REQUIRED" }),
]),
);
});
it("returns an actionable validation issue for invalid AI output", () => {
expect(parseCustomWidgetClipboardDetailed(JSON.stringify({ $schema: "homarr-custom-widget-v2" }))).toEqual({
success: false,
error: expect.stringMatching(/name|sources/u),
});
it("returns all useful schema issues instead of a nullable failure", () => {
const result = parseCustomWidgetClipboardDetailed(JSON.stringify({ $schema: "homarr-custom-widget-v2" }));
expect(result).toMatchObject({ success: false });
if (result.success) throw new Error("Expected invalid widget to fail");
expect(result.issues.length).toBeGreaterThan(1);
expect(result.issues.some((issue) => issue.path?.includes("name"))).toBe(true);
});
});
@@ -37,8 +37,6 @@ const input: CreatePreviewSessionInput = {
template: "<Text>Preview</Text>",
optionsSchema: { type: "object", properties: {}, additionalProperties: false },
defaultOptions: {},
stateSchema: {},
defaultState: {},
};
describe("preview session service", () => {
@@ -4,11 +4,12 @@ import { act } from "react";
import type { ReactNode } from "react";
import { createRoot } from "react-dom/client";
import type { Root } from "react-dom/client";
import { MantineProvider, Text } from "@mantine/core";
import { MantineProvider } from "@mantine/core";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ActionButton, CustomJsxRenderer, CustomWidgetRuntimeProvider, SubFetch, ToggleSwitch } from "../runtime";
import { createCustomJsxComponents } from "../jsx";
import type {
CustomJsxRequestCapability,
CustomWidgetPublishedQueryState,
@@ -105,35 +106,33 @@ async function settle() {
}
describe("Custom Widget runtime ports", () => {
it("does not republish local state when equivalent default objects are rendered", async () => {
const onStateChange = vi.fn();
it("exposes temporary bindings through inputs without browser storage", async () => {
const setItem = vi.spyOn(Storage.prototype, "setItem");
const rendererMessages = {
noTemplate: "No template",
interactive: "Interactive",
networkCapabilities: "Network capabilities",
templateWarnings: (count: number) => `${count} warnings`,
};
const widget = () => (
const components = createCustomJsxComponents({
TablerIcon: (() => null) as never,
copyLabels: { copy: "Copy", copied: "Copied" },
});
const widget = (
<CustomJsxRenderer
template="<Text>{state.search}</Text>"
template={'<Stack><TextInput bind="search" defaultValue="containers"/><Text>{inputs.search}</Text></Stack>'}
data={{}}
stateSchema={{ search: "string" }}
defaultState={{ search: "containers" }}
onStateChange={onStateChange}
requestCapabilities={[]}
components={{ Text: Text as never }}
components={components}
createBindings={() => ({})}
messages={rendererMessages}
/>
);
await render(widget(), createPort());
await render(widget, createPort());
await settle();
expect(onStateChange).toHaveBeenCalledTimes(1);
await render(widget(), createPort());
await settle();
expect(onStateChange).toHaveBeenCalledTimes(1);
expect(host.textContent).toContain("containers");
expect(setItem).not.toHaveBeenCalled();
});
it("runs an automatic query once and publishes data to the declarative data root", async () => {
+23 -24
View File
@@ -1,5 +1,6 @@
import { describe, expect, test } from "vitest";
import { BUNDLED_CUSTOM_WIDGETS } from "../core/bundled-widgets";
import { customJsxExamples, CUSTOM_WIDGET_STARTER } from "../core/examples";
import { customJsxRequestSchema, customWidgetCreateSchema, customWidgetDefinitionSchema } from "../core/schema";
import { validateCustomWidgetOptions } from "../core/options";
@@ -9,6 +10,23 @@ describe("Custom JSX v2 validation", () => {
expect(() => customWidgetDefinitionSchema.parse(example.widget)).not.toThrow();
});
test.each(BUNDLED_CUSTOM_WIDGETS)("validates bundled definition $id", ({ widget }) => {
const result = customWidgetDefinitionSchema.safeParse(widget);
expect(result.success, result.error?.issues.map((issue) => issue.message).join("; ")).toBe(true);
});
test("keeps the bundled seed set stable, disabled-ready, and credential-free", () => {
expect(BUNDLED_CUSTOM_WIDGETS.map(({ id }) => id)).toEqual([
"seed-dog-facts",
"seed-currency-exchange",
"seed-jellyfin",
"seed-pokedex",
]);
for (const { widget } of BUNDLED_CUSTOM_WIDGETS) {
expect(JSON.stringify(widget)).not.toMatch(/"(?:apiKey|password|secret|token)"\s*:/iu);
}
});
test("rejects every legacy schema", () => {
expect(
customWidgetDefinitionSchema.safeParse({ ...CUSTOM_WIDGET_STARTER, $schema: "homarr-custom-widget-v3" }).success,
@@ -105,7 +123,7 @@ describe("Custom JSX v2 validation", () => {
).toBe(false);
});
test("accepts multi-source actions, dynamic options, conditions, and typed state", () => {
test("accepts multi-source actions, dynamic options, conditions, and temporary inputs", () => {
const result = customWidgetDefinitionSchema.safeParse({
...CUSTOM_WIDGET_STARTER,
sources: [
@@ -177,8 +195,6 @@ describe("Custom JSX v2 validation", () => {
then: { properties: { advancedColor: { type: "string" } } },
},
defaultOptions: { start: "today" },
stateSchema: { selectedDate: "date", search: "string", opened: "boolean", selectedEntities: "string[]" },
defaultState: { selectedDate: "2026-07-17", search: "", opened: false, selectedEntities: ["light.office"] },
template:
'<Stack><Calendar bind="selectedDate"/><ActionButton requestId="monitor-series" params={{ seriesId: 1 }}>Monitor</ActionButton></Stack>',
});
@@ -225,13 +241,7 @@ describe("Custom JSX v2 validation", () => {
},
}).success,
).toBe(false);
expect(
customWidgetDefinitionSchema.safeParse({
...CUSTOM_WIDGET_STARTER,
stateSchema: { accessToken: "string" },
defaultState: {},
}).success,
).toBe(false);
expect(customWidgetDefinitionSchema.safeParse({ ...CUSTOM_WIDGET_STARTER, stateSchema: {} }).success).toBe(false);
expect(
customWidgetDefinitionSchema.safeParse({
...CUSTOM_WIDGET_STARTER,
@@ -253,13 +263,6 @@ describe("Custom JSX v2 validation", () => {
staticHeaders: { "X-API-Key": "not-exportable" },
}).success,
).toBe(false);
expect(
customWidgetDefinitionSchema.safeParse({
...CUSTOM_WIDGET_STARTER,
stateSchema: { count: "number" },
defaultState: { count: "wrong" },
}).success,
).toBe(false);
expect(
customWidgetDefinitionSchema.safeParse({
...CUSTOM_WIDGET_STARTER,
@@ -299,27 +302,23 @@ describe("Custom JSX v2 validation", () => {
).toBe(false);
});
test("requires literal bindings to reference declared camel-case state", () => {
test("requires literal temporary input bindings", () => {
expect(
customWidgetDefinitionSchema.safeParse({
...CUSTOM_WIDGET_STARTER,
stateSchema: { selectedDate: "date" },
defaultState: { selectedDate: "2026-07-17" },
template: '<Calendar bind="selectedDate" />',
}).success,
).toBe(true);
expect(
customWidgetDefinitionSchema.safeParse({
...CUSTOM_WIDGET_STARTER,
stateSchema: { selectedDate: "date" },
template: '<Calendar bind="missingDate" />',
template: "<Calendar bind={inputs.selectedDate} />",
}).success,
).toBe(false);
expect(
customWidgetDefinitionSchema.safeParse({
...CUSTOM_WIDGET_STARTER,
stateSchema: { selectedDate: "date" },
template: "<Calendar bind={state.selectedDate} />",
template: "<Text>{state.search}</Text>",
}).success,
).toBe(false);
});
@@ -12,6 +12,13 @@ import {
renameCustomWidgetRequest,
} from "../workbench";
import { CUSTOM_WIDGET_STARTER } from "../core/examples";
import {
CUSTOM_WIDGET_OPTIONS_EXAMPLES,
CUSTOM_WIDGET_REQUEST_EXAMPLES,
getCustomWidgetDefaultOptionsJsonSchema,
getCustomWidgetOptionsJsonSchema,
getCustomWidgetRequestsJsonSchema,
} from "../core/schema-references";
describe("Custom Widget workbench contracts", () => {
it("always provides a valid starter template", () => {
@@ -41,6 +48,14 @@ describe("Custom Widget workbench contracts", () => {
);
});
it("generates schema references and examples from canonical validators", () => {
expect(getCustomWidgetRequestsJsonSchema()).toMatchObject({ type: "array" });
expect(getCustomWidgetOptionsJsonSchema()).toMatchObject({ type: "object" });
expect(getCustomWidgetDefaultOptionsJsonSchema()).toMatchObject({ type: "object" });
expect(analyzeRequestManifest(JSON.stringify(CUSTOM_WIDGET_REQUEST_EXAMPLES.full))).toEqual([]);
expect(CUSTOM_WIDGET_OPTIONS_EXAMPLES.full.defaults).toMatchObject({ limit: 20 });
});
it("validates named request references and rejects inline network capabilities", () => {
expect(analyzeJsxTemplate('<SubFetch requestId="known" />', { requestIds: ["known"] })).toEqual([]);
expect(analyzeJsxTemplate('<SubFetch requestId="missing" url="/api" />', { requestIds: ["known"] })).toEqual(
@@ -51,13 +66,13 @@ describe("Custom Widget workbench contracts", () => {
);
});
it("diagnoses dynamic and unknown local-state bindings", () => {
expect(analyzeJsxTemplate('<Calendar bind="selectedDate" />', { stateKeys: ["selectedDate"] })).toEqual([]);
expect(analyzeJsxTemplate('<Calendar bind="missing" />', { stateKeys: ["selectedDate"] })).toEqual([
expect.objectContaining({ code: "unknownStateBinding", value: "missing" }),
]);
expect(analyzeJsxTemplate("<Calendar bind={state.selectedDate} />", { stateKeys: ["selectedDate"] })).toEqual(
expect.arrayContaining([expect.objectContaining({ code: "dynamicStateBinding" })]),
it("accepts named temporary inputs and diagnoses dynamic bindings", () => {
expect(analyzeJsxTemplate('<Calendar bind="selectedDate" />')).toEqual([]);
expect(analyzeJsxTemplate("<Calendar bind={inputs.selectedDate} />")).toEqual(
expect.arrayContaining([expect.objectContaining({ code: "dynamicInputBinding" })]),
);
expect(analyzeJsxTemplate('<TextInput bind="filter"/><Switch bind="filter"/>')).toEqual(
expect.arrayContaining([expect.objectContaining({ code: "bindingTypeConflict", value: "filter" })]),
);
});
@@ -0,0 +1,20 @@
import { readdir, readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { describe, expect, test } from "vitest";
const languageDirectory = resolve(import.meta.dirname, "../../../translation/src/lang");
describe("Workshop navigation translations", () => {
test("defines the navbar and breadcrumb label in every locale", async () => {
const files = (await readdir(languageDirectory)).filter((file) => file.endsWith(".json"));
for (const file of files) {
const messages = JSON.parse(await readFile(resolve(languageDirectory, file), "utf8")) as {
management?: { navbar?: { items?: { workshop?: unknown } } };
navigationStructure?: { manage?: { workshop?: { label?: unknown } } };
};
expect(messages.management?.navbar?.items?.workshop, file).toBe("Workshop");
expect(messages.navigationStructure?.manage?.workshop?.label, file).toBe("Workshop");
}
});
});