feat(custom-widgets): complete JSX v2
This commit is contained in:
263 files changed
+65503
-17816
No files matched your search
@@ -1,32 +1,87 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { buildCustomWidgetAiPrompt } from "../core/ai-prompt";
|
||||
import { buildCustomWidgetAiPrompt, buildCustomWidgetFixPrompt, buildCustomWidgetMcpPrompt } from "../core/ai-prompt";
|
||||
import { CUSTOM_WIDGET_STARTER } from "../core/examples";
|
||||
|
||||
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"}');
|
||||
test("is concise and describes the one-widget v2 contract", () => {
|
||||
const prompt = buildCustomWidgetAiPrompt(
|
||||
undefined,
|
||||
null,
|
||||
null,
|
||||
"Create a Portainer widget",
|
||||
"https://example.com/docs",
|
||||
);
|
||||
expect(prompt).toContain("homarr-custom-widget-v2");
|
||||
expect(prompt).toContain("Return exactly two fenced blocks");
|
||||
expect(prompt).toContain("Never include credentials");
|
||||
expect(prompt).toContain("Create a Portainer widget");
|
||||
expect(prompt.length).toBeLessThanOrEqual(8_000);
|
||||
});
|
||||
|
||||
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: [] },
|
||||
});
|
||||
test("includes an existing widget without exposing secret fields", () => {
|
||||
const secret = "TOP-SECRET-123";
|
||||
const source = CUSTOM_WIDGET_STARTER.sources[0];
|
||||
if (!source) throw new Error("Starter widget requires one source");
|
||||
const prompt = buildCustomWidgetAiPrompt(
|
||||
undefined,
|
||||
JSON.stringify({ token: secret }),
|
||||
{
|
||||
...CUSTOM_WIDGET_STARTER,
|
||||
sources: [{ ...source, baseUrl: `https://user:${secret}@example.com?api_key=${secret}` }],
|
||||
secrets: [{ sourceId: "default", kind: "apiKey", value: secret }],
|
||||
},
|
||||
`Improve it with API key: ${secret}`,
|
||||
`https://example.com/docs?token=${secret}`,
|
||||
);
|
||||
expect(prompt).toContain('"$schema": "homarr-custom-widget-v2"');
|
||||
expect(prompt).not.toContain(secret);
|
||||
expect(prompt).not.toContain("user:");
|
||||
expect(prompt).not.toContain("api_key=");
|
||||
});
|
||||
|
||||
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");
|
||||
test("redacts camel-case credentials and invalid structured editor drafts", () => {
|
||||
const prompt = buildCustomWidgetAiPrompt(undefined, null, {
|
||||
sources: '[{"baseUrl":"https://user:password@example.com?accessToken=VALUE"}]',
|
||||
requests: '[{"bodyTemplate":{"clientSecret":"VALUE"}}]',
|
||||
template: "<Text>accessToken: VALUE</Text>",
|
||||
});
|
||||
expect(prompt).not.toContain("password@");
|
||||
expect(prompt).not.toContain('clientSecret": "VALUE');
|
||||
expect(prompt).not.toContain("accessToken: VALUE");
|
||||
});
|
||||
|
||||
test("redacts malformed URLs without recursing", () => {
|
||||
expect(buildCustomWidgetAiPrompt(undefined, null, null, "Inspect http://[", null)).toContain("[REDACTED_URL]");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildCustomWidgetFixPrompt", () => {
|
||||
test("includes the complete current widget and redacted iteration evidence", () => {
|
||||
const secret = "literal-sensitive-value";
|
||||
const prompt = buildCustomWidgetFixPrompt({
|
||||
currentConfig: {
|
||||
name: "Large widget",
|
||||
template: `<Text>${"x".repeat(9_000)}</Text>`,
|
||||
secrets: [{ value: secret }],
|
||||
},
|
||||
request: `Fix it with password='${secret}'`,
|
||||
diagnostics: `Header apiKey='${secret}' is invalid`,
|
||||
journal: [{ requestId: "load", authorization: secret }],
|
||||
});
|
||||
expect(prompt).toContain("x".repeat(9_000));
|
||||
expect(prompt).toContain("Validation diagnostics");
|
||||
expect(prompt).not.toContain(secret);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildCustomWidgetMcpPrompt", () => {
|
||||
test("describes tool-driven iteration without embedding the copy/paste output contract", () => {
|
||||
const prompt = buildCustomWidgetMcpPrompt("Create two Portainer widgets", "https://example.com/docs");
|
||||
expect(prompt).toContain("customWidget_previewCreate");
|
||||
expect(prompt).toContain("homarr://custom-widgets/schema");
|
||||
expect(prompt).toContain("Create two Portainer widgets");
|
||||
expect(prompt).not.toContain("Return exactly two fenced blocks");
|
||||
expect(prompt.length).toBeLessThan(8_000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
CUSTOM_WIDGET_SKILL_MD,
|
||||
CUSTOM_WIDGET_SKILL_SHA256,
|
||||
getCustomWidgetComponent,
|
||||
getCustomWidgetSkill,
|
||||
} from "../core/authoring-resources";
|
||||
|
||||
const repositoryRoot = resolve(import.meta.dirname, "../../../..");
|
||||
|
||||
describe("custom widget Agent Skill", () => {
|
||||
test("keeps the MCP skill text synchronized with the repository source", async () => {
|
||||
const source = await readFile(resolve(repositoryRoot, ".agents/skills/homarr-custom-widget/SKILL.md"), "utf8");
|
||||
expect(CUSTOM_WIDGET_SKILL_MD).toBe(source);
|
||||
expect(getCustomWidgetSkill().skillMd).toBe(source);
|
||||
});
|
||||
|
||||
test("publishes the exact archive hash", async () => {
|
||||
const archive = await readFile(
|
||||
resolve(repositoryRoot, "apps/docs/static/downloads/homarr-custom-widget-2.0.0.zip"),
|
||||
);
|
||||
expect(createHash("sha256").update(archive).digest("hex")).toBe(CUSTOM_WIDGET_SKILL_SHA256);
|
||||
});
|
||||
|
||||
test("publishes safe icon names with the Tabler icon component", () => {
|
||||
expect(getCustomWidgetComponent("TablerIcon")?.knownValues?.name).toContain("server");
|
||||
});
|
||||
});
|
||||
@@ -183,12 +183,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);
|
||||
});
|
||||
@@ -207,6 +209,9 @@ describe("customJsxComponentRegistry", () => {
|
||||
expect(component.supportedProps, component.name).toBeInstanceOf(Array);
|
||||
expect(component.subcomponents, component.name).toBeInstanceOf(Array);
|
||||
expect(component.accessibilityRequirements, 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");
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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([]);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,68 +1,31 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { parseCustomWidgetClipboard } from "../core/import";
|
||||
import { customWidgetImportSchema } from "../core/schema";
|
||||
|
||||
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__",
|
||||
},
|
||||
};
|
||||
import { CUSTOM_WIDGET_STARTER } from "../core/examples";
|
||||
import { parseCustomWidgetClipboard, parseCustomWidgetClipboardDetailed } from "../core/import";
|
||||
|
||||
describe("parseCustomWidgetClipboard", () => {
|
||||
it("keeps supporting raw JSON imports", () => {
|
||||
const result = parseCustomWidgetClipboard(JSON.stringify({ ...widget, displayType: "raw" }));
|
||||
expect(result?.name).toBe("Example");
|
||||
it("accepts the canonical raw JSON export", () => {
|
||||
expect(parseCustomWidgetClipboard(JSON.stringify(CUSTOM_WIDGET_STARTER))?.$schema).toBe("homarr-custom-widget-v2");
|
||||
});
|
||||
|
||||
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("accepts case-insensitive fenced languages and CRLF line endings", () => {
|
||||
it("combines widget.json and widget.jsx blocks", () => {
|
||||
const manifest = { ...CUSTOM_WIDGET_STARTER, template: "__HOMARR_TEMPLATE__" };
|
||||
const result = parseCustomWidgetClipboard(
|
||||
`\`\`\`JSON\r\n${JSON.stringify(widget)}\r\n\`\`\`\r\n\`\`\`TSX\r\n<Text>{data.name}</Text>\r\n\`\`\``,
|
||||
`\`\`\`json\n${JSON.stringify(manifest)}\n\`\`\`\n\`\`\`jsx\n<Text>{options.title}</Text>\n\`\`\``,
|
||||
);
|
||||
|
||||
expect((result?.displayConfig as Record<string, unknown> | undefined)?.template).toBe("<Text>{data.name}</Text>");
|
||||
expect(result?.template).toBe("<Text>{options.title}</Text>");
|
||||
});
|
||||
|
||||
it("rejects an unterminated fence with a large whitespace payload", () => {
|
||||
expect(parseCustomWidgetClipboard(`\`\`\`json\n${" ".repeat(250_000)}`)).toBeNull();
|
||||
it("rejects removed display types and old exports", () => {
|
||||
expect(
|
||||
parseCustomWidgetClipboard(JSON.stringify({ displayType: "raw", displayConfig: { type: "raw" } })),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("returns an actionable validation issue for invalid AI output", () => {
|
||||
expect(parseCustomWidgetClipboardDetailed(JSON.stringify({ $schema: "homarr-custom-widget-v2" }))).toEqual({
|
||||
success: false,
|
||||
error: expect.stringMatching(/name|sources/u),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -28,4 +28,97 @@ 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 });
|
||||
});
|
||||
|
||||
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(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") }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -22,11 +22,23 @@ function createHarness() {
|
||||
|
||||
const input: CreatePreviewSessionInput = {
|
||||
userId: "user-1",
|
||||
baseUrl: "https://example.com/api",
|
||||
authType: "bearer",
|
||||
secrets: [{ kind: "apiKey", value: "secret" }],
|
||||
networkScope: "public",
|
||||
sources: [
|
||||
{
|
||||
id: "default",
|
||||
name: "API",
|
||||
baseUrl: "https://example.com/api",
|
||||
auth: { type: "bearer" },
|
||||
networkScope: "public",
|
||||
},
|
||||
],
|
||||
secrets: [{ sourceId: "default", kind: "apiKey", value: "secret" }],
|
||||
requests: [],
|
||||
name: "Preview",
|
||||
template: "<Text>Preview</Text>",
|
||||
optionsSchema: { type: "object", properties: {}, additionalProperties: false },
|
||||
defaultOptions: {},
|
||||
stateSchema: {},
|
||||
defaultState: {},
|
||||
};
|
||||
|
||||
describe("preview session service", () => {
|
||||
@@ -35,15 +47,15 @@ describe("preview session service", () => {
|
||||
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" }]);
|
||||
expect(service.getSecrets(session, "default")).toEqual([{ kind: "apiKey", value: "secret" }]);
|
||||
});
|
||||
|
||||
it("isolates sessions by user and expires them after five minutes", async () => {
|
||||
it("isolates sessions by user and expires them after ten 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);
|
||||
advance(10 * 60_000 + 1);
|
||||
await expect(service.get(second.id, input.userId)).rejects.toMatchObject({ code: "NOT_FOUND" });
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { hashRuntimeParams, renderRequestBody, renderRequestTarget, validateRunt
|
||||
|
||||
const request: CustomJsxRequest = {
|
||||
id: "device",
|
||||
sourceId: "default",
|
||||
kind: "action",
|
||||
method: "PATCH",
|
||||
pathTemplate: "/devices/{device}/state",
|
||||
@@ -12,6 +13,8 @@ const request: CustomJsxRequest = {
|
||||
bodyTemplate: { enabled: { $param: "enabled" }, nested: { level: { $param: "level" } } },
|
||||
auth: "inherit",
|
||||
minimumBoardPermission: "modify",
|
||||
trigger: "manual",
|
||||
queryTemplate: { verbose: true, level: { $param: "level" } },
|
||||
};
|
||||
|
||||
describe("named request manifest rendering", () => {
|
||||
@@ -28,6 +31,7 @@ describe("named request manifest rendering", () => {
|
||||
expect(renderRequestTarget("https://example.com/api", request, params).pathname).toBe(
|
||||
"/devices/living%20room%2F1/state",
|
||||
);
|
||||
expect(renderRequestTarget("https://example.com/api", request, params).searchParams.get("level")).toBe("5");
|
||||
expect(JSON.parse(renderRequestBody(request.bodyTemplate, params) ?? "null")).toEqual({
|
||||
enabled: true,
|
||||
nested: { level: 5 },
|
||||
|
||||
@@ -4,20 +4,21 @@ import { act } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { Root } from "react-dom/client";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { MantineProvider, Text } 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 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 +75,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 +88,7 @@ async function render(
|
||||
requestCapabilities={capabilities}
|
||||
port={port}
|
||||
messages={messages}
|
||||
setQueryState={setQueryState}
|
||||
>
|
||||
{node}
|
||||
</CustomWidgetRuntimeProvider>
|
||||
@@ -102,15 +105,48 @@ 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();
|
||||
await render(
|
||||
<SubFetch requestId="details" render={(data) => <span>{(data as { name: string }).name}</span>} />,
|
||||
port,
|
||||
it("does not republish local state when equivalent default objects are rendered", async () => {
|
||||
const onStateChange = vi.fn();
|
||||
const rendererMessages = {
|
||||
noTemplate: "No template",
|
||||
interactive: "Interactive",
|
||||
networkCapabilities: "Network capabilities",
|
||||
templateWarnings: (count: number) => `${count} warnings`,
|
||||
};
|
||||
const widget = () => (
|
||||
<CustomJsxRenderer
|
||||
template="<Text>{state.search}</Text>"
|
||||
data={{}}
|
||||
stateSchema={{ search: "string" }}
|
||||
defaultState={{ search: "containers" }}
|
||||
onStateChange={onStateChange}
|
||||
requestCapabilities={[]}
|
||||
components={{ Text: Text as never }}
|
||||
createBindings={() => ({})}
|
||||
messages={rendererMessages}
|
||||
/>
|
||||
);
|
||||
|
||||
await render(widget(), createPort());
|
||||
await settle();
|
||||
expect(onStateChange).toHaveBeenCalledTimes(1);
|
||||
|
||||
await render(widget(), createPort());
|
||||
await settle();
|
||||
expect(onStateChange).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
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(host.textContent).toContain("Bulbasaur");
|
||||
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("passes cancellation to the port when the component unmounts", async () => {
|
||||
@@ -129,8 +165,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();
|
||||
@@ -154,4 +197,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" onParams={{ enabled: true }} offParams={{ 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 }));
|
||||
});
|
||||
});
|
||||
@@ -1,166 +1,424 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { customJsxExamples } from "../core/examples";
|
||||
import { customJsxDisplayConfigV2Schema, customJsxRequestSchema, displayConfigSchema } from "../core/schema";
|
||||
import { validateCustomJsxTemplate } from "../jsx/analyzer";
|
||||
import { customJsxExamples, CUSTOM_WIDGET_STARTER } from "../core/examples";
|
||||
import { customJsxRequestSchema, customWidgetCreateSchema, customWidgetDefinitionSchema } from "../core/schema";
|
||||
import { validateCustomWidgetOptions } from "../core/options";
|
||||
|
||||
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();
|
||||
describe("Custom JSX v2 validation", () => {
|
||||
test.each(customJsxExamples)("validates $id", (example) => {
|
||||
expect(() => customWidgetDefinitionSchema.parse(example.widget)).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>",
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
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([]);
|
||||
});
|
||||
|
||||
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: [],
|
||||
};
|
||||
expect(customJsxDisplayConfigV2Schema.safeParse({ ...config, template: "<Text>" }).success).toBe(false);
|
||||
test("rejects every legacy schema", () => {
|
||||
expect(
|
||||
customJsxDisplayConfigV2Schema.safeParse({ ...config, template: '<Text>{fetch("/private")}</Text>' }).success,
|
||||
customWidgetDefinitionSchema.safeParse({ ...CUSTOM_WIDGET_STARTER, $schema: "homarr-custom-widget-v3" }).success,
|
||||
).toBe(false);
|
||||
expect(customWidgetDefinitionSchema.safeParse({ displayType: "raw", displayConfig: { type: "raw" } }).success).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("allows query POST and action GET while keeping actions manual", () => {
|
||||
const base = {
|
||||
sourceId: "default",
|
||||
pathTemplate: "/status",
|
||||
parameters: {},
|
||||
auth: "none" as const,
|
||||
trigger: "manual" as const,
|
||||
};
|
||||
expect(
|
||||
customJsxRequestSchema.safeParse({
|
||||
...base,
|
||||
id: "query",
|
||||
kind: "query",
|
||||
method: "POST",
|
||||
minimumBoardPermission: "view",
|
||||
}).success,
|
||||
).toBe(true);
|
||||
expect(
|
||||
customJsxRequestSchema.safeParse({
|
||||
...base,
|
||||
id: "action",
|
||||
kind: "action",
|
||||
method: "GET",
|
||||
minimumBoardPermission: "modify",
|
||||
}).success,
|
||||
).toBe(true);
|
||||
expect(
|
||||
customJsxRequestSchema.safeParse({
|
||||
...base,
|
||||
id: "action",
|
||||
kind: "action",
|
||||
method: "POST",
|
||||
trigger: "load",
|
||||
minimumBoardPermission: "modify",
|
||||
}).success,
|
||||
).toBe(false);
|
||||
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",
|
||||
},
|
||||
],
|
||||
customJsxRequestSchema.safeParse({
|
||||
...base,
|
||||
id: "delete-query",
|
||||
kind: "query",
|
||||
method: "DELETE",
|
||||
minimumBoardPermission: "full",
|
||||
}).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", () => {
|
||||
test("rejects reserved headers, duplicate IDs, and unknown sources", () => {
|
||||
const request = {
|
||||
id: "status",
|
||||
sourceId: "missing",
|
||||
kind: "query" as const,
|
||||
method: "GET" as const,
|
||||
pathTemplate: "/status",
|
||||
parameters: {},
|
||||
auth: "none" as const,
|
||||
minimumBoardPermission: "view" as const,
|
||||
trigger: "load" as const,
|
||||
};
|
||||
const result = customJsxDisplayConfigV2Schema.safeParse({
|
||||
type: "customJsx",
|
||||
jsxApiVersion: 2,
|
||||
template: "<Text />",
|
||||
networkScope: "public",
|
||||
requests: [request, request],
|
||||
expect(customWidgetDefinitionSchema.safeParse({ ...CUSTOM_WIDGET_STARTER, requests: [request] }).success).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
customJsxRequestSchema.safeParse({ ...request, sourceId: "default", staticHeaders: { Authorization: "secret" } })
|
||||
.success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
customJsxRequestSchema.safeParse({
|
||||
...request,
|
||||
sourceId: "default",
|
||||
confirmation: { title: "Ignored", message: "Ignored" },
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(customJsxRequestSchema.safeParse({ ...request, sourceId: "default", invalidates: ["status"] }).success).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
customWidgetCreateSchema.safeParse({
|
||||
...CUSTOM_WIDGET_STARTER,
|
||||
secrets: [
|
||||
{ sourceId: "default", kind: "apiKey", value: "one" },
|
||||
{ sourceId: "default", kind: "apiKey", value: "two" },
|
||||
],
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("accepts multi-source actions, dynamic options, conditions, and typed state", () => {
|
||||
const result = customWidgetDefinitionSchema.safeParse({
|
||||
...CUSTOM_WIDGET_STARTER,
|
||||
sources: [
|
||||
{
|
||||
id: "sonarr",
|
||||
name: "Sonarr",
|
||||
baseUrl: "http://sonarr:8989",
|
||||
networkScope: "private",
|
||||
auth: { type: "apiKeyHeader", headerName: "X-Api-Key" },
|
||||
},
|
||||
{
|
||||
id: "radarr",
|
||||
name: "Radarr",
|
||||
baseUrl: "http://radarr:7878",
|
||||
networkScope: "private",
|
||||
auth: { type: "apiKeyHeader", headerName: "X-Api-Key" },
|
||||
},
|
||||
],
|
||||
requests: [
|
||||
{
|
||||
id: "list-calendars",
|
||||
sourceId: "sonarr",
|
||||
kind: "query",
|
||||
method: "POST",
|
||||
pathTemplate: "/api/v3/calendar",
|
||||
parameters: { start: "string" },
|
||||
bodyTemplate: { start: { $param: "start" } },
|
||||
auth: "inherit",
|
||||
minimumBoardPermission: "view",
|
||||
trigger: "manual",
|
||||
},
|
||||
{
|
||||
id: "monitor-series",
|
||||
sourceId: "sonarr",
|
||||
kind: "action",
|
||||
method: "GET",
|
||||
pathTemplate: "/api/v3/series/{seriesId}",
|
||||
parameters: { seriesId: "number" },
|
||||
auth: "inherit",
|
||||
minimumBoardPermission: "modify",
|
||||
trigger: "manual",
|
||||
confirmation: { title: "Monitor series", message: "Monitor this series?" },
|
||||
invalidates: ["list-calendars"],
|
||||
},
|
||||
],
|
||||
optionsSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
start: { type: "string", title: "Start date", "x-homarr": { control: "date", order: 1 } },
|
||||
calendarId: {
|
||||
type: "integer",
|
||||
title: "Calendar",
|
||||
"x-homarr": {
|
||||
control: "select",
|
||||
optionsSource: {
|
||||
requestId: "list-calendars",
|
||||
itemsPath: "$.data.environments",
|
||||
valuePath: "$.Id",
|
||||
labelPath: "$.Name",
|
||||
},
|
||||
},
|
||||
},
|
||||
advancedColor: { type: "string", "x-homarr": { control: "color", advanced: true } },
|
||||
},
|
||||
required: ["start"],
|
||||
additionalProperties: false,
|
||||
if: { properties: { start: { const: "today" } } },
|
||||
// oxlint-disable-next-line unicorn/no-thenable -- JSON Schema uses the standard `then` keyword.
|
||||
then: { properties: { advancedColor: { type: "string" } } },
|
||||
},
|
||||
defaultOptions: { start: "today" },
|
||||
stateSchema: { selectedDate: "date", search: "string", opened: "boolean", selectedEntities: "string[]" },
|
||||
defaultState: { selectedDate: "2026-07-17", search: "", opened: false, selectedEntities: ["light.office"] },
|
||||
template:
|
||||
'<Stack><Calendar bind="selectedDate"/><ActionButton requestId="monitor-series" params={{ seriesId: 1 }}>Monitor</ActionButton></Stack>',
|
||||
});
|
||||
expect(result.success, result.error?.issues.map((issue) => issue.message).join("; ")).toBe(true);
|
||||
});
|
||||
|
||||
test("rejects unsafe source URLs, auth overrides, invalid defaults, and missing dynamic requests", () => {
|
||||
const unsafeSource = { ...CUSTOM_WIDGET_STARTER.sources[0], baseUrl: "https://user:pass@example.com?token=nope" };
|
||||
expect(customWidgetDefinitionSchema.safeParse({ ...CUSTOM_WIDGET_STARTER, sources: [unsafeSource] }).success).toBe(
|
||||
false,
|
||||
);
|
||||
|
||||
const source = {
|
||||
...CUSTOM_WIDGET_STARTER.sources[0],
|
||||
auth: { type: "apiKeyHeader" as const, headerName: "X-Api-Key" },
|
||||
};
|
||||
const request = {
|
||||
id: "status",
|
||||
sourceId: "default",
|
||||
kind: "query" as const,
|
||||
method: "GET" as const,
|
||||
pathTemplate: "/status",
|
||||
parameters: {},
|
||||
auth: "inherit" as const,
|
||||
minimumBoardPermission: "view" as const,
|
||||
trigger: "load" as const,
|
||||
staticHeaders: { "x-api-key": "not-a-secret" },
|
||||
};
|
||||
expect(
|
||||
customWidgetDefinitionSchema.safeParse({ ...CUSTOM_WIDGET_STARTER, sources: [source], requests: [request] })
|
||||
.success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
customWidgetDefinitionSchema.safeParse({ ...CUSTOM_WIDGET_STARTER, defaultOptions: { apiKey: "not-exportable" } })
|
||||
.success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
customWidgetDefinitionSchema.safeParse({
|
||||
...CUSTOM_WIDGET_STARTER,
|
||||
optionsSchema: {
|
||||
type: "object",
|
||||
properties: { accessToken: { type: "string" } },
|
||||
additionalProperties: false,
|
||||
},
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
customWidgetDefinitionSchema.safeParse({
|
||||
...CUSTOM_WIDGET_STARTER,
|
||||
stateSchema: { accessToken: "string" },
|
||||
defaultState: {},
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
customWidgetDefinitionSchema.safeParse({
|
||||
...CUSTOM_WIDGET_STARTER,
|
||||
requests: [
|
||||
{
|
||||
...request,
|
||||
sourceId: "default",
|
||||
auth: "none",
|
||||
staticHeaders: { "X-Custom": "Bearer not-exportable-token" },
|
||||
},
|
||||
],
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
customJsxRequestSchema.safeParse({
|
||||
...request,
|
||||
sourceId: "default",
|
||||
auth: "none",
|
||||
staticHeaders: { "X-API-Key": "not-exportable" },
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
customWidgetDefinitionSchema.safeParse({
|
||||
...CUSTOM_WIDGET_STARTER,
|
||||
stateSchema: { count: "number" },
|
||||
defaultState: { count: "wrong" },
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
customWidgetDefinitionSchema.safeParse({
|
||||
...CUSTOM_WIDGET_STARTER,
|
||||
requests: [
|
||||
{
|
||||
id: "action",
|
||||
sourceId: "default",
|
||||
kind: "action",
|
||||
method: "POST",
|
||||
pathTemplate: "/action",
|
||||
parameters: {},
|
||||
auth: "none",
|
||||
minimumBoardPermission: "modify",
|
||||
trigger: "manual",
|
||||
invalidates: ["missing"],
|
||||
},
|
||||
],
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
customWidgetDefinitionSchema.safeParse({
|
||||
...CUSTOM_WIDGET_STARTER,
|
||||
optionsSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
endpoint: {
|
||||
type: "string",
|
||||
"x-homarr": {
|
||||
control: "select",
|
||||
optionsSource: { requestId: "missing", valuePath: "$.id", labelPath: "$.name" },
|
||||
},
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("requires literal bindings to reference declared camel-case state", () => {
|
||||
expect(
|
||||
customWidgetDefinitionSchema.safeParse({
|
||||
...CUSTOM_WIDGET_STARTER,
|
||||
stateSchema: { selectedDate: "date" },
|
||||
defaultState: { selectedDate: "2026-07-17" },
|
||||
template: '<Calendar bind="selectedDate" />',
|
||||
}).success,
|
||||
).toBe(true);
|
||||
expect(
|
||||
customWidgetDefinitionSchema.safeParse({
|
||||
...CUSTOM_WIDGET_STARTER,
|
||||
stateSchema: { selectedDate: "date" },
|
||||
template: '<Calendar bind="missingDate" />',
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
customWidgetDefinitionSchema.safeParse({
|
||||
...CUSTOM_WIDGET_STARTER,
|
||||
stateSchema: { selectedDate: "date" },
|
||||
template: "<Calendar bind={state.selectedDate} />",
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("validates values declared only by the active conditional branch", () => {
|
||||
const schema = {
|
||||
type: "object",
|
||||
properties: { mode: { type: "string", enum: ["simple", "advanced"] } },
|
||||
required: ["mode"],
|
||||
additionalProperties: false,
|
||||
if: { properties: { mode: { const: "advanced" } } },
|
||||
// oxlint-disable-next-line unicorn/no-thenable -- JSON Schema uses the standard `then` keyword.
|
||||
then: {
|
||||
properties: { color: { type: "string" } },
|
||||
required: ["color"],
|
||||
},
|
||||
};
|
||||
expect(validateCustomWidgetOptions(schema, { mode: "advanced", color: "blue" })).toEqual([]);
|
||||
expect(validateCustomWidgetOptions(schema, { mode: "advanced" })).toEqual([
|
||||
{ path: "configuration.color", message: "This field is required" },
|
||||
]);
|
||||
expect(validateCustomWidgetOptions(schema, { mode: "simple", color: "blue" })).toEqual([
|
||||
{ path: "configuration.color", message: "Unknown option" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("rejects unsafe custom authentication header names", () => {
|
||||
const result = customWidgetDefinitionSchema.safeParse({
|
||||
...CUSTOM_WIDGET_STARTER,
|
||||
sources: [
|
||||
{
|
||||
...CUSTOM_WIDGET_STARTER.sources[0],
|
||||
auth: { type: "apiKeyHeader", headerName: "X-Forwarded-Authorization" },
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects mutation-shaped queries and weak DELETE permissions", () => {
|
||||
test("rejects prototype-pollution keys in structured authored data", () => {
|
||||
const request = {
|
||||
id: "status",
|
||||
sourceId: "default",
|
||||
kind: "query" as const,
|
||||
method: "POST" as const,
|
||||
pathTemplate: "/status",
|
||||
parameters: {},
|
||||
bodyTemplate: JSON.parse('{"constructor":{"prototype":{"polluted":true}}}') as unknown,
|
||||
auth: "none" as const,
|
||||
minimumBoardPermission: "view" as const,
|
||||
trigger: "manual" as const,
|
||||
};
|
||||
expect(customWidgetDefinitionSchema.safeParse({ ...CUSTOM_WIDGET_STARTER, requests: [request] }).success).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects option controls, enum values, and icon URLs that do not match their declared capability", () => {
|
||||
expect(
|
||||
customJsxRequestSchema.safeParse({
|
||||
id: "mutating-query",
|
||||
kind: "query",
|
||||
method: "POST",
|
||||
pathTemplate: "/status",
|
||||
parameters: {},
|
||||
auth: "none",
|
||||
minimumBoardPermission: "view",
|
||||
customWidgetDefinitionSchema.safeParse({ ...CUSTOM_WIDGET_STARTER, iconUrl: "javascript:alert(1)" }).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
customWidgetDefinitionSchema.safeParse({
|
||||
...CUSTOM_WIDGET_STARTER,
|
||||
iconUrl: "https://user:password@example.com/icon.png",
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
customJsxRequestSchema.safeParse({
|
||||
id: "delete",
|
||||
kind: "action",
|
||||
method: "DELETE",
|
||||
pathTemplate: "/resource",
|
||||
parameters: {},
|
||||
auth: "inherit",
|
||||
minimumBoardPermission: "modify",
|
||||
customWidgetDefinitionSchema.safeParse({
|
||||
...CUSTOM_WIDGET_STARTER,
|
||||
iconUrl: "https://example.com/icon.png?accessToken=literal-secret",
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
customWidgetDefinitionSchema.safeParse({
|
||||
...CUSTOM_WIDGET_STARTER,
|
||||
template: '<Text>apiKey="literal-secret"</Text>',
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
customWidgetDefinitionSchema.safeParse({
|
||||
...CUSTOM_WIDGET_STARTER,
|
||||
optionsSchema: {
|
||||
type: "object",
|
||||
properties: { enabled: { type: "boolean", "x-homarr": { control: "slider" } } },
|
||||
additionalProperties: false,
|
||||
},
|
||||
defaultOptions: { enabled: true },
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
customWidgetDefinitionSchema.safeParse({
|
||||
...CUSTOM_WIDGET_STARTER,
|
||||
optionsSchema: {
|
||||
type: "object",
|
||||
properties: { count: { type: "integer", enum: [1, "two"] } },
|
||||
additionalProperties: false,
|
||||
},
|
||||
defaultOptions: { count: 1 },
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
assertSafeStaticHeaders,
|
||||
classifyAddress,
|
||||
executeCustomWidgetRequest,
|
||||
invalidateCustomWidgetResponseCache,
|
||||
resolveAndValidateHost,
|
||||
resolveSameOriginTarget,
|
||||
validateCustomWidgetUrl,
|
||||
@@ -52,6 +53,66 @@ 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("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");
|
||||
|
||||
@@ -2,13 +2,16 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
analyzeJsxTemplate,
|
||||
analyzeCustomWidgetAccessibility,
|
||||
analyzeRequestManifest,
|
||||
appendDataPath,
|
||||
createResponseTreeNode,
|
||||
CUSTOM_JSX_TEMPLATE_LIMIT,
|
||||
DEFAULT_CUSTOM_WIDGET_FORM_VALUES,
|
||||
customWidgetFormSchema,
|
||||
renameCustomWidgetRequest,
|
||||
} from "../workbench";
|
||||
import { CUSTOM_WIDGET_STARTER } from "../core/examples";
|
||||
|
||||
describe("Custom Widget workbench contracts", () => {
|
||||
it("always provides a valid starter template", () => {
|
||||
@@ -39,17 +42,25 @@ describe("Custom Widget workbench contracts", () => {
|
||||
});
|
||||
|
||||
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: "inlineRequestProps" }),
|
||||
expect.objectContaining({ code: "unknownRequest", value: "missing" }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("diagnoses dynamic and unknown local-state bindings", () => {
|
||||
expect(analyzeJsxTemplate('<Calendar bind="selectedDate" />', { stateKeys: ["selectedDate"] })).toEqual([]);
|
||||
expect(analyzeJsxTemplate('<Calendar bind="missing" />', { stateKeys: ["selectedDate"] })).toEqual([
|
||||
expect.objectContaining({ code: "unknownStateBinding", value: "missing" }),
|
||||
]);
|
||||
expect(analyzeJsxTemplate("<Calendar bind={state.selectedDate} />", { stateKeys: ["selectedDate"] })).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ code: "dynamicStateBinding" })]),
|
||||
);
|
||||
});
|
||||
|
||||
it("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" })]),
|
||||
@@ -64,4 +75,71 @@ describe("Custom Widget workbench contracts", () => {
|
||||
const tree = createResponseTreeNode({ items: [{ name: "one" }] }, "data", "data");
|
||||
expect(tree.children?.[0]?.children?.[0]?.children?.[0]?.value).toBe("data.items[0].name");
|
||||
});
|
||||
|
||||
it("renames a request and every canonical reference atomically", () => {
|
||||
const renamed = renameCustomWidgetRequest(
|
||||
{
|
||||
...CUSTOM_WIDGET_STARTER,
|
||||
requests: [
|
||||
{
|
||||
id: "items",
|
||||
sourceId: "default",
|
||||
kind: "query",
|
||||
method: "GET",
|
||||
pathTemplate: "/items",
|
||||
parameters: {},
|
||||
auth: "none",
|
||||
minimumBoardPermission: "view",
|
||||
trigger: "manual",
|
||||
},
|
||||
{
|
||||
id: "refresh",
|
||||
sourceId: "default",
|
||||
kind: "action",
|
||||
method: "POST",
|
||||
pathTemplate: "/refresh",
|
||||
parameters: {},
|
||||
auth: "none",
|
||||
minimumBoardPermission: "modify",
|
||||
trigger: "manual",
|
||||
invalidates: ["items"],
|
||||
},
|
||||
],
|
||||
optionsSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
item: {
|
||||
type: "string",
|
||||
"x-homarr": {
|
||||
control: "select",
|
||||
optionsSource: { requestId: "items", valuePath: "$.id", labelPath: "$.name" },
|
||||
},
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
template: '<Stack><SubFetch requestId="items" />{data["items"]}{status.items.ok}</Stack>',
|
||||
},
|
||||
"items",
|
||||
"inventory",
|
||||
);
|
||||
|
||||
expect(renamed.requests[0]?.id).toBe("inventory");
|
||||
expect(renamed.requests[1]?.invalidates).toEqual(["inventory"]);
|
||||
expect(JSON.stringify(renamed.optionsSchema)).toContain('"requestId":"inventory"');
|
||||
expect(renamed.template).toBe(
|
||||
'<Stack><SubFetch requestId="inventory" />{data["inventory"]}{status.inventory.ok}</Stack>',
|
||||
);
|
||||
});
|
||||
|
||||
it("reports common accessibility omissions in preview", () => {
|
||||
expect(
|
||||
analyzeCustomWidgetAccessibility('<Stack><Image src="/image.png"/><ActionIcon/><TextInput /></Stack>'),
|
||||
).toEqual(["imageAlt", "actionIconLabel", "inputLabel"]);
|
||||
expect(
|
||||
analyzeCustomWidgetAccessibility(
|
||||
'<Stack><Image src="/image.png" alt="Preview"/><ActionIcon aria-label="Refresh"/><TextInput label="Search" /></Stack>',
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user