feat(custom-widgets): complete Custom JSX v2 (#6390)

This commit is contained in:
Thomas authored and ajnart committed 2026-08-13 19:48:57 +02:00
1 parent 3a1245cbc5
commit acdfcbb18e
416 files changed
+37853 -18066

No files matched your search

@@ -0,0 +1,108 @@
import { describe, expect, it } from "vitest";
import { CUSTOM_WIDGET_AI_EVALUATION_CASES } from "../../scripts/ai-evaluation-cases";
import {
buildEvaluationPrompt,
buildRepairPrompt,
DEFAULT_GENERATOR_MODEL,
DEFAULT_JUDGE_MODEL,
getJudgeResponseFormat,
judgePasses,
parseJudgeResult,
} from "../../scripts/ai-evaluation";
import type { CustomWidgetJudgeResult } from "../../scripts/ai-evaluation";
const categoryNames = [
"schemaAndBindings",
"apiAndRequestDesign",
"runtimeCompatibility",
"goalFulfillment",
"visualQuality",
"responsiveAndTheme",
"loadingEmptyErrorSuccess",
"dailyUsefulness",
"complexityDiscipline",
"accessibility",
"actionSafety",
] as const;
const makeCategories = (score: number) =>
Object.fromEntries(categoryNames.map((name) => [name, score])) as CustomWidgetJudgeResult["categories"];
const makeReasons = () =>
Object.fromEntries(
categoryNames.map((name) => [name, `Concrete evidence for ${name}.`]),
) as CustomWidgetJudgeResult["categoryReasons"];
const makeJudgeResult = (score: number) => ({
total: score,
verdict: "pass" as const,
dailyUseDecision: "would-use-daily" as const,
categories: makeCategories(score),
categoryReasons: makeReasons(),
strengths: [],
problems: [],
fatalProblems: [],
highestImpactFixes: [],
});
describe("AI authoring evaluation", () => {
it("defines five distinct complex scenarios", () => {
expect(CUSTOM_WIDGET_AI_EVALUATION_CASES).toHaveLength(5);
expect(new Set(CUSTOM_WIDGET_AI_EVALUATION_CASES.map((entry) => entry.id)).size).toBe(5);
});
it("uses the exact clipboard prompt and grounded API notes", () => {
const testCase = CUSTOM_WIDGET_AI_EVALUATION_CASES.at(0);
if (!testCase) throw new Error("Expected at least one AI evaluation case");
const prompt = buildEvaluationPrompt(testCase);
expect(prompt.length).toBeLessThanOrEqual(12_000);
expect(prompt).toContain("Verified API notes");
expect(prompt).not.toContain("customWidget_validate");
expect(prompt).not.toContain("homarr://");
});
it("keeps validation failures concrete during repair", () => {
const prompt = buildRepairPrompt("original", "bad response", [
{ path: ["requests", "list", "path"], message: "Required" },
]);
expect(prompt).toContain("requests.list.path: Required");
expect(prompt).toContain("bad response");
});
it("uses the requested DeepSeek models and strict structured judge output", () => {
expect(DEFAULT_GENERATOR_MODEL).toBe("deepseek/deepseek-v4-pro");
expect(DEFAULT_JUDGE_MODEL).toBe("deepseek/deepseek-v4-flash");
const format = getJudgeResponseFormat();
expect(format.type).toBe("json_schema");
expect(format.json_schema.strict).toBe(true);
expect(format.json_schema.schema).toMatchObject({ type: "object", additionalProperties: false });
});
it("requires exceptional goal, design, practicality, and complexity scores", () => {
expect(judgePasses(makeJudgeResult(90))).toBe(true);
expect(
judgePasses({
...makeJudgeResult(92),
categories: { ...makeCategories(92), dailyUsefulness: 84 },
}),
).toBe(false);
expect(judgePasses({ ...makeJudgeResult(95), fatalProblems: ["A requested core action is missing."] })).toBe(false);
});
it("computes the weighted score and refuses inflated advisory verdicts", () => {
const parsed = parseJudgeResult(
JSON.stringify({
...makeJudgeResult(95),
total: 99,
categories: { ...makeCategories(95), visualQuality: 70 },
categoryReasons: makeReasons(),
strengths: [],
problems: [],
fatalProblems: [],
highestImpactFixes: [],
}),
);
expect(parsed.total).toBe(91);
expect(parsed.verdict).toBe("fail");
});
});
@@ -1,32 +1,48 @@
import { describe, expect, test } from "vitest";
import { describe, expect, it } from "vitest";
import { buildCustomWidgetAiPrompt } from "../core/ai-prompt";
import { buildCustomWidgetAiPrompt, CUSTOM_WIDGET_FINAL_OUTPUT_INSTRUCTION } from "../core/ai-prompt";
describe("buildCustomWidgetAiPrompt", () => {
test("describes the complete v2 named-request and render-child workflow", () => {
const prompt = buildCustomWidgetAiPrompt({ type: "object" }, '{"serviceId":"alpha"}');
expect(prompt).toContain('"$schema": "homarr-custom-widget-v3"');
expect(prompt).toContain("jsxApiVersion: 2");
expect(prompt).toContain("SubFetch can reference query requests only");
expect(prompt).toContain("Prefer the SubFetch function-as-children API");
expect(prompt).toContain('<SubFetch requestId="service-detail"');
expect(prompt).toContain("ActionButton and ToggleSwitch can reference action requests only");
expect(prompt).toContain("## Generated component reference");
expect(prompt).toContain("## Tested examples");
expect(prompt).toContain('{"serviceId":"alpha"}');
describe("AI prompt", () => {
it("is self-contained, request-first, and compact", () => {
const prompt = buildCustomWidgetAiPrompt(
undefined,
null,
null,
"Create a beautiful Pokédex",
"https://pokeapi.co/docs/v2",
);
expect(prompt.length).toBeLessThanOrEqual(12_000);
expect(prompt.indexOf("Create a beautiful Pokédex")).toBeLessThan(prompt.indexOf("Manifest contract"));
expect(prompt).toContain('"requests": {');
expect(prompt).toContain("{option:name}");
expect(prompt).toContain("Example — Service dashboard");
expect(prompt).toContain("Example — Search and action");
expect(prompt).toContain("visual hierarchy");
expect(prompt).toContain('template set exactly to "__HOMARR_TEMPLATE__"');
expect(prompt).toContain('trigger="manual"');
expect(prompt).toContain("never write `=> {` anywhere");
expect(prompt).toContain('"choicesFrom"');
expect(prompt).toContain("must not shadow the reserved roots");
expect(prompt.endsWith(CUSTOM_WIDGET_FINAL_OUTPUT_INSTRUCTION)).toBe(true);
});
test("keeps an existing multiline template readable and outside JSON escaping", () => {
const template = '<Stack gap="sm">\n <Text>{data.name}</Text>\n</Stack>';
const prompt = buildCustomWidgetAiPrompt({ type: "object" }, null, {
name: "Existing",
displayConfig: { type: "customJsx", jsxApiVersion: 2, template, requests: [] },
});
it("does not claim offline MCP tools or embed the component catalog", () => {
const prompt = buildCustomWidgetAiPrompt(undefined, null, null, "Build a widget");
expect(prompt).not.toContain("customWidget_validate");
expect(prompt).not.toContain("homarr://");
expect(prompt).not.toContain("OFFLINE BUNDLE");
expect(prompt.match(/Recommended components:/gu)).toHaveLength(1);
});
expect(prompt).toContain('"template": "__HOMARR_TEMPLATE__"');
expect(prompt).toContain(`\`\`\`jsx\n${template}\n\`\`\``);
expect(prompt).not.toContain(template.replaceAll("\n", "\\n"));
expect(prompt).toContain("PASTE_API_RESPONSE_HERE");
it("redacts secrets and preserves the final instruction when optional context is large", () => {
const prompt = buildCustomWidgetAiPrompt(
undefined,
JSON.stringify({ token: "sensitive", data: "x".repeat(20_000) }),
null,
"Build it",
);
expect(prompt).not.toContain("sensitive");
expect(prompt.endsWith(CUSTOM_WIDGET_FINAL_OUTPUT_INSTRUCTION)).toBe(true);
expect(prompt.length).toBeLessThanOrEqual(12_000);
});
});
@@ -0,0 +1,32 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
getCustomWidgetComponent,
getCustomWidgetComponentCatalog,
getCustomWidgetSkill,
getCustomWidgetSkillContent,
} from "../core/authoring-resources";
describe("authoring resources", () => {
it("returns the concise skills.sh discovery shape", () => {
const skill = getCustomWidgetSkill();
expect(skill).toMatchObject({ name: "homarr-custom-widget", version: "2.1.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(),
);
});
it("keeps the full catalog retrieval-based", () => {
expect(getCustomWidgetComponentCatalog().components.length).toBeGreaterThan(100);
expect(getCustomWidgetComponent("Stack")?.name).toBe("Stack");
expect(getCustomWidgetComponent("Icon")?.knownValues?.name.length).toBeGreaterThan(100);
});
});
@@ -0,0 +1,141 @@
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { describe, expect, test } from "vitest";
import {
customJsxAuthoringCatalog,
getCustomJsxPropType,
resolveCustomJsxPropDescriptor,
} from "../core/component-catalog";
import {
CUSTOM_JSX_AUTHORING_CATALOG_SCHEMA_VERSION,
CUSTOM_WIDGET_AUTHORING_VERSION,
} from "../core/component-catalog-types";
import { customJsxComponentRegistry } from "../core/component-registry";
const repositoryRoot = resolve(import.meta.dirname, "../../../..");
const catalogPath = resolve(repositoryRoot, "packages/custom-widgets/src/core/component-catalog.generated.json");
const publicCatalogPath = resolve(repositoryRoot, "apps/docs/static/custom-widgets/component-catalog-v1.json");
// Keep a reviewed ceiling close to the generated artifact so accidental metadata
// duplication must receive explicit review.
const MAX_CATALOG_BYTES = 800 * 1024;
describe("Custom JSX authoring catalog", () => {
test("is versioned against the installed authoring surface", async () => {
const mantinePackage = JSON.parse(
await readFile(resolve(repositoryRoot, "node_modules/@mantine/core/package.json"), "utf8"),
) as { version: string };
expect(customJsxAuthoringCatalog.schemaVersion).toBe(CUSTOM_JSX_AUTHORING_CATALOG_SCHEMA_VERSION);
expect(customJsxAuthoringCatalog.mantineVersion).toBe(mantinePackage.version);
expect(customJsxAuthoringCatalog.customWidgetVersion).toBe(CUSTOM_WIDGET_AUTHORING_VERSION);
});
test("publishes deterministic unique component and prop records", () => {
const componentNames = customJsxAuthoringCatalog.components.map(({ name }) => name);
const globalPropNames = customJsxAuthoringCatalog.globalProps.map(({ name }) => name);
const types = customJsxAuthoringCatalog.types;
expect(new Set(componentNames).size).toBe(componentNames.length);
expect(new Set(globalPropNames).size).toBe(globalPropNames.length);
expect(new Set(types).size).toBe(types.length);
expect(componentNames).toEqual([...componentNames].toSorted());
expect(globalPropNames).toEqual([...globalPropNames].toSorted());
expect(types).toEqual([...types].toSorted());
const globals = new Map(customJsxAuthoringCatalog.globalProps.map((prop) => [prop.name, prop]));
for (const component of customJsxAuthoringCatalog.components) {
const propNames = component.props.map(({ name }) => name);
expect(new Set(propNames).size, component.name).toBe(propNames.length);
expect(propNames, component.name).toEqual([...propNames].toSorted());
for (const prop of component.props) {
expect(prop.source, `${component.name}.${prop.name}`).toBe("component");
expect(prop.typeRef, `${component.name}.${prop.name}`).toBeGreaterThanOrEqual(0);
expect(prop.typeRef, `${component.name}.${prop.name}`).toBeLessThan(types.length);
expect(getCustomJsxPropType(prop), `${component.name}.${prop.name}`).not.toBe("");
const global = globals.get(prop.name);
if (global) {
expect(
prop.required !== global.required ||
getCustomJsxPropType(prop) !== getCustomJsxPropType(global) ||
JSON.stringify(prop.literalValues ?? []) !== JSON.stringify(global.literalValues ?? []),
`${component.name}.${prop.name} must be a meaningful global-prop override`,
).toBe(true);
}
}
}
for (const prop of customJsxAuthoringCatalog.globalProps) {
expect(prop.source, prop.name).toBe("global");
expect(prop.required, prop.name).toBe(false);
expect(prop.typeRef, prop.name).toBeGreaterThanOrEqual(0);
expect(prop.typeRef, prop.name).toBeLessThan(types.length);
expect(resolveCustomJsxPropDescriptor(prop).type, prop.name).not.toBe("");
}
});
test("covers the complete runtime component registry", () => {
const catalogNames = customJsxAuthoringCatalog.components.map(({ name }) => name);
const runtimeNames = customJsxComponentRegistry.map(({ name }) => name).toSorted();
expect(catalogNames).toEqual(runtimeNames);
});
test("includes prop APIs, bindings, accessibility, and denied reasons", () => {
const textInput = customJsxAuthoringCatalog.components.find(({ name }) => name === "TextInput");
const label = textInput?.props.find(({ name }) => name === "label");
expect(label && getCustomJsxPropType(label)).toContain("ReactNode");
expect(label?.required).toBe(false);
expect(label?.description).toBeTruthy();
expect(textInput?.bind).toEqual({ type: "string", initialProp: "defaultValue" });
const switchComponent = customJsxAuthoringCatalog.components.find(({ name }) => name === "Switch");
expect(switchComponent?.bind).toEqual({ type: "boolean", initialProp: "defaultChecked" });
expect(customJsxAuthoringCatalog.components.some(({ name }) => name === "RecursiveList")).toBe(false);
const portal = customJsxAuthoringCatalog.components.find(({ name }) => name === "Portal");
expect(portal?.safety).toBe("denied");
expect(portal?.deniedReason).toBeTruthy();
expect(portal?.props).toEqual([]);
const tableOfContents = customJsxAuthoringCatalog.components.find(({ name }) => name === "TableOfContents");
expect(tableOfContents?.safety).toBe("denied");
expect(tableOfContents?.deniedReason).toContain("outside the widget root");
const tooltip = customJsxAuthoringCatalog.components.find(({ name }) => name === "Tooltip");
expect(tooltip?.blockedProps).toContainEqual(
expect.objectContaining({ name: "target", reason: expect.stringContaining("outside the widget root") }),
);
expect(tooltip?.props.map(({ name }) => name)).not.toContain("target");
const datePickerInput = customJsxAuthoringCatalog.components.find(({ name }) => name === "DatePickerInput");
expect(datePickerInput?.blockedProps.map(({ name }) => name)).toEqual(["dropdownType", "modalProps"]);
});
test("documents blocked prop capabilities once and excludes them from APIs", () => {
const blockedNames = customJsxAuthoringCatalog.blockedCapabilities.map(({ name }) => name);
expect(new Set(blockedNames).size).toBe(blockedNames.length);
expect(customJsxAuthoringCatalog.blockedCapabilities).toContainEqual(
expect.objectContaining({ kind: "prop", name: "dangerouslySetInnerHTML" }),
);
expect(customJsxAuthoringCatalog.blockedCapabilities).toContainEqual(
expect.objectContaining({ kind: "prop-pattern", name: "on*" }),
);
const authoredProps = [
...customJsxAuthoringCatalog.globalProps,
...customJsxAuthoringCatalog.components.flatMap(({ props }) => props),
];
expect(authoredProps.some(({ name }) => /^on/iu.test(name))).toBe(false);
expect(authoredProps.some(({ name }) => name === "dangerouslySetInnerHTML")).toBe(false);
});
test("stays within the reviewed compact byte budget", async () => {
const catalog = await readFile(catalogPath);
expect(catalog.byteLength).toBeLessThanOrEqual(MAX_CATALOG_BYTES);
});
test("publishes a byte-identical static documentation copy", async () => {
const [catalog, publicCatalog] = await Promise.all([readFile(catalogPath), readFile(publicCatalogPath)]);
expect(publicCatalog.equals(catalog)).toBe(true);
});
});
@@ -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",
@@ -183,12 +187,14 @@ describe("customJsxComponentRegistry", () => {
});
test.each([
["ChartTooltip", "allowed"],
["ChartLegend", "allowed"],
["VisuallyHidden", "allowed"],
["ChartTooltip", "wrapped"],
["ChartLegend", "wrapped"],
["VisuallyHidden", "wrapped"],
["SubFetch", "wrapped"],
["Portal", "denied"],
["TextInput", "denied"],
["TextInput", "wrapped"],
["ModalRoot", "denied"],
["AppShellMain", "denied"],
] as const)("classifies %s as %s", (name, safety) => {
expect(customJsxComponentByName.get(name)?.safety).toBe(safety);
});
@@ -201,12 +207,13 @@ describe("customJsxComponentRegistry", () => {
}
});
test("publishes complete authoring metadata for every component", () => {
test("publishes the minimal safe runtime metadata for every component", () => {
for (const component of customJsxComponentRegistry) {
expect(component.documentationUrl, component.name).toMatch(/^https:\/\//);
expect(component.supportedProps, component.name).toBeInstanceOf(Array);
expect(component.subcomponents, component.name).toBeInstanceOf(Array);
expect(component.accessibilityRequirements, component.name).toBeInstanceOf(Array);
expect(component.blockedProps, component.name).toBeInstanceOf(Array);
expect(component.supportedProps, component.name).not.toContain("renderOption");
expect(component.supportedProps, component.name).not.toContain("rootRef");
expect(component.supportedProps, component.name).not.toContain("popoverTarget");
}
});
@@ -226,4 +233,48 @@ 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"],
["DatePicker", "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", () => {
for (const component of [
"DatePicker",
"DatePickerInput",
"MonthPicker",
"MonthPickerInput",
"YearPicker",
"YearPickerInput",
]) {
expect(getCustomJsxBindingType(component, { type: "multiple" })).toBe("string[]");
expect(getCustomJsxBindingType(component, { type: "range" })).toBe("string[]");
}
expect(getCustomJsxBindingType("DateTimePicker", { type: "range" })).toBe("string[]");
expect(getCustomJsxBindingType("DateTimePicker", { type: "multiple" })).toBe("string");
expect(getCustomJsxBindingType("InlineDateTimePicker", { type: "range" })).toBe("string[]");
expect(getCustomJsxBindingType("InlineDateTimePicker", { type: "multiple" })).toBe("string");
expect(getCustomJsxBindingType("Accordion", { multiple: true })).toBe("string[]");
expect(getCustomJsxBindingType("TreeSelect", { mode: "multiple" })).toBe("string[]");
expect(getCustomJsxBindingType("TreeSelect", { mode: "checkbox" })).toBe("string[]");
expect(getCustomJsxBindingType("Chip.Group", { multiple: true })).toBe("string[]");
expect(getCustomJsxBindingType("ChipGroup", { multiple: true })).toBe("string[]");
expect(getCustomJsxBindingType("RadioGroup")).toBe("string");
expect(getCustomJsxBindingType("CheckboxGroup")).toBe("string[]");
expect(getCustomJsxBindingType("SwitchGroup")).toBe("string[]");
expect(getCustomJsxBindingType("Calendar")).toBeNull();
expect(getCustomJsxBindingType("HoverCard")).toBeNull();
expect(getCustomJsxBindingType("Text", {})).toBeNull();
});
});
@@ -1,135 +0,0 @@
import { describe, expect, test } from "vitest";
import { extractDisplayData } from "../core/display-data";
import type { DisplayConfig } from "../core/schema";
const response = {
value: 42,
status: "online",
total: 100,
rows: [
{ name: "Alpha", count: 2 },
{ name: "Beta", count: 3 },
],
};
const extract = (config: DisplayConfig) => extractDisplayData(response, config.type, config);
describe("display data extraction", () => {
test("extracts a single value", () => {
expect(extract({ type: "singleValue", jsonPath: "$.value", label: "Value", unit: "%" })).toMatchObject({
type: "singleValue",
value: 42,
valueSize: "lg",
labelPosition: "below",
});
});
test("extracts key-value mappings", () => {
expect(
extract({
type: "keyValue",
mappings: [
{ label: "Value", jsonPath: "$.value", unit: "%" },
{ label: "Status", jsonPath: "$.status", unit: "" },
],
}),
).toMatchObject({ entries: [{ value: 42 }, { value: "online" }], layout: "list", columns: 2 });
});
test("extracts table rows", () => {
expect(
extract({
type: "table",
tablePath: "$.rows",
columns: [
{ header: "Name", jsonPath: "$.name" },
{ header: "Count", jsonPath: "$.count" },
],
}),
).toMatchObject({
columns: ["Name", "Count"],
rows: [
["Alpha", 2],
["Beta", 3],
],
});
});
test("extracts stat cards", () => {
expect(
extract({ type: "statGrid", items: [{ label: "Value", jsonPath: "$.value", unit: "", color: "red" }] }),
).toMatchObject({ items: [{ value: 42, color: "red" }], columns: 2, cardStyle: "filled" });
});
test("extracts progress values and maxima", () => {
expect(
extract({
type: "progressBars",
bars: [{ label: "Usage", valuePath: "$.value", maxPath: "$.total", unit: "%" }],
}),
).toMatchObject({ bars: [{ value: 42, max: 100, color: "blue" }], showPercentage: true, barSize: "md" });
});
test("classifies status values", () => {
expect(
extract({
type: "statusIndicator",
items: [{ label: "Service", jsonPath: "$.status", goodValues: ["ONLINE"] }],
}),
).toMatchObject({ items: [{ value: "online", isGood: true }], layout: "list", dotSize: "md" });
});
test("extracts count grids", () => {
expect(extract({ type: "countGrid", items: [{ label: "Count", jsonPath: "$.value", unit: "" }] })).toMatchObject({
items: [{ value: 42 }],
columns: 2,
valueSize: "md",
});
});
test("extracts raw paths", () => {
expect(extract({ type: "raw", jsonPath: "$.rows", maxHeight: 200 })).toEqual({
type: "raw",
data: response.rows,
maxHeight: 200,
});
});
test("normalizes action button defaults", () => {
expect(extract({ type: "actionButton", buttonLabel: "Run" })).toEqual({
type: "actionButton",
buttonLabel: "Run",
buttonColor: "blue",
confirmText: "",
successMessage: "",
});
});
test("passes Custom JSX data and capabilities through", () => {
expect(
extract({
type: "customJsx",
jsxApiVersion: 2,
template: "<Text>{data.value}</Text>",
networkScope: "public",
requests: [
{
id: "detail",
kind: "query",
method: "GET",
pathTemplate: "/detail",
parameters: {},
auth: "none",
minimumBoardPermission: "view",
},
],
}),
).toMatchObject({
type: "customJsx",
data: response,
jsxApiVersion: 2,
requestCapabilities: [{ id: "detail", kind: "query", method: "GET", minimumBoardPermission: "view" }],
});
});
});
@@ -1,77 +0,0 @@
import { describe, expect, test } from "vitest";
import { buildDisplayConfigFromFormValues, buildDisplayFormValues, CUSTOM_JSX_STARTER } from "../core/display-form";
import { customWidgetDisplayTypes, displayConfigSchema } from "../core/schema";
const requiredFormValues = (displayType: string) => {
const values = buildDisplayFormValues(displayType, {});
values.label = "Value";
values.mappings = [{ label: "CPU", jsonPath: "$.cpu", unit: "%" }];
values.columns = [{ header: "Name", jsonPath: "$.name" }];
values.statGridItems = [{ label: "CPU", jsonPath: "$.cpu", unit: "%", color: "blue" }];
values.progressBars = [{ label: "Disk", valuePath: "$.used", maxPath: "$.total", unit: "GB", color: "blue" }];
values.statusItems = [{ label: "Service", jsonPath: "$.status", goodValues: "online, true" }];
values.countGridItems = [{ label: "Users", jsonPath: "$.users", unit: "" }];
values.buttonLabel = "Execute";
values.template = CUSTOM_JSX_STARTER;
return values;
};
describe("custom-widget display form contracts", () => {
test.each(customWidgetDisplayTypes)("round-trips the %s display through one canonical transform", (displayType) => {
const initialForm = requiredFormValues(displayType);
const config = buildDisplayConfigFromFormValues(initialForm);
const parsed = displayConfigSchema.parse(config);
const hydratedForm = buildDisplayFormValues(displayType, parsed);
const rebuilt = buildDisplayConfigFromFormValues(hydratedForm);
expect(rebuilt).toEqual(parsed);
});
test("preserves display-only v1 Custom JSX without adding network capabilities", () => {
const values = requiredFormValues("customJsx");
values.jsxApiVersion = "1";
values.requestManifest = JSON.stringify([
{ id: "unsafe", kind: "action", method: "POST", pathTemplate: "/action" },
]);
expect(buildDisplayConfigFromFormValues(values)).toEqual({
type: "customJsx",
template: CUSTOM_JSX_STARTER,
});
});
test("validates the v2 request manifest while serializing form state", () => {
const values = requiredFormValues("customJsx");
values.jsxApiVersion = "2";
values.requestManifest = JSON.stringify([
{
id: "status",
kind: "query",
method: "GET",
pathTemplate: "/status/{id}",
parameters: { id: "string" },
auth: "none",
minimumBoardPermission: "view",
},
]);
expect(buildDisplayConfigFromFormValues(values)).toMatchObject({
type: "customJsx",
jsxApiVersion: 2,
requests: [{ id: "status", kind: "query", method: "GET" }],
});
});
test("does not throw on invalid request manifest JSON while serializing v2 form state", () => {
const values = requiredFormValues("customJsx");
values.jsxApiVersion = "2";
for (const invalidManifest of ["[invalid", "{not an array}", "", '{"id":1}', "null"]) {
values.requestManifest = invalidManifest;
expect(() => buildDisplayConfigFromFormValues(values)).not.toThrow();
const config = buildDisplayConfigFromFormValues(values) as { requests?: unknown };
expect(config.requests).toEqual([]);
}
});
});
@@ -0,0 +1,62 @@
import type { HomarrCustomWidgetV2Input } from "../../core/custom-jsx-schema";
export const PORTAINER_REFERENCE_WIDGET: HomarrCustomWidgetV2Input = {
$schema: "homarr-custom-widget-v2",
name: "Portainer containers",
description: "Monitor and control Docker containers through Portainer.",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/portainer.svg",
sources: {
default: {
name: "Portainer",
baseUrl: "https://portainer.example.com",
networkScope: "private",
auth: { type: "apiKeyHeader", name: "X-API-Key" },
},
},
requests: {
containers: {
path: "/api/endpoints/{option:endpointId}/docker/containers/json",
query: { all: { $option: "showAll" } },
cacheSeconds: 20,
},
start: {
kind: "action",
method: "POST",
path: "/api/endpoints/{option:endpointId}/docker/containers/{param:id}/start",
confirmation: "Start this container?",
invalidates: ["containers"],
},
stop: {
kind: "action",
method: "POST",
path: "/api/endpoints/{option:endpointId}/docker/containers/{param:id}/stop",
confirmation: { title: "Stop container", message: "The service will become unavailable.", destructive: true },
invalidates: ["containers"],
},
restart: {
kind: "action",
method: "POST",
path: "/api/endpoints/{option:endpointId}/docker/containers/{param:id}/restart",
confirmation: "Restart this container?",
invalidates: ["containers"],
},
},
options: {
endpointId: { label: "Environment ID", control: "number", default: 2, min: 1 },
showAll: { label: "Include stopped containers", control: "switch", default: true },
},
template: `<Stack gap="md" p="md" h="100%">
<Group justify="space-between" align="flex-start">
<Stack gap={2}><Group gap="xs"><ThemeIcon color="blue" variant="light"><Icon name="brand-docker" /></ThemeIcon><Title order={3}>Containers</Title></Group><Text size="xs" c="dimmed">Portainer environment {options.endpointId}</Text></Stack>
<RefreshButton />
</Group>
{status.containers?.loading ? <Stack gap="xs"><Skeleton height={68} radius="md" /><Skeleton height={68} radius="md" /><Skeleton height={68} radius="md" /></Stack> : status.containers?.error ? <Alert color="red" title="Portainer is unavailable">{status.containers.error}</Alert> : (data.containers ?? []).length === 0 ? <Alert color="gray" title="No containers">No containers matched this environment.</Alert> : <Stack gap="md">
<SimpleGrid cols={{ base: 1, xs: 3 }}>
<Paper withBorder p="sm" radius="md"><Text size="xs" c="dimmed">Total</Text><Text size="xl" fw={700}>{data.containers.length}</Text></Paper>
<Paper withBorder p="sm" radius="md"><Text size="xs" c="dimmed">Running</Text><Text size="xl" fw={700} c="green">{data.containers.filter(container => container.State === "running").length}</Text></Paper>
<Paper withBorder p="sm" radius="md"><Text size="xs" c="dimmed">Stopped</Text><Text size="xl" fw={700} c="dimmed">{data.containers.filter(container => container.State !== "running").length}</Text></Paper>
</SimpleGrid>
<ScrollArea h={230} type="auto"><Stack gap="xs">{data.containers.map(container => <Paper key={container.Id} withBorder p="sm" radius="md"><Group justify="space-between" align="flex-start" wrap="wrap"><Stack gap={3}><Group gap="xs"><Text fw={650}>{container.Names?.[0]?.replace("/", "") ?? container.Id.slice(0, 12)}</Text><Badge color={container.State === "running" ? "green" : "gray"} variant="light">{container.State}</Badge></Group><Text size="xs" c="dimmed" lineClamp={1}>{container.Image}</Text><Text size="xs" c="dimmed">{container.Status}</Text></Stack><Group gap="xs">{container.State === "running" ? <ActionButton requestId="stop" params={{ id: container.Id }} color="red" variant="light">Stop</ActionButton> : <ActionButton requestId="start" params={{ id: container.Id }} color="green" variant="light">Start</ActionButton>}<ActionButton requestId="restart" params={{ id: container.Id }} variant="light">Restart</ActionButton></Group></Group></Paper>)}</Stack></ScrollArea>
</Stack>}
</Stack>`,
};
@@ -1,23 +0,0 @@
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { describe, expect, test } from "vitest";
import { customJsxComponentRegistry } from "../core";
describe("generated Custom JSX documentation", () => {
test("matches the canonical component registry", async () => {
const path = resolve(import.meta.dirname, "../../../../apps/docs/src/generated/custom-jsx-components.json");
const generated = JSON.parse(await readFile(path, "utf8")) as unknown;
const expected = customJsxComponentRegistry.map(
({ name, package: packageName, category, safety, reason, documentationUrl }) => ({
name,
package: packageName,
category,
safety,
reason,
documentationUrl,
}),
);
expect(generated).toEqual(expected);
});
});
+38 -54
View File
@@ -1,68 +1,52 @@
import { describe, expect, it } from "vitest";
import { parseCustomWidgetClipboard } from "../core/import";
import { customWidgetImportSchema } from "../core/schema";
import {
CUSTOM_WIDGET_STARTER,
getImportReview,
parseCustomWidgetAiResponse,
parseCustomWidgetClipboardDetailed,
} from "../core";
const widget = {
$schema: "homarr-custom-widget-v3",
name: "Example",
url: "https://example.com/api",
authType: "none",
method: "GET",
displayType: "customJsx",
displayConfig: {
type: "customJsx",
jsxApiVersion: 2,
networkScope: "public",
requests: [],
template: "__HOMARR_TEMPLATE__",
},
};
const response = (manifest: unknown, jsx = "<Text>Ready</Text>") =>
`\`\`\`json\n${JSON.stringify(manifest, null, 2)}\n\`\`\`\n\`\`\`jsx\n${jsx}\n\`\`\``;
describe("parseCustomWidgetClipboard", () => {
it("keeps supporting raw JSON imports", () => {
const result = parseCustomWidgetClipboard(JSON.stringify({ ...widget, displayType: "raw" }));
expect(result?.name).toBe("Example");
describe("Custom Widget imports", () => {
it("accepts exactly one lean manifest and JSX block", () => {
const result = parseCustomWidgetAiResponse(response({ ...CUSTOM_WIDGET_STARTER, template: "__HOMARR_TEMPLATE__" }));
expect(result.success).toBe(true);
if (result.success) expect(result.widget.template).toBe("<Text>Ready</Text>");
});
it("keeps accepting the v2 import envelope", () => {
expect(
customWidgetImportSchema.parse({
...widget,
$schema: "homarr-custom-widget-v2",
displayType: "raw",
displayConfig: { type: "raw", jsonPath: "$", maxHeight: 300 },
}).$schema,
).toBe("homarr-custom-widget-v2");
});
it("combines a JSON metadata block with a readable JSX block", () => {
const result = parseCustomWidgetClipboard(`Here is the widget:
\`\`\`json
${JSON.stringify(widget, null, 2)}
\`\`\`
\`\`\`jsx
<Stack gap="sm">
<Text>{data.name}</Text>
</Stack>
\`\`\``);
expect((result?.displayConfig as Record<string, unknown> | undefined)?.template).toBe(
'<Stack gap="sm">\n <Text>{data.name}</Text>\n</Stack>',
it("rejects the unreleased superseded schema without normalization", () => {
const result = parseCustomWidgetAiResponse(
response({
...CUSTOM_WIDGET_STARTER,
sources: Object.values(CUSTOM_WIDGET_STARTER.sources),
requests: [],
optionsSchema: {},
defaultOptions: {},
template: "__HOMARR_TEMPLATE__",
}),
);
expect(result.success).toBe(false);
});
it("accepts case-insensitive fenced languages and CRLF line endings", () => {
const result = parseCustomWidgetClipboard(
`\`\`\`JSON\r\n${JSON.stringify(widget)}\r\n\`\`\`\r\n\`\`\`TSX\r\n<Text>{data.name}</Text>\r\n\`\`\``,
it("reports missing fences and removed local state", () => {
expect(parseCustomWidgetAiResponse(JSON.stringify(CUSTOM_WIDGET_STARTER)).success).toBe(false);
const result = parseCustomWidgetAiResponse(
response({ ...CUSTOM_WIDGET_STARTER, stateSchema: {}, template: "__HOMARR_TEMPLATE__" }),
);
expect((result?.displayConfig as Record<string, unknown> | undefined)?.template).toBe("<Text>{data.name}</Text>");
expect(result.success).toBe(false);
if (!result.success) expect(result.issues.some(({ code }) => code === "REMOVED_LOCAL_STATE")).toBe(true);
});
it("rejects an unterminated fence with a large whitespace payload", () => {
expect(parseCustomWidgetClipboard(`\`\`\`json\n${" ".repeat(250_000)}`)).toBeNull();
it("returns a capability review from keyed records", () => {
const review = getImportReview(CUSTOM_WIDGET_STARTER);
expect(review).toMatchObject({ origins: ["https://example.com"], authTypes: ["none"], hasActions: false });
});
it("keeps strict clipboard import compatible with canonical exports", () => {
const result = parseCustomWidgetClipboardDetailed(JSON.stringify(CUSTOM_WIDGET_STARTER));
expect(result.success).toBe(true);
});
});
@@ -1,6 +1,9 @@
import { describe, expect, test } from "vitest";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { validateCustomJsxTemplate } from "../jsx/analyzer";
import { createCustomJsxBindings } from "../jsx/bindings";
import { renderSafeJsx, sanitizeCustomJsxProps } from "../jsx/interpreter";
import { CUSTOM_JSX_BLOCKED_PROPERTIES, CUSTOM_JSX_BLOCKED_PROPS, CUSTOM_JSX_LIMITS } from "../jsx/policy";
@@ -8,6 +11,47 @@ const components = { Text: () => null };
const bindings = { data: Object.create(null) as Record<string, unknown> };
describe("shared Custom JSX policy", () => {
test.each(['status.list === "loading"', '"success" === status.list', 'status.list !== "error"'])(
"rejects string request-status comparisons in %s",
(expression) => {
expect(validateCustomJsxTemplate(`<Text>{${expression}}</Text>`)).toEqual(
expect.arrayContaining([
expect.objectContaining({
severity: "error",
message: expect.stringContaining("INVALID_STATUS_COMPARISON: status.list is an object"),
}),
]),
);
},
);
test("accepts request-status object fields", () => {
const diagnostics = validateCustomJsxTemplate(
"<Text>{status.list?.loading ? 'Loading' : status.list?.ok === false ? status.list.error : 'Ready'}</Text>",
);
expect(diagnostics.filter(({ severity }) => severity === "error")).toEqual([]);
});
test("accepts canonical Mantine compound component names", () => {
const diagnostics = validateCustomJsxTemplate(
'<Radio.Group value="one"><Radio.Card value="one"><Radio.Indicator /></Radio.Card></Radio.Group>',
);
expect(diagnostics.filter(({ severity }) => severity === "error")).toEqual([]);
});
test("accepts the concise Icon alias used by the AI prompt", () => {
expect(validateCustomJsxTemplate('<ThemeIcon><Icon name="brand-docker" /></ThemeIcon>')).toEqual([]);
});
test("accepts common bounded formatting and collection operations", () => {
expect(
validateCustomJsxTemplate(
'<Text>{[1, 2].concat([3]).flatMap(value => [value]).map(value => value.toLocaleString()).join(", ")}</Text>',
),
).toEqual([]);
expect(validateCustomJsxTemplate('<Text>{"b".localeCompare("a")}</Text>')).toEqual([]);
});
test.each([...CUSTOM_JSX_BLOCKED_PROPERTIES])(
"blocks reflective property %s in diagnostics and runtime",
(property) => {
@@ -28,4 +72,314 @@ describe("shared Custom JSX policy", () => {
const template = `<Text>{${"!".repeat(CUSTOM_JSX_LIMITS.astDepth + 1)}data}</Text>`;
expect(validateCustomJsxTemplate(template).some(({ message }) => message.includes("depth limit"))).toBe(true);
});
test("passes unknown ordinary props through as advisories and blocks capabilities explicitly", () => {
const diagnostics = validateCustomJsxTemplate('<Text futureMantineProp="yes">Safe</Text>');
expect(diagnostics).toEqual(
expect.arrayContaining([
expect.objectContaining({ severity: "warning", message: expect.stringContaining("UNKNOWN_MANTINE_PROP") }),
]),
);
expect(sanitizeCustomJsxProps({ futureMantineProp: "yes" }, "Text")).toHaveProperty("futureMantineProp", "yes");
expect(validateCustomJsxTemplate("<Text onClick={() => null}>Unsafe</Text>")).toEqual(
expect.arrayContaining([
expect.objectContaining({ severity: "error", message: expect.stringContaining("BLOCKED_CAPABILITY") }),
]),
);
expect(validateCustomJsxTemplate("<Text valueFormatter={(value) => value}>Unsafe</Text>")).toEqual(
expect.arrayContaining([
expect.objectContaining({ severity: "error", message: expect.stringContaining("Callback prop") }),
]),
);
expect(validateCustomJsxTemplate('<Anchor href="javascript:alert(1)">Unsafe</Anchor>')).toEqual(
expect.arrayContaining([
expect.objectContaining({ severity: "error", message: expect.stringContaining("INVALID_PROP_VALUE") }),
]),
);
expect(validateCustomJsxTemplate('<Box pos="fixed" top={0}>Unsafe</Box>')).toEqual(
expect.arrayContaining([
expect.objectContaining({ severity: "error", message: expect.stringContaining("BLOCKED_CAPABILITY") }),
]),
);
expect(sanitizeCustomJsxProps({ pos: "fixed", top: 0, unstyled: true })).toEqual({ unstyled: true });
expect(sanitizeCustomJsxProps({ position: "top", unstyled: true }, "Tooltip")).toEqual({
position: "top",
unstyled: true,
});
});
test("rejects unsupported date-time multiple mode in analysis and at the renderer boundary", () => {
expect(validateCustomJsxTemplate('<DateTimePicker type="multiple" />')).toEqual(
expect.arrayContaining([
expect.objectContaining({ severity: "error", message: expect.stringContaining("INVALID_PROP_VALUE") }),
]),
);
expect(sanitizeCustomJsxProps({ type: "multiple", label: "Date" }, "DateTimePicker")).toEqual({
label: "Date",
});
const rendered = renderSafeJsx({
template: "<DateTimePicker {...data.props} />",
components: { ...components, DateTimePicker: (() => null) as never },
bindings: { data: { props: { type: "multiple", label: "Date" } } },
});
expect(rendered.warnings).toEqual([expect.stringContaining("INVALID_PROP_VALUE")]);
});
test("reports and removes component-specific blocked props from explicit and spread JSX", () => {
expect(validateCustomJsxTemplate('<Tooltip target="#outside">Unsafe</Tooltip>')).toEqual(
expect.arrayContaining([
expect.objectContaining({ severity: "error", message: expect.stringContaining("BLOCKED_CAPABILITY") }),
]),
);
expect(sanitizeCustomJsxProps({ target: "#outside", label: "Safe" }, "Tooltip")).toEqual({ label: "Safe" });
const rendered = renderSafeJsx({
template: "<Tooltip {...data.props}>Safe</Tooltip>",
components: { ...components, Tooltip: (() => null) as never },
bindings: { data: { props: { target: "#outside", label: "Safe" } } },
});
expect(rendered.warnings).toEqual([
expect.stringContaining("BLOCKED_CAPABILITY: Prop 'Tooltip.target' is not allowed"),
]);
});
test("preserves interpreted React elements in safe ReactNode props", () => {
const rendered = renderSafeJsx({
template: '<Button leftSection={<TablerIcon name="plus" />}>Add</Button>',
components: {
Button: ((props: { children?: unknown; leftSection?: unknown }) =>
createElement("button", null, props.leftSection as never, props.children as never)) as never,
TablerIcon: ((props: { name?: string }) => createElement("span", null, props.name)) as never,
},
bindings,
});
expect(rendered.warnings).toEqual([]);
expect(renderToStaticMarkup(rendered.node)).toContain("<span>plus</span>Add");
});
test.each(["|", "&", "^", "in", "instanceof"])(
"rejects unsupported binary operator %s in analysis and runtime",
(operator) => {
const right = operator === "in" ? "data" : operator === "instanceof" ? "data.Type" : "1";
const template = `<Text>{1 ${operator} ${right}}</Text>`;
expect(validateCustomJsxTemplate(template)).toEqual(
expect.arrayContaining([
expect.objectContaining({ severity: "error", message: expect.stringContaining("Binary operator") }),
]),
);
expect(() => renderSafeJsx({ template, components, bindings: { data: { Type: Object } } })).toThrow(
"Unsupported binary operator",
);
},
);
test.each([
["data.fn()", "CALL_TARGET_NOT_ALLOWED"],
["data.value.unknownMethod()", "CALL_TARGET_NOT_ALLOWED"],
["data.items.map()", "CALLBACK_VALUE_NOT_ALLOWED"],
["data.items.map(data.fn)", "CALLBACK_VALUE_NOT_ALLOWED"],
["data.items.sort(Number)", "CALLBACK_VALUE_NOT_ALLOWED"],
["data.items.reduce(Number, 0)", "CALLBACK_VALUE_NOT_ALLOWED"],
])("rejects analyzer/runtime call mismatch %s", (expression, diagnostic) => {
const template = `<Text>{${expression}}</Text>`;
expect(validateCustomJsxTemplate(template)).toEqual(
expect.arrayContaining([
expect.objectContaining({ severity: "error", message: expect.stringContaining(diagnostic) }),
]),
);
expect(() =>
renderSafeJsx({
template,
components,
bindings: { data: { fn: () => 1, items: [1, 2], value: {}, Type: Object } },
}),
).toThrow();
});
test.each([
"data.items.map(Number).join(',')",
"data.items.filter(Boolean).join(',')",
"String(data.value)",
"data.label.toUpperCase()",
])("accepts documented safe call %s", (expression) => {
const template = `<Text>{${expression}}</Text>`;
expect(validateCustomJsxTemplate(template).filter(({ severity }) => severity === "error")).toEqual([]);
expect(() =>
renderSafeJsx({
template,
components,
bindings: createCustomJsxBindings({ items: [0, 2], label: "safe", value: 2 }),
}),
).not.toThrow();
});
test("blocks refs, native popovers, and editable DOM capabilities without blocking declarative bind", () => {
const diagnostics = validateCustomJsxTemplate(
'<TextInput bind="search" rootRef={data.ref} popoverTarget="outside" contentEditable />',
);
expect(diagnostics.filter(({ severity }) => severity === "error")).toHaveLength(3);
expect(diagnostics.some(({ message }) => message.includes("Prop 'bind'"))).toBe(false);
});
test("provides closest component and binding diagnostics", () => {
expect(validateCustomJsxTemplate("<Stak />")[0]?.message).toMatch(/UNKNOWN_COMPONENT.*Stack/u);
expect(validateCustomJsxTemplate('<Text bind="search" />')).toEqual(
expect.arrayContaining([
expect.objectContaining({ severity: "warning", message: expect.stringContaining("BINDING_UNAVAILABLE") }),
]),
);
expect(() => renderSafeJsx({ template: '<Text bind="search" />', components, bindings })).not.toThrow();
});
test("reports denied component families as blocked capabilities", () => {
expect(validateCustomJsxTemplate("<ModalRoot opened />")[0]?.message).toMatch(/BLOCKED_CAPABILITY/u);
});
test("reports blocked capabilities introduced through spreads at runtime", () => {
const rendered = renderSafeJsx({
template: "<Text {...data.props}>Safe</Text>",
components,
bindings: { data: { props: { onClick: "unsafe", href: "javascript:alert(1)" } } },
});
expect(rendered.warnings).toEqual(
expect.arrayContaining([
expect.stringContaining("BLOCKED_CAPABILITY"),
expect.stringContaining("INVALID_PROP_VALUE"),
]),
);
});
test("sanitizes and diagnoses blocked capabilities inside nested Mantine props", () => {
const props = sanitizeCustomJsxProps({
tooltipProps: { label: "Safe", onClick: "unsafe", href: "javascript:alert(1)" },
data: [{ image: "metric-name", onClick: "api-field" }],
});
expect(props).toEqual({
tooltipProps: { label: "Safe" },
data: [{ image: "metric-name", onClick: "api-field" }],
});
expect(sanitizeCustomJsxProps({ values: ["safe", () => "unsafe", { nested: () => "unsafe" }] })).toEqual({
values: ["safe", undefined, { nested: undefined }],
});
expect(validateCustomJsxTemplate("<Text tooltipProps={{ onClick: () => null }}>Unsafe</Text>")).toEqual(
expect.arrayContaining([
expect.objectContaining({ severity: "error", message: expect.stringContaining("Callback prop") }),
]),
);
expect(
validateCustomJsxTemplate("<BarChart data={data.items.map((item) => item)} />").some(({ message }) =>
message.includes("Callback prop"),
),
).toBe(false);
expect(validateCustomJsxTemplate("<Select data={data.items.map((item) => () => item.label)} />")).toEqual(
expect.arrayContaining([
expect.objectContaining({ severity: "error", message: expect.stringContaining("BLOCKED_CAPABILITY") }),
]),
);
});
test("supports expression callbacks", () => {
const template =
'<Text>{data.items.filter((item) => item.visible).map((item) => item.name.toUpperCase()).join(", ")}</Text>';
expect(validateCustomJsxTemplate(template).filter(({ severity }) => severity === "error")).toEqual([]);
const rendered = renderSafeJsx({
template,
components: {
Text: ((props: { children?: unknown }) => createElement("span", null, props.children as never)) as never,
},
bindings: { data: { items: [{ name: "Bulbasaur", visible: true }] } },
});
expect(renderToStaticMarkup(rendered.node)).toContain("BULBASAUR");
});
test("supports bounded regex string operations", () => {
const template = '<Text>{data.name.replace(/[^a-z]/gi, "-")}</Text>';
expect(validateCustomJsxTemplate(template).filter(({ severity }) => severity === "error")).toEqual([]);
const rendered = renderSafeJsx({
template,
components: {
Text: ((props: { children?: unknown }) => createElement("span", null, props.children as never)) as never,
},
bindings: { data: { name: "Bulba saur!" } },
});
expect(renderToStaticMarkup(rendered.node)).toContain("Bulba-saur-");
});
test.each(["/(a+)+$/", "/(?<=a)b/", "/(a)\\1/"])("rejects unsafe regex %s", (pattern) => {
expect(validateCustomJsxTemplate(`<Text>{${pattern}.test(data.name)}</Text>`)).toEqual(
expect.arrayContaining([expect.objectContaining({ message: expect.stringContaining("UNSAFE_REGEX") })]),
);
});
test("keeps direct documented safe helper callbacks available", () => {
const template = '<Text>{data.items.map(Number).join(",")}</Text>';
expect(validateCustomJsxTemplate(template).filter(({ severity }) => severity === "error")).toEqual([]);
expect(() =>
renderSafeJsx({ template, components, bindings: createCustomJsxBindings({ items: ["1", "2"] }) }),
).not.toThrow();
});
test("rejects IIFEs and callback statement blocks", () => {
for (const template of [
"{(() => <Text>value</Text>)()}",
"{data.items.map((item) => { const value = item; return value; })}",
]) {
expect(
validateCustomJsxTemplate(template).some(
({ message }) => message.includes("IIFE") || message.includes("UNSUPPORTED_BLOCK_STATEMENT"),
),
).toBe(true);
expect(() =>
renderSafeJsx({ template, components, bindings: createCustomJsxBindings({ items: [1] }) }),
).toThrow();
}
});
test("normalizes duplicate callback parameter parse diagnostics", () => {
const template = "{data.items.map((item, item) => item)}";
expect(validateCustomJsxTemplate(template)).toEqual(
expect.arrayContaining([
expect.objectContaining({ message: expect.stringContaining("DUPLICATE_LOCAL_BINDING") }),
]),
);
expect(() => renderSafeJsx({ template, components, bindings: { data: { items: [1] } } })).toThrow(
"DUPLICATE_LOCAL_BINDING",
);
});
test("rejects callback values outside controlled call sites", () => {
const template = "<Text>{(item) => item}</Text>";
expect(validateCustomJsxTemplate(template)).toEqual(
expect.arrayContaining([
expect.objectContaining({ message: expect.stringContaining("CALLBACK_VALUE_NOT_ALLOWED") }),
]),
);
expect(() => renderSafeJsx({ template, components, bindings })).toThrow("CALLBACK_VALUE_NOT_ALLOWED");
});
test.each(["constructor", "call", "bind"])(
"keeps blocked lexical binding %s aligned between diagnostics and runtime",
(name) => {
const parameterTemplate = `{data.items.map((${name}) => <Text>{${name}}</Text>)}`;
expect(validateCustomJsxTemplate(parameterTemplate)).toEqual(
expect.arrayContaining([
expect.objectContaining({ message: expect.stringContaining("INVALID_LOCAL_DECLARATION") }),
]),
);
expect(() =>
renderSafeJsx({ template: parameterTemplate, components, bindings: { data: { items: [1] } } }),
).toThrow("INVALID_LOCAL_DECLARATION");
},
);
test.each(['{data.items["ma" + "p"]((item) => item).join(",")}', '{data.items[`map`]((item) => item).join(",")}'])(
"keeps analyzer and runtime aligned for static computed collection callbacks",
(template) => {
expect(validateCustomJsxTemplate(template).filter(({ severity }) => severity === "error")).toEqual([]);
expect(() =>
renderSafeJsx({ template, components, bindings: { data: { items: ["one", "two"] } } }),
).not.toThrow();
},
);
});
@@ -0,0 +1,104 @@
import { describe, expect, it } from "vitest";
import {
customWidgetDefinitionSchema,
customWidgetOptionControls,
customWidgetOptionSchema,
getCustomWidgetDefaultOptions,
validateCustomWidgetOptions,
} from "../core";
const defaultsByControl: Record<(typeof customWidgetOptionControls)[number], unknown> = {
text: "value",
textarea: "long value",
number: 2,
switch: true,
select: "one",
multiSelect: ["one"],
slider: 50,
date: "2026-07-21",
time: "12:30",
color: "#228be6",
icon: "server",
url: "https://example.com",
duration: 30,
timeZone: "Europe/Paris",
json: { nested: true },
};
describe("lean Custom Widget options", () => {
it("treats an empty dynamic choices item path as the response root", () => {
expect(
customWidgetOptionSchema.parse({
label: "Printer",
control: "select",
default: "",
choicesFrom: {
request: "printers",
itemsPath: "",
valuePath: "id",
labelPath: "name",
},
}).choicesFrom?.itemsPath,
).toBeUndefined();
});
it.each(customWidgetOptionControls)("accepts the %s control and infers its default type", (control) => {
const option = {
label: control,
control,
default: defaultsByControl[control],
...(["select", "multiSelect"].includes(control) ? { choices: [{ label: "One", value: "one" }] } : {}),
};
expect(customWidgetOptionSchema.safeParse(option).success).toBe(true);
});
it("builds and validates a complete instance configuration", () => {
const options = Object.fromEntries(
customWidgetOptionControls.map((control) => [
control,
customWidgetOptionSchema.parse({
label: control,
control,
default: defaultsByControl[control],
...(["select", "multiSelect"].includes(control) ? { choices: [{ label: "One", value: "one" }] } : {}),
}),
]),
);
const configuration = getCustomWidgetDefaultOptions(options);
expect(validateCustomWidgetOptions(options, configuration)).toEqual([]);
});
it("rejects duplicate static choice values", () => {
expect(
customWidgetOptionSchema.safeParse({
label: "Environment",
control: "select",
default: "prod",
choices: [
{ label: "Production", value: "prod" },
{ label: "Duplicate", value: "prod" },
],
}).success,
).toBe(false);
});
it("keeps dynamic choices on a load query without invocation parameters", () => {
const result = customWidgetDefinitionSchema.safeParse({
$schema: "homarr-custom-widget-v2",
name: "Dynamic options",
sources: { default: { baseUrl: "https://example.com", networkScope: "public", auth: "none" } },
requests: { environments: { path: "/environments" } },
options: {
environment: {
label: "Environment",
control: "select",
default: "",
choicesFrom: { request: "environments", valuePath: "id", labelPath: "name" },
},
},
template: "<Text>{options.environment}</Text>",
});
expect(result.success).toBe(true);
});
});
@@ -1,72 +1,112 @@
import { describe, expect, it } from "vitest";
import { customWidgetDefinitionSchema } from "../core";
import { CustomWidgetPreviewSessionService } from "../server";
import type { CreatePreviewSessionInput } from "../server";
function createHarness() {
let now = 1_000;
let id = 0;
const service = new CustomWidgetPreviewSessionService({
createId: () => `id-${++id}`,
encrypt: (value) => `encrypted:${value}`,
decrypt: (value) => value.replace("encrypted:", ""),
now: () => now,
});
return {
service,
advance: (milliseconds: number) => {
now += milliseconds;
},
};
}
const definition = customWidgetDefinitionSchema.parse({
$schema: "homarr-custom-widget-v2",
name: "Preview",
sources: { default: { baseUrl: "https://example.com", networkScope: "public", auth: "bearer" } },
requests: { data: { path: "/data" } },
options: { limit: { label: "Limit", control: "number", default: 10 } },
template: "<Text>{data.data?.name}</Text>",
});
const input: CreatePreviewSessionInput = {
userId: "user-1",
baseUrl: "https://example.com/api",
authType: "bearer",
secrets: [{ kind: "apiKey", value: "secret" }],
networkScope: "public",
requests: [],
};
describe("preview session service", () => {
it("stores secrets encrypted and returns them only through the decrypting boundary", async () => {
const { service } = createHarness();
const created = await service.create(input);
const session = await service.get(created.id, input.userId);
expect(session.secrets[0]?.value).toBe("encrypted:secret");
expect(service.getSecrets(session)).toEqual([{ kind: "apiKey", value: "secret" }]);
describe("preview sessions", () => {
it("stores keyed definitions and encrypted secrets", async () => {
let id = 0;
const service = new CustomWidgetPreviewSessionService({
createId: () => `id-${++id}`,
encrypt: (value) => `encrypted:${value}`,
decrypt: (value) => value.replace("encrypted:", ""),
now: () => 1_000,
});
const created = await service.create({
userId: "user",
sources: definition.sources,
requests: definition.requests,
name: definition.name,
template: definition.template,
optionDefinitions: definition.options,
options: { limit: 10 },
secrets: [{ sourceId: "default", kind: "apiKey", value: "secret" }],
});
const session = await service.get(created.id, "user");
expect(session.requests.data?.path).toBe("/data");
expect(session.secrets[0]?.value).not.toBe("secret");
expect(service.getSecrets(session, "default")).toEqual([{ kind: "apiKey", value: "secret" }]);
});
it("isolates sessions by user and expires them after five minutes", async () => {
const { service, advance } = createHarness();
const created = await service.create(input);
await expect(service.get(created.id, "other-user")).rejects.toMatchObject({ code: "NOT_FOUND" });
const second = await service.create(input);
advance(5 * 60_000 + 1);
await expect(service.get(second.id, input.userId)).rejects.toMatchObject({ code: "NOT_FOUND" });
it("keeps journal paths redacted and bounded to the session", async () => {
let id = 0;
const service = new CustomWidgetPreviewSessionService({
createId: () => `id-${++id}`,
encrypt: String,
decrypt: String,
});
const created = await service.create({
userId: "user",
sources: definition.sources,
requests: definition.requests,
name: definition.name,
template: definition.template,
optionDefinitions: definition.options,
options: { limit: 10 },
secrets: [],
});
const session = await service.get(created.id, "user");
await service.appendJournal(session, {
requestId: "data",
kind: "query",
method: "GET",
path: "/data",
status: 200,
durationMs: 5,
simulated: false,
});
expect((await service.getJournal(created.id, "user"))[0]).toMatchObject({ path: "/data", status: 200 });
});
it("updates live-action approval and caps the newest-first journal", async () => {
const { service } = createHarness();
const created = await service.create(input);
const approved = await service.setLiveActions(created.id, input.userId, true);
expect(approved.liveActions).toBe(true);
const session = await service.get(created.id, input.userId);
for (let index = 0; index < 52; index += 1) {
await service.appendJournal(session, {
requestId: `request-${index}`,
kind: "query",
method: "GET",
pathTemplate: "/data",
status: 200,
durationMs: 1,
simulated: false,
});
}
const journal = await service.getJournal(created.id, input.userId);
expect(journal).toHaveLength(50);
expect(journal[0]?.requestId).toBe("request-51");
expect(journal.at(-1)?.requestId).toBe("request-2");
it("applies source deployment values without restoring stale source metadata", async () => {
let id = 0;
const service = new CustomWidgetPreviewSessionService({
createId: () => `id-${++id}`,
encrypt: String,
decrypt: String,
});
const created = await service.create({
userId: "user",
sources: definition.sources,
requests: definition.requests,
name: definition.name,
template: definition.template,
optionDefinitions: definition.options,
options: { limit: 10 },
secrets: [],
});
await service.configureSource(
created.id,
"user",
"default",
{ baseUrl: "http://service.local", networkScope: "private", auth: "bearer" },
[{ sourceId: "default", kind: "apiKey", value: "secret" }],
);
const configured = await service.get(created.id, "user");
expect(configured.sources.default).toEqual({
baseUrl: "http://service.local",
networkScope: "private",
auth: "bearer",
});
await expect(
service.configureSource(
created.id,
"user",
"default",
{ baseUrl: "http://service.local", networkScope: "private", auth: "none" },
[],
),
).rejects.toThrow("authentication changed");
});
});
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import { BUNDLED_CUSTOM_WIDGETS, customWidgetDefinitionSchema, parseCustomWidgetAiResponse } from "../core";
import { collectCustomWidgetRequestReferences } from "../core/request-schema";
import { PORTAINER_REFERENCE_WIDGET } from "./fixtures/reference-widgets";
describe("reference widget capabilities", () => {
it("validates a full Pokédex with list and manual detail requests", () => {
const pokedex = BUNDLED_CUSTOM_WIDGETS.find(({ id }) => id === "seed-pokedex");
expect(pokedex).toBeDefined();
const parsed = customWidgetDefinitionSchema.parse(pokedex?.widget);
expect(parsed.requests.pokemon?.trigger).toBe("load");
expect(parsed.requests.detail?.trigger).toBe("manual");
});
it("round-trips the full Pokédex through the exact two-block AI paste format", () => {
const pokedex = BUNDLED_CUSTOM_WIDGETS.find(({ id }) => id === "seed-pokedex")?.widget;
if (!pokedex) throw new Error("Pokédex seed was not found");
const { template, ...manifest } = pokedex;
const parsed = parseCustomWidgetAiResponse(
`\`\`\`json\n${JSON.stringify({ ...manifest, template: "__HOMARR_TEMPLATE__" }, null, 2)}\n\`\`\`\n\n\`\`\`jsx\n${template}\n\`\`\``,
);
expect(parsed.success).toBe(true);
if (parsed.success) expect(parsed.widget.template).toBe(template);
});
it("validates a polished Portainer dashboard with start, stop, and restart actions", () => {
const parsed = customWidgetDefinitionSchema.parse(PORTAINER_REFERENCE_WIDGET);
expect(parsed.sources.default?.auth).toEqual({ type: "apiKeyHeader", name: "X-API-Key" });
expect(Object.keys(parsed.requests)).toEqual(["containers", "start", "stop", "restart"]);
for (const action of ["start", "stop", "restart"] as const) {
const request = parsed.requests[action];
if (!request) throw new Error(`Portainer ${action} request was not found`);
expect(request.kind).toBe("action");
expect(request.invalidates).toEqual(["containers"]);
expect([...collectCustomWidgetRequestReferences(request).params]).toEqual(["id"]);
}
});
it("round-trips the Portainer dashboard through AI paste", () => {
const { template, ...manifest } = PORTAINER_REFERENCE_WIDGET;
const parsed = parseCustomWidgetAiResponse(
`\`\`\`json\n${JSON.stringify({ ...manifest, template: "__HOMARR_TEMPLATE__" }, null, 2)}\n\`\`\`\n\n\`\`\`jsx\n${template}\n\`\`\``,
);
expect(parsed.success).toBe(true);
});
});
@@ -1,57 +1,84 @@
import { describe, expect, it } from "vitest";
import type { CustomJsxRequest } from "../core";
import { hashRuntimeParams, renderRequestBody, renderRequestTarget, validateRuntimeParams } from "../server";
import { customJsxRequestSchema } from "../core";
import { renderRequestBody, renderRequestTarget, resolveCustomWidgetRequestValues } from "../server";
const request: CustomJsxRequest = {
id: "device",
const request = customJsxRequestSchema.parse({
kind: "action",
method: "PATCH",
pathTemplate: "/devices/{device}/state",
parameters: { device: "string", enabled: "boolean", level: "number" },
bodyTemplate: { enabled: { $param: "enabled" }, nested: { level: { $param: "level" } } },
auth: "inherit",
minimumBoardPermission: "modify",
};
method: "POST",
path: "/devices/{option:room}/{param:id}",
query: { verbose: true, level: { $param: "level" } },
body: { enabled: { $param: "enabled" }, room: { $option: "room" } },
});
describe("named request manifest rendering", () => {
it("validates exact parameter names and declared primitive types", () => {
expect(() => validateRuntimeParams(request, { device: "lamp", enabled: true, level: 5 })).not.toThrow();
expect(() => validateRuntimeParams(request, { device: "lamp", enabled: true })).toThrow("match the manifest");
expect(() => validateRuntimeParams(request, { device: "lamp", enabled: true, level: "5" })).toThrow(
"must be number",
describe("lean request bindings", () => {
it("infers and resolves option and invocation references", () => {
const values = resolveCustomWidgetRequestValues(
request,
{ room: "office" },
{ id: "lamp/1", level: 5, enabled: true },
);
expect(renderRequestTarget("https://example.com/api/", request, values).toString()).toBe(
"https://example.com/api/devices/office/lamp%2F1?verbose=true&level=5",
);
expect(JSON.parse(renderRequestBody(request, values) ?? "null")).toEqual({ enabled: true, room: "office" });
});
it("encodes path placeholders and preserves JSON parameter types", () => {
const params = { device: "living room/1", enabled: true, level: 5 };
expect(renderRequestTarget("https://example.com/api", request, params).pathname).toBe(
"/devices/living%20room%2F1/state",
it("rejects missing and extra invocation params", () => {
expect(() => resolveCustomWidgetRequestValues(request, { room: "office" }, { id: "lamp", level: 5 })).toThrow(
"do not match",
);
expect(JSON.parse(renderRequestBody(request.bodyTemplate, params) ?? "null")).toEqual({
enabled: true,
nested: { level: 5 },
expect(() =>
resolveCustomWidgetRequestValues(
request,
{ room: "office" },
{ id: "lamp", level: 5, enabled: true, extra: true },
),
).toThrow("do not match");
});
it("rejects missing options and non-primitive URL values", () => {
expect(() => resolveCustomWidgetRequestValues(request, {}, { id: "lamp", level: 5, enabled: true })).toThrow(
"room",
);
const values = resolveCustomWidgetRequestValues(request, { room: [] }, { id: "lamp", level: 5, enabled: true });
expect(() => renderRequestTarget("https://example.com", request, values)).toThrow("Path value");
});
it("supports structured options in JSON bodies", () => {
const bodyRequest = customJsxRequestSchema.parse({
kind: "action",
method: "POST",
path: "/rules",
body: { entities: { $option: "entities" }, rule: { $option: "rule" } },
});
const values = resolveCustomWidgetRequestValues(bodyRequest, {
entities: ["light.office", "light.desk"],
rule: { transition: 2 },
});
expect(JSON.parse(renderRequestBody(bodyRequest, values) ?? "null")).toEqual({
entities: ["light.office", "light.desk"],
rule: { transition: 2 },
});
});
it("rejects unresolved or malformed placeholders", () => {
expect(() =>
renderRequestTarget(
"https://example.com",
{ ...request, pathTemplate: "/{missing}" },
{ device: "lamp", enabled: true, level: 5 },
),
).toThrow("Unknown path parameter");
expect(() =>
renderRequestTarget(
"https://example.com",
{ ...request, pathTemplate: "/{INVALID}" },
{ device: "lamp", enabled: true, level: 5 },
),
).toThrow("invalid placeholder");
it("keeps a source base path", () => {
const pokemon = customJsxRequestSchema.parse({ path: "/pokemon" });
expect(renderRequestTarget("https://pokeapi.co/api/v2", pokemon, {}).toString()).toBe(
"https://pokeapi.co/api/v2/pokemon",
);
});
it("hashes parameters independently of object insertion order", () => {
expect(hashRuntimeParams({ a: 1, b: "two" })).toBe(hashRuntimeParams({ b: "two", a: 1 }));
it.each(["start", "stop", "restart"])("renders a Portainer %s action without duplicating declarations", (action) => {
const portainer = customJsxRequestSchema.parse({
kind: "action",
method: "POST",
path: `/api/endpoints/{option:endpointId}/docker/containers/{param:id}/${action}`,
invalidates: ["containers"],
});
const values = resolveCustomWidgetRequestValues(portainer, { endpointId: 2 }, { id: "abc/123" });
expect(renderRequestTarget("https://portainer.example", portainer, values).pathname).toBe(
`/api/endpoints/2/docker/containers/abc%2F123/${action}`,
);
});
});
@@ -8,16 +8,18 @@ import { MantineProvider } from "@mantine/core";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ActionButton, CustomWidgetRuntimeProvider, SubFetch, ToggleSwitch } from "../runtime";
import { ActionButton, CustomJsxRenderer, CustomWidgetRuntimeProvider, SubFetch, ToggleSwitch } from "../runtime";
import { createCustomJsxComponents } from "../jsx";
import type {
CustomJsxRequestCapability,
CustomWidgetPublishedQueryState,
CustomWidgetRequestResult,
CustomWidgetRuntimeMessages,
CustomWidgetRuntimePort,
} from "../runtime";
const messages: CustomWidgetRuntimeMessages = {
migrationRequired: "Migration required",
requestIdRequired: "Request ID required",
unsavedPreview: "Unsaved",
invalidParams: "Invalid params",
loadRequest: "Load",
@@ -74,6 +76,7 @@ async function render(
node: ReactNode,
port: CustomWidgetRuntimePort,
capabilities: readonly CustomJsxRequestCapability[] = [],
setQueryState?: (requestId: string, value: CustomWidgetPublishedQueryState | null) => void,
) {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
await act(async () => {
@@ -86,6 +89,7 @@ async function render(
requestCapabilities={capabilities}
port={port}
messages={messages}
setQueryState={setQueryState}
>
{node}
</CustomWidgetRuntimeProvider>
@@ -102,14 +106,300 @@ async function settle() {
}
describe("Custom Widget runtime ports", () => {
it("runs an automatic query once and exposes data to a render child", async () => {
const port = createPort();
it("renders common safe collection and number formatting operations", async () => {
const components = createCustomJsxComponents({
TablerIcon: (() => null) as never,
copyLabels: { copy: "Copy", copied: "Copied" },
});
await render(
<SubFetch requestId="details" render={(data) => <span>{(data as { name: string }).name}</span>} />,
port,
<CustomJsxRenderer
template={
'<Text>{[1, 2].concat([3]).flatMap(value => [value]).map(value => value.toLocaleString()).join(" · ")}</Text>'
}
data={{}}
components={components}
createBindings={() => ({})}
messages={{ noTemplate: "No template", templateWarnings: (count) => `${count} warnings` }}
/>,
createPort(),
);
expect(host.textContent).toContain("1 · 2 · 3");
});
it("removes unsafe dynamic link targets at runtime", async () => {
const components = createCustomJsxComponents({
TablerIcon: (() => null) as never,
copyLabels: { copy: "Copy", copied: "Copied" },
});
const rendererMessages = {
noTemplate: "No template",
templateWarnings: (count: number) => `${count} warnings`,
};
await render(
<CustomJsxRenderer
template={"<Anchor href={data.url}>Unsafe</Anchor>"}
data={{ url: "//example.com/escape" }}
components={components}
createBindings={(data) => ({ data })}
messages={rendererMessages}
/>,
createPort(),
);
expect(host.querySelector("a")?.hasAttribute("href")).toBe(false);
});
it("scopes authored radio names to the widget and preserves uncontrolled defaults", async () => {
const components = createCustomJsxComponents({
TablerIcon: (() => null) as never,
copyLabels: { copy: "Copy", copied: "Copied" },
});
const rendererMessages = {
noTemplate: "No template",
templateWarnings: (count: number) => `${count} warnings`,
};
await render(
<>
<button name="shared" role="radio" aria-checked="false" type="button">
Outside
</button>
<CustomJsxRenderer
template={
'<Radio.Group name="shared" defaultValue="inside"><Radio.Card value="inside"><Radio.Indicator /></Radio.Card><Radio.Card value="other"><Radio.Indicator /></Radio.Card></Radio.Group>'
}
data={{}}
components={components}
createBindings={() => ({})}
messages={rendererMessages}
/>
</>,
createPort(),
);
await settle();
const [outside, inside, other] = [...host.querySelectorAll<HTMLButtonElement>('button[role="radio"]')];
expect(outside?.name).toBe("shared");
expect(inside?.name).toMatch(/^custom-widget-/u);
expect(inside?.name).not.toBe(outside?.name);
expect(other?.name).toBe(inside?.name);
expect(inside?.getAttribute("aria-checked")).toBe("true");
expect(other?.getAttribute("aria-checked")).toBe("false");
});
it("exposes temporary bindings through inputs without browser storage", async () => {
const setItem = vi.spyOn(Storage.prototype, "setItem");
const rendererMessages = {
noTemplate: "No template",
templateWarnings: (count: number) => `${count} warnings`,
};
const components = createCustomJsxComponents({
TablerIcon: (() => null) as never,
copyLabels: { copy: "Copy", copied: "Copied" },
});
const widget = (
<CustomJsxRenderer
template={'<Stack><TextInput bind="search" defaultValue="containers"/><Text>{inputs.search}</Text></Stack>'}
data={{}}
components={components}
createBindings={() => ({})}
messages={rendererMessages}
/>
);
await render(widget, createPort());
await settle();
expect(host.textContent).toContain("containers");
expect(setItem).not.toHaveBeenCalled();
});
it("preserves input focus across temporary binding updates", async () => {
const components = createCustomJsxComponents({
TablerIcon: (() => null) as never,
copyLabels: { copy: "Copy", copied: "Copied" },
});
await render(
<CustomJsxRenderer
template={'<Stack><TextInput bind="search"/><Text>{inputs.search}</Text></Stack>'}
data={{}}
components={components}
createBindings={() => ({})}
messages={{ noTemplate: "No template", templateWarnings: (count) => `${count} warnings` }}
/>,
createPort(),
);
await settle();
const input = host.querySelector("input") as HTMLInputElement;
input.focus();
await act(async () => {
input.value = "p";
input.dispatchEvent(new InputEvent("input", { bubbles: true, data: "p", inputType: "insertText" }));
});
await settle();
expect(input.isConnected).toBe(true);
expect(document.activeElement).toBe(input);
expect(input.value).toBe("p");
expect(host.textContent).toContain("p");
});
it("resets temporary bindings when the template changes", async () => {
const rendererMessages = {
noTemplate: "No template",
templateWarnings: (count: number) => `${count} warnings`,
};
const components = createCustomJsxComponents({
TablerIcon: (() => null) as never,
copyLabels: { copy: "Copy", copied: "Copied" },
});
await render(
<CustomJsxRenderer
template={'<Stack><TextInput bind="shared" defaultValue="text"/><Text>{inputs.shared}</Text></Stack>'}
data={{}}
components={components}
createBindings={() => ({})}
messages={rendererMessages}
/>,
createPort(),
);
await settle();
expect(host.textContent).toContain("text");
await render(
<CustomJsxRenderer
template={
'<Stack><Switch bind="shared" defaultChecked/><Text>{inputs.shared ? "enabled" : "disabled"}</Text></Stack>'
}
data={{}}
components={components}
createBindings={() => ({})}
messages={rendererMessages}
/>,
createPort(),
);
await settle();
expect(host.textContent).toContain("enabled");
expect(host.textContent).not.toContain("BINDING_TYPE_CONFLICT");
});
it("preserves temporary bindings when data changes without changing the template", async () => {
const rendererMessages = {
noTemplate: "No template",
templateWarnings: (count: number) => `${count} warnings`,
};
const components = createCustomJsxComponents({
TablerIcon: (() => null) as never,
copyLabels: { copy: "Copy", copied: "Copied" },
});
const template = '<Stack><Switch bind="shared"/><Text>{inputs.shared ? "enabled" : "disabled"}</Text></Stack>';
await render(
<CustomJsxRenderer
template={template}
data={{ version: 1 }}
components={components}
createBindings={(data) => ({ data })}
messages={rendererMessages}
/>,
createPort(),
);
await settle();
await act(async () => (host.querySelector("input") as HTMLInputElement).click());
await settle();
expect(host.textContent).toContain("enabled");
await render(
<CustomJsxRenderer
template={template}
data={{ version: 2 }}
components={components}
createBindings={(data) => ({ data })}
messages={rendererMessages}
/>,
createPort(),
);
await settle();
expect((host.querySelector("input") as HTMLInputElement).checked).toBe(true);
expect(host.textContent).toContain("enabled");
});
it("runs an automatic query once and publishes data to the declarative data root", async () => {
const port = createPort();
const publish = vi.fn();
await render(<SubFetch requestId="details" />, port, [], publish);
await settle();
expect(port.query).toHaveBeenCalledTimes(1);
expect(publish).toHaveBeenLastCalledWith("details", {
data: { name: "Bulbasaur" },
status: expect.objectContaining({ loading: false, ok: true, status: 200 }),
});
expect(host.querySelector("button, p, code, [role='alert']")).toBeNull();
});
it("uses a built-in manual trigger and passes successful request metadata to children", async () => {
const port = createPort();
await render(
<SubFetch requestId="details" trigger="manual">
{(data, metadata) => (
<span>{`${(data as { name: string }).name}:${metadata.status}:${String(metadata.loading)}`}</span>
)}
</SubFetch>,
port,
);
expect(port.query).not.toHaveBeenCalled();
expect(host.textContent).toContain("Load");
await act(async () => (host.querySelector("button") as HTMLButtonElement).click());
await settle();
expect(port.query).toHaveBeenCalledOnce();
expect(host.textContent).toContain("Bulbasaur:200:false");
});
it("keeps manual query results local instead of publishing a shared request slot", async () => {
const port = createPort();
const publish = vi.fn();
await render(
<SubFetch requestId="details" trigger="manual" triggerContent={<span>Bulbasaur card</span>}>
{(data) => <span>{(data as { name: string }).name}</span>}
</SubFetch>,
port,
[],
publish,
);
await act(async () => (host.querySelector("button") as HTMLButtonElement).click());
await settle();
expect(port.query).toHaveBeenCalledOnce();
expect(publish).not.toHaveBeenCalled();
expect(host.textContent).toContain("Bulbasaur");
});
it("runs a manual query from a custom accessible trigger", async () => {
const port = createPort();
await render(
<SubFetch
requestId="details"
trigger="manual"
triggerAriaLabel="Open Bulbasaur"
triggerContent={<span>Bulbasaur card</span>}
>
{(data) => <span>{(data as { name: string }).name}</span>}
</SubFetch>,
port,
);
const trigger = host.querySelector("button") as HTMLButtonElement;
expect(trigger.getAttribute("aria-label")).toBe("Open Bulbasaur");
expect(host.textContent).toContain("Bulbasaur card");
await act(async () => trigger.click());
await settle();
expect(port.query).toHaveBeenCalledOnce();
expect(host.textContent).toContain("Bulbasaur");
});
@@ -129,8 +419,15 @@ describe("Custom Widget runtime ports", () => {
it("confirms DELETE actions, reports success, and invalidates declared targets", async () => {
const port = createPort();
await render(<ActionButton requestId="remove" label="Remove" invalidate={["parent"]} />, port, [
{ id: "remove", kind: "action", method: "DELETE", minimumBoardPermission: "full" },
await render(<ActionButton requestId="remove">Remove</ActionButton>, port, [
{
id: "remove",
kind: "action",
method: "DELETE",
trigger: "manual",
minimumBoardPermission: "full",
invalidates: ["parent"],
},
]);
await act(async () => (host.querySelector("button") as HTMLButtonElement).click());
await settle();
@@ -144,7 +441,10 @@ describe("Custom Widget runtime ports", () => {
const port = createPort({
executeAction: vi.fn(async () => ({ ok: false, status: 500, data: null, error: "Nope" })),
});
await render(<ToggleSwitch requestId="toggle" onParams={{ enabled: true }} offParams={{ enabled: false }} />, port);
await render(
<ToggleSwitch requestId="toggle" enabledParams={{ enabled: true }} disabledParams={{ enabled: false }} />,
port,
);
const input = host.querySelector("input") as HTMLInputElement;
await act(async () => {
input.click();
@@ -154,4 +454,25 @@ describe("Custom Widget runtime ports", () => {
expect(port.notify).toHaveBeenCalledWith(expect.objectContaining({ kind: "error", message: "Nope" }));
expect(port.invalidate).not.toHaveBeenCalled();
});
it("confirms a DELETE toggle before executing it", async () => {
const port = createPort();
await render(
<ToggleSwitch requestId="delete-toggle" enabledParams={{ enabled: true }} disabledParams={{ enabled: false }} />,
port,
[
{
id: "delete-toggle",
kind: "action",
method: "DELETE",
trigger: "manual",
minimumBoardPermission: "full",
},
],
);
await act(async () => (host.querySelector("input") as HTMLInputElement).click());
await settle();
expect(port.confirm).toHaveBeenCalledWith(expect.objectContaining({ destructive: true }));
expect(port.executeAction).toHaveBeenCalledWith(expect.objectContaining({ confirmed: true }));
});
});
+140 -150
View File
@@ -1,167 +1,157 @@
import { describe, expect, test } from "vitest";
import { describe, expect, it } from "vitest";
import { customJsxExamples } from "../core/examples";
import { customJsxDisplayConfigV2Schema, customJsxRequestSchema, displayConfigSchema } from "../core/schema";
import { validateCustomJsxTemplate } from "../jsx/analyzer";
import {
CUSTOM_WIDGET_STARTER,
customWidgetDefinitionSchema,
customJsxRequestSchema,
getCustomWidgetDefaultOptions,
getCustomWidgetSecretRequirements,
getCustomWidgetSourceSetups,
hasSameCustomWidgetSourceAuthentication,
applyCustomWidgetSourceSetup,
} from "../core";
describe("custom JSX v2 validation", () => {
test.each(customJsxExamples)("validates the $id shared example", (example) => {
expect(() =>
customJsxDisplayConfigV2Schema.parse({
type: "customJsx",
jsxApiVersion: 2,
template: example.template,
networkScope: "public",
requests: [...example.requests],
}),
).not.toThrow();
});
test("retains display-only v1 compatibility", () => {
expect(displayConfigSchema.parse({ type: "customJsx", template: "<Text>{data.value}</Text>" })).toEqual({
type: "customJsx",
template: "<Text>{data.value}</Text>",
describe("lean Custom Widget schema", () => {
it("applies request defaults", () => {
expect(customJsxRequestSchema.parse({ path: "/status" })).toMatchObject({
source: "default",
kind: "query",
method: "GET",
trigger: "load",
auth: "inherit",
permission: "view",
});
expect(customJsxRequestSchema.parse({ kind: "action", path: "/restart" })).toMatchObject({
trigger: "manual",
permission: "modify",
});
expect(customJsxRequestSchema.parse({ method: "DELETE", path: "/item" })).toMatchObject({
permission: "full",
confirmation: { destructive: true },
});
});
test("accepts benign text containing interpreter-reserved words", () => {
expect(() =>
customJsxDisplayConfigV2Schema.parse({
type: "customJsx",
jsxApiVersion: 2,
template: "<Text>constructor fetch prototype</Text>",
networkScope: "public",
requests: [],
}),
).not.toThrow();
it("validates keyed sources, requests and options", () => {
const result = customWidgetDefinitionSchema.parse({
...CUSTOM_WIDGET_STARTER,
requests: {
list: { path: "/items/{option:category}", query: { limit: { $option: "limit" } } },
search: { trigger: "manual", path: "/search", query: { q: { $param: "query" } } },
restart: { kind: "action", method: "POST", path: "/items/{param:id}/restart", invalidates: ["list"] },
},
options: {
category: { label: "Category", control: "text", default: "all" },
limit: { label: "Limit", control: "number", default: 20, min: 1, max: 100 },
},
});
expect(Object.keys(result.requests)).toEqual(["list", "search", "restart"]);
expect(getCustomWidgetDefaultOptions(result.options)).toEqual({ category: "all", limit: 20 });
});
test("accepts supported Tabs, ScrollArea, and SubData image props without warnings", () => {
const diagnostics = validateCustomJsxTemplate(
'<Tabs.List grow><ScrollArea offsetScrollbars><SubData as="Image" fit="contain" alt="Artwork" /></ScrollArea></Tabs.List>',
);
expect(diagnostics).toEqual([]);
it("defaults omitted options to an empty map", () => {
const { options: _options, ...withoutOptions } = CUSTOM_WIDGET_STARTER;
expect(customWidgetDefinitionSchema.parse(withoutOptions).options).toEqual({});
});
test("rejects malformed JSX and direct global fetch while allowing declarative onParams", () => {
const config = {
type: "customJsx" as const,
jsxApiVersion: 2 as const,
networkScope: "public" as const,
requests: [],
it("rejects invocation params on load and unknown options", () => {
const loadParam = customWidgetDefinitionSchema.safeParse({
...CUSTOM_WIDGET_STARTER,
requests: { bad: { path: "/{param:id}" } },
});
expect(loadParam.success).toBe(false);
const unknownOption = customWidgetDefinitionSchema.safeParse({
...CUSTOM_WIDGET_STARTER,
requests: { bad: { path: "/{option:missing}" } },
});
expect(unknownOption.success).toBe(false);
});
it("rejects malformed placeholders and removed public fields", () => {
expect(customJsxRequestSchema.safeParse({ path: "/items/{id}" }).success).toBe(false);
expect(customWidgetDefinitionSchema.safeParse({ ...CUSTOM_WIDGET_STARTER, optionsSchema: {} }).success).toBe(false);
expect(customWidgetDefinitionSchema.safeParse({ ...CUSTOM_WIDGET_STARTER, stateSchema: {} }).success).toBe(false);
});
it("keeps auth secrets outside the document", () => {
const definition = customWidgetDefinitionSchema.parse({
...CUSTOM_WIDGET_STARTER,
sources: {
default: { baseUrl: "https://example.com", networkScope: "public", auth: "bearer" },
admin: { baseUrl: "https://example.net", networkScope: "private", auth: "basic" },
},
});
expect(
getCustomWidgetSecretRequirements(definition.sources).map(({ sourceId, kind }) => `${sourceId}:${kind}`),
).toEqual(["default:apiKey", "admin:username", "admin:password"]);
});
it("requires installation confirmation for private and placeholder source URLs", () => {
const sources = {
default: { baseUrl: "https://pokeapi.co/api/v2", networkScope: "public" as const, auth: "none" as const },
tautulli: {
name: "Tautulli",
baseUrl: "http://tautulli.local:8181",
networkScope: "private" as const,
auth: { type: "apiKeyQuery" as const, name: "apikey" },
},
placeholder: {
baseUrl: "https://api.example.com",
networkScope: "public" as const,
auth: "none" as const,
},
};
const setups = getCustomWidgetSourceSetups(sources);
expect(setups.map(({ sourceId, requiresUrlConfirmation }) => [sourceId, requiresUrlConfirmation])).toEqual([
["default", false],
["tautulli", true],
["placeholder", true],
]);
expect(setups.find(({ sourceId }) => sourceId === "tautulli")?.credentialFields).toEqual([
{ kind: "apiKey", destination: "apikey", configured: false },
]);
});
it("applies source setup without changing source authentication", () => {
const sources = {
default: {
baseUrl: "http://tautulli.local:8181",
networkScope: "private" as const,
auth: { type: "apiKeyQuery" as const, name: "apikey" },
},
};
expect(customJsxDisplayConfigV2Schema.safeParse({ ...config, template: "<Text>" }).success).toBe(false);
expect(
customJsxDisplayConfigV2Schema.safeParse({ ...config, template: '<Text>{fetch("/private")}</Text>' }).success,
applyCustomWidgetSourceSetup(sources, {
default: { baseUrl: "http://192.168.1.20:8181", networkScope: "private" },
}).default,
).toEqual({
baseUrl: "http://192.168.1.20:8181",
networkScope: "private",
auth: { type: "apiKeyQuery", name: "apikey" },
});
});
it("detects stale source authentication snapshots", () => {
const bearer = { baseUrl: "https://example.com", networkScope: "public" as const, auth: "bearer" as const };
expect(hasSameCustomWidgetSourceAuthentication(bearer, { ...bearer, baseUrl: "https://api.example.com" })).toBe(
true,
);
expect(hasSameCustomWidgetSourceAuthentication(bearer, { ...bearer, auth: "none" })).toBe(false);
expect(
hasSameCustomWidgetSourceAuthentication(
{ ...bearer, auth: { type: "apiKeyHeader", name: "X-API-Key" } },
{ ...bearer, auth: { type: "apiKeyHeader", name: "Authorization" } },
),
).toBe(false);
});
it("supports official Mantine compound names and separates bigint diagnostics", () => {
expect(
customJsxDisplayConfigV2Schema.safeParse({
...config,
template: '<ToggleSwitch requestId="toggle" onParams={{ enabled: true }} offParams={{ enabled: false }} />',
requests: [
{
id: "toggle",
kind: "action",
method: "POST",
pathTemplate: "/toggle",
parameters: { enabled: "boolean" },
bodyTemplate: { enabled: { $param: "enabled" } },
auth: "none",
minimumBoardPermission: "modify",
},
],
customWidgetDefinitionSchema.safeParse({
...CUSTOM_WIDGET_STARTER,
template: "<Radio.Group><Radio.Card><Radio.Indicator /></Radio.Card></Radio.Group>",
}).success,
).toBe(true);
});
test("rejects reserved manifest headers and mismatched request component kinds", () => {
expect(
customJsxRequestSchema.safeParse({
id: "status",
kind: "query",
method: "GET",
pathTemplate: "/status",
parameters: {},
staticHeaders: { Authorization: "secret" },
auth: "none",
minimumBoardPermission: "view",
}).success,
).toBe(false);
expect(
customJsxDisplayConfigV2Schema.safeParse({
type: "customJsx",
jsxApiVersion: 2,
networkScope: "public",
template: '<ActionButton requestId="status" label="Run" />',
requests: [
{
id: "status",
kind: "query",
method: "GET",
pathTemplate: "/status",
parameters: {},
auth: "none",
minimumBoardPermission: "view",
},
],
}).success,
).toBe(false);
});
test("accepts 50,000 characters and rejects 50,001", () => {
const config = {
type: "customJsx" as const,
jsxApiVersion: 2 as const,
networkScope: "public" as const,
requests: [],
};
expect(customJsxDisplayConfigV2Schema.safeParse({ ...config, template: "x".repeat(50_000) }).success).toBe(true);
expect(customJsxDisplayConfigV2Schema.safeParse({ ...config, template: "x".repeat(50_001) }).success).toBe(false);
});
test("rejects duplicate request IDs", () => {
const request = {
id: "status",
kind: "query" as const,
method: "GET" as const,
pathTemplate: "/status",
parameters: {},
auth: "none" as const,
minimumBoardPermission: "view" as const,
};
const result = customJsxDisplayConfigV2Schema.safeParse({
type: "customJsx",
jsxApiVersion: 2,
template: "<Text />",
networkScope: "public",
requests: [request, request],
});
expect(result.success).toBe(false);
});
test("rejects mutation-shaped queries and weak DELETE permissions", () => {
expect(
customJsxRequestSchema.safeParse({
id: "mutating-query",
kind: "query",
method: "POST",
pathTemplate: "/status",
parameters: {},
auth: "none",
minimumBoardPermission: "view",
}).success,
).toBe(false);
expect(
customJsxRequestSchema.safeParse({
id: "delete",
kind: "action",
method: "DELETE",
pathTemplate: "/resource",
parameters: {},
auth: "inherit",
minimumBoardPermission: "modify",
}).success,
).toBe(false);
const result = customWidgetDefinitionSchema.safeParse({ ...CUSTOM_WIDGET_STARTER, template: "<Text>{1n}</Text>" });
expect(result.success ? "" : result.error.issues[0]?.message).toContain("BIGINT_NOT_SUPPORTED");
});
});
@@ -6,6 +6,7 @@ import {
assertSafeStaticHeaders,
classifyAddress,
executeCustomWidgetRequest,
invalidateCustomWidgetResponseCache,
resolveAndValidateHost,
resolveSameOriginTarget,
validateCustomWidgetUrl,
@@ -52,6 +53,147 @@ describe("custom widget network policy", () => {
}
});
test("preserves structured bodies for GET queries", async () => {
const server = createServer((request, response) => {
let body = "";
request.setEncoding("utf8");
request.on("data", (chunk: string) => {
body += chunk;
});
request.on("end", () => {
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify({ body }));
});
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (!address || typeof address === "string") throw new Error("Test server did not expose a TCP port");
try {
await expect(
executeCustomWidgetRequest({
baseUrl: `http://127.0.0.1:${address.port}`,
method: "GET",
body: '{"query":"status"}',
networkScope: "loopback",
kind: "query",
}),
).resolves.toMatchObject({ ok: true, data: { body: '{"query":"status"}' } });
} finally {
await new Promise<void>((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
}
});
test.each([
[301, "GET", ""],
[302, "GET", ""],
[303, "GET", ""],
[307, "POST", '{"query":"status"}'],
[308, "POST", '{"query":"status"}'],
] as const)("follows %i redirects with fetch-compatible method semantics", async (status, method, body) => {
const server = createServer((request, response) => {
if (request.url === "/start") {
request.resume();
response.writeHead(status, { location: "/target" });
response.end();
return;
}
let receivedBody = "";
request.setEncoding("utf8");
request.on("data", (chunk: string) => {
receivedBody += chunk;
});
request.on("end", () => {
response.writeHead(200, { "content-type": "application/json" });
response.end(
JSON.stringify({
method: request.method,
body: receivedBody,
contentType: request.headers["content-type"] ?? null,
}),
);
});
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (!address || typeof address === "string") throw new Error("Test server did not expose a TCP port");
const baseUrl = `http://127.0.0.1:${address.port}`;
try {
await expect(
executeCustomWidgetRequest({
baseUrl,
targetUrl: `${baseUrl}/start`,
method: "POST",
body: '{"query":"status"}',
networkScope: "loopback",
kind: "query",
}),
).resolves.toMatchObject({
ok: true,
data: {
method,
body,
contentType: body ? "application/json" : null,
},
});
} finally {
await new Promise<void>((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
}
});
test("does not follow redirects for actions", async () => {
const server = createServer((_request, response) => {
response.writeHead(303, { location: "/target" });
response.end();
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (!address || typeof address === "string") throw new Error("Test server did not expose a TCP port");
const baseUrl = `http://127.0.0.1:${address.port}`;
try {
await expect(
executeCustomWidgetRequest({
baseUrl,
targetUrl: `${baseUrl}/start`,
method: "POST",
networkScope: "loopback",
kind: "action",
}),
).rejects.toThrow("redirect limit");
} finally {
await new Promise<void>((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
}
});
test("evicts declared response-cache prefixes after an action", async () => {
let calls = 0;
const server = createServer((_request, response) => {
calls += 1;
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify({ calls }));
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (!address || typeof address === "string") throw new Error("Test server did not expose a TCP port");
const cacheKey = "custom-jsx:item:status:params";
const input = {
baseUrl: `http://127.0.0.1:${address.port}`,
method: "GET" as const,
networkScope: "loopback" as const,
kind: "query" as const,
cacheKey,
cacheTtlSeconds: 60,
};
try {
await expect(executeCustomWidgetRequest(input)).resolves.toMatchObject({ data: { calls: 1 } });
await expect(executeCustomWidgetRequest(input)).resolves.toMatchObject({ data: { calls: 1 } });
invalidateCustomWidgetResponseCache(["custom-jsx:item:status:"]);
await expect(executeCustomWidgetRequest(input)).resolves.toMatchObject({ data: { calls: 2 } });
} finally {
invalidateCustomWidgetResponseCache(["custom-jsx:item:status:"]);
await new Promise<void>((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
}
});
test("allows only credential-free HTTP(S) URLs without fragments", () => {
expect(validateCustomWidgetUrl("https://example.com/api").href).toBe("https://example.com/api");
expect(() => validateCustomWidgetUrl("file:///etc/passwd")).toThrow("HTTP and HTTPS");
@@ -53,29 +53,16 @@ describe("Custom Widget workbench UI", () => {
expect(host.querySelector('button[aria-label="Insert path"]')).not.toBeNull();
});
test("keeps mutation preview execution disabled and labels every capability control", async () => {
test("keeps mutation preview execution disabled without a network-capability badge", async () => {
await render(
<PreviewHeader
method="POST"
url="https://example.com"
methods={["GET", "POST"]}
hasNamedActions
hasPreviewSession={false}
liveActions={false}
isUpdatingLiveActions={false}
isTesting={false}
isSampleStale={false}
onTest={vi.fn()}
methodColor={() => "blue"}
messages={{
title: "Preview",
interactive: "Interactive",
capabilitiesTitle: "Capabilities",
capabilitiesDescription: "Declared operations",
liveActions: "Live actions",
liveActionsDescription: "Enabled",
runTestFirst: "Run test first",
simulated: "Simulated",
test: "Test",
mutationDisabled: "Mutations are disabled",
staleTitle: "Stale",
@@ -86,7 +73,7 @@ describe("Custom Widget workbench UI", () => {
const testButton = [...host.querySelectorAll("button")].find((button) => button.textContent?.trim() === "Test");
expect(testButton?.disabled).toBe(true);
expect(host.textContent).toContain("Mutations are disabled");
expect(host.textContent).toContain("Interactive");
expect(host.textContent).not.toContain("Interactive");
});
test("shows an empty response state without exposing a raw JSON toggle", async () => {
@@ -1,67 +1,164 @@
import { describe, expect, it } from "vitest";
import { CUSTOM_WIDGET_STARTER, customWidgetDefinitionSchema } from "../core";
import {
CUSTOM_WIDGET_OPTIONS_EXAMPLES,
CUSTOM_WIDGET_REQUEST_EXAMPLES,
getCustomWidgetOptionsJsonSchema,
getCustomWidgetRequestsJsonSchema,
} from "../core/schema-references";
import {
analyzeCustomWidgetAccessibility,
analyzeJsxTemplate,
analyzeRequestManifest,
appendDataPath,
createResponseTreeNode,
CUSTOM_JSX_TEMPLATE_LIMIT,
DEFAULT_CUSTOM_WIDGET_FORM_VALUES,
customWidgetFormSchema,
DEFAULT_CUSTOM_WIDGET_FORM_VALUES,
renameCustomWidgetOption,
renameCustomWidgetRequest,
} from "../workbench";
import { getCustomJsxComponentPropCompletions } from "../workbench/code-language";
describe("Custom Widget workbench contracts", () => {
it("always provides a valid starter template", () => {
expect(DEFAULT_CUSTOM_WIDGET_FORM_VALUES.template.trim()).not.toBe("");
expect(
customWidgetFormSchema.safeParse({
...DEFAULT_CUSTOM_WIDGET_FORM_VALUES,
name: "Example",
url: "https://example.com/api",
}).success,
).toBe(true);
it("provides valid lean starter form values", () => {
expect(customWidgetFormSchema.safeParse(DEFAULT_CUSTOM_WIDGET_FORM_VALUES).success).toBe(true);
expect(JSON.parse(DEFAULT_CUSTOM_WIDGET_FORM_VALUES.sources)).toHaveProperty("default");
});
it("accepts 50,000 template characters and rejects 50,001", () => {
expect(analyzeJsxTemplate("x".repeat(CUSTOM_JSX_TEMPLATE_LIMIT))).not.toEqual(
it("validates keyed request manifests", () => {
expect(analyzeRequestManifest(JSON.stringify(CUSTOM_WIDGET_REQUEST_EXAMPLES.full))).toEqual([]);
expect(analyzeRequestManifest("[]")).toEqual(
expect.arrayContaining([expect.objectContaining({ code: "invalidRequestManifest" })]),
);
expect(getCustomWidgetRequestsJsonSchema()).toMatchObject({ type: "object" });
expect(getCustomWidgetOptionsJsonSchema()).toMatchObject({ type: "object" });
expect(CUSTOM_WIDGET_OPTIONS_EXAMPLES.full.limit.default).toBe(20);
});
it("enforces the template size and named request references", () => {
expect(analyzeJsxTemplate("x".repeat(CUSTOM_JSX_TEMPLATE_LIMIT + 1))).toEqual(
expect.arrayContaining([expect.objectContaining({ code: "templateTooLong" })]),
);
expect(analyzeJsxTemplate("x".repeat(CUSTOM_JSX_TEMPLATE_LIMIT + 1))).toEqual(
expect.arrayContaining([expect.objectContaining({ code: "templateTooLong", severity: "error" })]),
);
});
it("validates full request manifests rather than accepting any JSON array", () => {
expect(analyzeRequestManifest("[]")).toEqual([]);
expect(analyzeRequestManifest('[{"id":"broken"}]')).toEqual(
expect.arrayContaining([expect.objectContaining({ code: "invalidRequestManifest", severity: "error" })]),
);
});
it("validates named request references and rejects inline network capabilities", () => {
expect(analyzeJsxTemplate('<SubFetch requestId="known" />', { apiVersion: 2, requestIds: ["known"] })).toEqual([]);
expect(
analyzeJsxTemplate('<SubFetch requestId="missing" url="/api" />', { apiVersion: 2, requestIds: ["known"] }),
).toEqual(
expect(analyzeJsxTemplate('<SubFetch requestId="known" />', { requestIds: ["known"] })).toEqual([]);
expect(analyzeJsxTemplate('<SubFetch requestId="missing" url="/api" />', { requestIds: ["known"] })).toEqual(
expect.arrayContaining([
expect.objectContaining({ code: "legacyNetworkProps" }),
expect.objectContaining({ code: "unknownRequest", value: "missing" }),
expect.objectContaining({ code: "inlineRequestProps" }),
expect.objectContaining({ code: "unknownRequest" }),
]),
);
});
it("uses the AST security policy for computed reflective access while accepting benign fetch text", () => {
expect(analyzeJsxTemplate("<Text>{data['con' + 'structor']}</Text>")).toEqual(
expect.arrayContaining([expect.objectContaining({ code: "ast", severity: "error" })]),
it("supports temporary inputs and diagnoses incompatible bindings", () => {
expect(analyzeJsxTemplate('<TextInput bind="filter"/><Switch bind="filter"/>')).toEqual(
expect.arrayContaining([expect.objectContaining({ code: "bindingTypeConflict" })]),
);
expect(analyzeJsxTemplate("<TextInput bind={inputs.filter} />")).toEqual(
expect.arrayContaining([expect.objectContaining({ code: "dynamicInputBinding" })]),
);
expect(analyzeJsxTemplate("<Text>fetch the latest status</Text>")).toEqual([]);
});
it("builds safe response-tree paths for identifiers, arrays, and arbitrary object keys", () => {
expect(appendDataPath("data", "items", false)).toBe("data.items");
expect(appendDataPath("data.items", "0", true)).toBe("data.items[0]");
it("does not advertise statement blocks or IIFEs", () => {
expect(
analyzeJsxTemplate("<Text>{data.items.map(item => { const name = item.name; return name; })}</Text>"),
).toEqual(
expect.arrayContaining([
expect.objectContaining({ code: "ast", value: expect.stringContaining("UNSUPPORTED_BLOCK_STATEMENT") }),
]),
);
expect(analyzeJsxTemplate("<Text>{(() => data.name)()}</Text>")).toEqual(
expect.arrayContaining([expect.objectContaining({ code: "ast", value: expect.stringContaining("IIFEs") })]),
);
});
it("supports compound names and contextual prop completions", () => {
expect(analyzeJsxTemplate("<Card.Section><Tabs.List /></Card.Section>")).toEqual([]);
expect(getCustomJsxComponentPropCompletions("TextInput")).toEqual(
expect.arrayContaining([expect.objectContaining({ label: "bind=" })]),
);
});
it("renames keyed request references atomically", () => {
const renamed = renameCustomWidgetRequest(
{
...customWidgetDefinitionSchema.parse(CUSTOM_WIDGET_STARTER),
requests: {
items: {
source: "default",
kind: "query",
method: "GET",
path: "/items",
auth: "inherit",
permission: "view",
trigger: "load",
confirmation: undefined,
},
refresh: {
source: "default",
kind: "action",
method: "POST",
path: "/refresh",
auth: "inherit",
permission: "modify",
trigger: "manual",
confirmation: undefined,
invalidates: ["items"],
},
},
options: {
item: {
label: "Item",
control: "select",
default: "",
choicesFrom: { request: "items", valuePath: "id", labelPath: "name" },
},
},
template: '<Stack>{data["items"]}{status.items.ok}</Stack>',
},
"items",
"inventory",
);
expect(renamed.requests.inventory).toBeDefined();
expect(renamed.requests.refresh?.invalidates).toEqual(["inventory"]);
expect(renamed.options.item?.choicesFrom?.request).toBe("inventory");
expect(renamed.template).toContain("inventory");
});
it("renames option references atomically", () => {
const renamed = renameCustomWidgetOption(
customWidgetDefinitionSchema.parse({
...CUSTOM_WIDGET_STARTER,
options: { room: { label: "Room", control: "text", default: "office" } },
requests: {
status: {
path: "/rooms/{option:room}",
query: { selected: { $option: "room" } },
body: { room: { $option: "room" } },
},
},
template: '<Text>{options.room}{options["room"]}</Text>',
}),
"room",
"roomId",
);
expect(renamed.options.roomId?.default).toBe("office");
expect(renamed.requests.status?.path).toBe("/rooms/{option:roomId}");
expect(renamed.requests.status?.query).toEqual({ selected: { $option: "roomId" } });
expect(renamed.requests.status?.body).toEqual({ room: { $option: "roomId" } });
expect(renamed.template).toBe('<Text>{options.roomId}{options["roomId"]}</Text>');
});
it("builds response paths and accessibility diagnostics", () => {
expect(appendDataPath("data", "first-name", false)).toBe('data["first-name"]');
const tree = createResponseTreeNode({ items: [{ name: "one" }] }, "data", "data");
expect(tree.children?.[0]?.children?.[0]?.children?.[0]?.value).toBe("data.items[0].name");
expect(
createResponseTreeNode({ items: [{ name: "one" }] }, "data", "data").children?.[0]?.children?.[0]?.children?.[0]
?.value,
).toBe("data.items[0].name");
expect(analyzeCustomWidgetAccessibility('<Image src="/image.png"/><ActionIcon/><TextInput />')).toEqual([
"imageAlt",
"actionIconLabel",
"inputLabel",
]);
});
});
@@ -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");
}
});
});