feat(custom-widgets): Full Mantine v9 + SubFetch support for custom JSX (#6304)

This commit is contained in:
Thomas authored and GitHub committed 2026-07-15 14:06:02 +02:00
1 parent 0a0b3a12c9
commit 3b2fc2527e
181 files changed
+18472 -4364

No files matched your search

@@ -0,0 +1,32 @@
import { describe, expect, test } from "vitest";
import { buildCustomWidgetAiPrompt } 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"}');
});
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: [] },
});
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");
});
});
@@ -0,0 +1,229 @@
import { describe, expect, test } from "vitest";
import { customJsxComponentByName, customJsxComponentRegistry } from "../core/component-registry";
const mantineCore941ComponentExports = [
"RemoveScroll",
"Collapse",
"ScrollArea",
"UnstyledButton",
"VisuallyHidden",
"Paper",
"Popover",
"ActionIcon",
"CloseButton",
"Group",
"Loader",
"Overlay",
"ModalBase",
"Input",
"InputBase",
"Flex",
"FloatingIndicator",
"Accordion",
"Affix",
"Alert",
"Anchor",
"AngleSlider",
"AppShell",
"AspectRatio",
"Autocomplete",
"Avatar",
"BackgroundImage",
"Badge",
"Blockquote",
"Breadcrumbs",
"Burger",
"Button",
"Card",
"Center",
"Checkbox",
"Chip",
"Code",
"ColorPicker",
"ColorInput",
"ColorSwatch",
"Combobox",
"ComboboxPopover",
"Container",
"CopyButton",
"DataList",
"Dialog",
"Divider",
"Drawer",
"EmptyState",
"Fieldset",
"FileButton",
"FileInput",
"FloatingWindow",
"FocusTrap",
"Grid",
"Highlight",
"HoverCard",
"Image",
"Indicator",
"JsonInput",
"Kbd",
"List",
"LoadingOverlay",
"Mark",
"Marquee",
"MaskInput",
"Menu",
"Menubar",
"Modal",
"MultiSelect",
"NativeSelect",
"NavLink",
"Notification",
"NumberFormatter",
"NumberInput",
"OverflowList",
"Pagination",
"PasswordInput",
"Pill",
"PillsInput",
"PinInput",
"Portal",
"Progress",
"Radio",
"Rating",
"RollingNumber",
"RingProgress",
"Scroller",
"SegmentedControl",
"Select",
"SemiCircleProgress",
"SimpleGrid",
"Skeleton",
"Slider",
"Space",
"Splitter",
"Spoiler",
"Stack",
"Stepper",
"Switch",
"Table",
"TableOfContents",
"Tabs",
"TagsInput",
"Text",
"Textarea",
"TextInput",
"ThemeIcon",
"Timeline",
"Title",
"Tooltip",
"Transition",
"Tree",
"TreeSelect",
"Typography",
] as const;
const mantineCharts941ComponentExports = [
"ChartTooltip",
"ChartLegend",
"AreaChart",
"BarChart",
"LineChart",
"Sparkline",
"DonutChart",
"PieChart",
"RadarChart",
"ScatterChart",
"BubbleChart",
"CompositeChart",
"RadialBarChart",
"FunnelChart",
"Heatmap",
"BarsList",
"Treemap",
"SankeyChart",
] as const;
const mantineDates941ComponentExports = [
"DatesProvider",
"HiddenDatesInput",
"TimeInput",
"TimePicker",
"TimeValue",
"Day",
"WeekdaysRow",
"Month",
"PickerControl",
"YearsList",
"MonthsList",
"CalendarHeader",
"DecadeLevel",
"YearLevel",
"MonthLevel",
"LevelsGroup",
"DecadeLevelGroup",
"YearLevelGroup",
"MonthLevelGroup",
"PickerInputBase",
"Calendar",
"YearPicker",
"MonthPicker",
"DatePicker",
"DateInput",
"DateTimePicker",
"InlineDateTimePicker",
"YearPickerInput",
"MonthPickerInput",
"DatePickerInput",
"TimeGrid",
"MiniCalendar",
] as const;
describe("customJsxComponentRegistry", () => {
test("classifies every component name exactly once", () => {
const names = customJsxComponentRegistry.map(({ name }) => name);
expect(new Set(names).size).toBe(names.length);
});
test.each([
["ChartTooltip", "allowed"],
["ChartLegend", "allowed"],
["VisuallyHidden", "allowed"],
["SubFetch", "wrapped"],
["Portal", "denied"],
["TextInput", "denied"],
] as const)("classifies %s as %s", (name, safety) => {
expect(customJsxComponentByName.get(name)?.safety).toBe(safety);
});
test("documents why every denied component is unavailable", () => {
for (const component of customJsxComponentRegistry) {
if (component.safety === "denied") {
expect(component.reason, component.name).toBeTruthy();
}
}
});
test("publishes complete authoring 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);
}
});
test.each([
["Tabs.List", "grow"],
["ScrollArea", "offsetScrollbars"],
["SubData", "fit"],
["SubData", "alt"],
] as const)("supports the %s.%s authoring prop", (component, prop) => {
expect(customJsxComponentByName.get(component)?.supportedProps).toContain(prop);
});
test.each([
...mantineCore941ComponentExports,
...mantineCharts941ComponentExports,
...mantineDates941ComponentExports,
])("classifies the Mantine 9.4.1 export %s", (name) => {
expect(customJsxComponentByName.has(name), `${name} is not classified`).toBe(true);
});
});
@@ -0,0 +1,135 @@
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" }],
});
});
});
@@ -0,0 +1,65 @@
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" }],
});
});
});
@@ -0,0 +1,23 @@
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);
});
});
@@ -0,0 +1,68 @@
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__",
},
};
describe("parseCustomWidgetClipboard", () => {
it("keeps supporting raw JSON imports", () => {
const result = parseCustomWidgetClipboard(JSON.stringify({ ...widget, displayType: "raw" }));
expect(result?.name).toBe("Example");
});
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", () => {
const result = parseCustomWidgetClipboard(
`\`\`\`JSON\r\n${JSON.stringify(widget)}\r\n\`\`\`\r\n\`\`\`TSX\r\n<Text>{data.name}</Text>\r\n\`\`\``,
);
expect((result?.displayConfig as Record<string, unknown> | undefined)?.template).toBe("<Text>{data.name}</Text>");
});
it("rejects an unterminated fence with a large whitespace payload", () => {
expect(parseCustomWidgetClipboard(`\`\`\`json\n${" ".repeat(250_000)}`)).toBeNull();
});
});
@@ -0,0 +1,31 @@
import { describe, expect, test } from "vitest";
import { validateCustomJsxTemplate } from "../jsx/analyzer";
import { renderSafeJsx, sanitizeCustomJsxProps } from "../jsx/interpreter";
import { CUSTOM_JSX_BLOCKED_PROPERTIES, CUSTOM_JSX_BLOCKED_PROPS, CUSTOM_JSX_LIMITS } from "../jsx/policy";
const components = { Text: () => null };
const bindings = { data: Object.create(null) as Record<string, unknown> };
describe("shared Custom JSX policy", () => {
test.each([...CUSTOM_JSX_BLOCKED_PROPERTIES])(
"blocks reflective property %s in diagnostics and runtime",
(property) => {
const template = `<Text>{data[${JSON.stringify(property)}]}</Text>`;
expect(validateCustomJsxTemplate(template).some(({ severity }) => severity === "error")).toBe(true);
expect(() => renderSafeJsx({ template, components, bindings })).toThrow(/reflective property/u);
},
);
test.each([...CUSTOM_JSX_BLOCKED_PROPS])("removes blocked prop %s at the renderer boundary", (property) => {
const safe = sanitizeCustomJsxProps({ [property]: "unsafe", title: "safe" }, "Text");
expect(safe).not.toHaveProperty(property);
expect(safe).toHaveProperty("title", "safe");
});
test("uses the same depth and operation budgets for analysis and runtime", () => {
const template = `<Text>{${"!".repeat(CUSTOM_JSX_LIMITS.astDepth + 1)}data}</Text>`;
expect(validateCustomJsxTemplate(template).some(({ message }) => message.includes("depth limit"))).toBe(true);
});
});
@@ -0,0 +1,72 @@
import { describe, expect, it } from "vitest";
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 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" }]);
});
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("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");
});
});
@@ -0,0 +1,42 @@
import { describe, expect, it, vi } from "vitest";
import { CustomWidgetRequestLimiter } from "../server";
import type { RequestLimitStore } from "../server";
const input = { category: "action", userId: "user", itemId: "item", definitionId: "definition" } as const;
describe("custom widget request limits", () => {
it("enforces local per-user/item concurrency and releases capacity", async () => {
const limiter = new CustomWidgetRequestLimiter();
const releases = await Promise.all(Array.from({ length: 4 }, () => limiter.acquire(input)));
await expect(limiter.acquire(input)).rejects.toMatchObject({ code: "TOO_MANY_REQUESTS", retryAfterMs: 1_000 });
await releases[0]?.();
await expect(limiter.acquire(input)).resolves.toBeTypeOf("function");
await Promise.all(releases.slice(1).map((release) => release()));
});
it("enforces category limits and exposes retry metadata", async () => {
const store: RequestLimitStore = {
incrementRate: vi.fn(async () => ({ count: 11, retryAfterMs: 321 })),
acquireConcurrency: vi.fn(async () => true),
releaseConcurrency: vi.fn(async () => undefined),
};
await expect(new CustomWidgetRequestLimiter({ store }).acquire(input)).rejects.toMatchObject({
code: "TOO_MANY_REQUESTS",
retryAfterMs: 321,
});
expect(store.acquireConcurrency).not.toHaveBeenCalled();
});
it("releases user capacity when definition capacity cannot be acquired", async () => {
const store: RequestLimitStore = {
incrementRate: vi.fn(async () => ({ count: 1, retryAfterMs: 60_000 })),
acquireConcurrency: vi.fn().mockResolvedValueOnce(true).mockResolvedValueOnce(false),
releaseConcurrency: vi.fn(async () => undefined),
};
await expect(new CustomWidgetRequestLimiter({ store }).acquire(input)).rejects.toMatchObject({
code: "TOO_MANY_REQUESTS",
});
expect(store.releaseConcurrency).toHaveBeenCalledOnce();
});
});
@@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import type { CustomJsxRequest } from "../core";
import { hashRuntimeParams, renderRequestBody, renderRequestTarget, validateRuntimeParams } from "../server";
const request: CustomJsxRequest = {
id: "device",
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",
};
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",
);
});
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",
);
expect(JSON.parse(renderRequestBody(request.bodyTemplate, params) ?? "null")).toEqual({
enabled: true,
nested: { level: 5 },
});
});
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("hashes parameters independently of object insertion order", () => {
expect(hashRuntimeParams({ a: 1, b: "two" })).toBe(hashRuntimeParams({ b: "two", a: 1 }));
});
});
@@ -0,0 +1,157 @@
// @vitest-environment jsdom
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 { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ActionButton, CustomWidgetRuntimeProvider, SubFetch, ToggleSwitch } from "../runtime";
import type {
CustomJsxRequestCapability,
CustomWidgetRequestResult,
CustomWidgetRuntimeMessages,
CustomWidgetRuntimePort,
} from "../runtime";
const messages: CustomWidgetRuntimeMessages = {
migrationRequired: "Migration required",
unsavedPreview: "Unsaved",
invalidParams: "Invalid params",
loadRequest: "Load",
requestFailed: "Failed",
loading: "Loading",
retry: "Retry",
widgetItemUnavailable: "Unavailable",
actionsDisabledEditMode: "Disabled",
actionSimulated: "Simulated",
actionCompleted: "Completed",
confirmDelete: "Confirm delete",
toggle: "Toggle",
refresh: "Refresh",
};
let root: Root;
let host: HTMLDivElement;
beforeEach(() => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
window.matchMedia = vi.fn().mockImplementation(() => ({
matches: false,
media: "",
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
});
afterEach(async () => {
await act(() => root.unmount());
host.remove();
vi.restoreAllMocks();
});
function createPort(overrides: Partial<CustomWidgetRuntimePort> = {}): CustomWidgetRuntimePort {
return {
query: vi.fn(async () => ({ ok: true, status: 200, data: { name: "Bulbasaur" } })),
executeAction: vi.fn(async () => ({ ok: true, status: 200, data: null })),
invalidate: vi.fn(async () => undefined),
confirm: vi.fn(async () => true),
notify: vi.fn(),
...overrides,
};
}
async function render(
node: ReactNode,
port: CustomWidgetRuntimePort,
capabilities: readonly CustomJsxRequestCapability[] = [],
) {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
await act(async () => {
root.render(
<MantineProvider>
<QueryClientProvider client={queryClient}>
<CustomWidgetRuntimeProvider
itemId="item-1"
isEditMode={false}
requestCapabilities={capabilities}
port={port}
messages={messages}
>
{node}
</CustomWidgetRuntimeProvider>
</QueryClientProvider>
</MantineProvider>,
);
});
}
async function settle() {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
}
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,
);
await settle();
expect(port.query).toHaveBeenCalledTimes(1);
expect(host.textContent).toContain("Bulbasaur");
});
it("passes cancellation to the port when the component unmounts", async () => {
let signal: AbortSignal | undefined;
const port = createPort({
query: vi.fn((_input, nextSignal) => {
signal = nextSignal;
return new Promise<CustomWidgetRequestResult>(() => undefined);
}),
});
await render(<SubFetch requestId="slow" />, port);
await act(() => root.unmount());
expect(signal?.aborted).toBe(true);
root = createRoot(host);
});
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 act(async () => (host.querySelector("button") as HTMLButtonElement).click());
await settle();
expect(port.confirm).toHaveBeenCalledOnce();
expect(port.executeAction).toHaveBeenCalledWith(expect.objectContaining({ confirmed: true }));
expect(port.notify).toHaveBeenCalledWith(expect.objectContaining({ kind: "success" }));
expect(port.invalidate).toHaveBeenCalledWith(expect.objectContaining({ targets: ["parent"] }));
});
it("rolls a toggle back when its action fails", async () => {
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);
const input = host.querySelector("input") as HTMLInputElement;
await act(async () => {
input.click();
});
await settle();
expect(input.checked).toBe(false);
expect(port.notify).toHaveBeenCalledWith(expect.objectContaining({ kind: "error", message: "Nope" }));
expect(port.invalidate).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,167 @@
import { describe, expect, test } from "vitest";
import { customJsxExamples } from "../core/examples";
import { customJsxDisplayConfigV2Schema, customJsxRequestSchema, displayConfigSchema } from "../core/schema";
import { validateCustomJsxTemplate } from "../jsx/analyzer";
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>",
});
});
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);
expect(
customJsxDisplayConfigV2Schema.safeParse({ ...config, template: '<Text>{fetch("/private")}</Text>' }).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",
},
],
}).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);
});
});
@@ -0,0 +1,86 @@
import { createServer } from "node:http";
import { describe, expect, test } from "vitest";
import {
assertJsonBudget,
assertSafeStaticHeaders,
classifyAddress,
executeCustomWidgetRequest,
resolveAndValidateHost,
resolveSameOriginTarget,
validateCustomWidgetUrl,
} from "../server";
describe("custom widget network policy", () => {
test.each([
["8.8.8.8", "public"],
["10.0.0.1", "private"],
["127.0.0.1", "loopback"],
["169.254.169.254", "blocked"],
["224.0.0.1", "blocked"],
["::ffff:127.0.0.1", "blocked"],
["fe80::1", "blocked"],
] as const)("classifies %s as %s", (address, expected) => expect(classifyAddress(address)).toBe(expected));
test("enforces configured address scopes", async () => {
await expect(resolveAndValidateHost("10.0.0.1", "public")).rejects.toMatchObject({ code: "FORBIDDEN" });
await expect(resolveAndValidateHost("10.0.0.1", "private")).resolves.toHaveLength(1);
await expect(resolveAndValidateHost("127.0.0.1", "private")).rejects.toMatchObject({ code: "FORBIDDEN" });
await expect(resolveAndValidateHost("127.0.0.1", "loopback")).resolves.toHaveLength(1);
await expect(resolveAndValidateHost("169.254.169.254", "loopback")).rejects.toMatchObject({ code: "FORBIDDEN" });
});
test("executes a DNS-pinned request within the approved scope", async () => {
const server = createServer((_request, response) => {
response.writeHead(200, { "content-type": "application/json" });
response.end('{"status":"ok"}');
});
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",
networkScope: "loopback",
kind: "query",
}),
).resolves.toMatchObject({ ok: true, status: 200, data: { status: "ok" } });
} finally {
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");
expect(() => validateCustomWidgetUrl("https://user:password@example.com/api")).toThrow("credentials");
expect(() => validateCustomWidgetUrl("https://example.com/api#secret")).toThrow("fragments");
});
test("rejects cross-origin targets and reserved headers", () => {
expect(resolveSameOriginTarget("https://example.com/base", "https://example.com/status").pathname).toBe("/status");
expect(() => resolveSameOriginTarget("https://example.com", "https://attacker.example")).toThrow("origin");
for (const header of [
"Authorization",
"Cookie",
"Host",
"Proxy-Authorization",
"Sec-Fetch-Site",
"X-Forwarded-For",
]) {
expect(() => assertSafeStaticHeaders({ [header]: "value" })).toThrow("reserved");
}
});
test("rejects response JSON beyond the depth budget", () => {
let nested: Record<string, unknown> = {};
const root = nested;
for (let index = 0; index < 40; index += 1) {
nested.child = {};
nested = nested.child as Record<string, unknown>;
}
expect(() => assertJsonBudget(root)).toThrow("deeply nested");
});
});
@@ -0,0 +1,118 @@
// @vitest-environment jsdom
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 { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { PreviewHeader, PreviewResponsePanel, ResponseTree } from "../workbench";
let root: Root;
let host: HTMLDivElement;
beforeEach(() => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
window.matchMedia = vi.fn().mockImplementation(() => ({
matches: false,
media: "",
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
});
afterEach(async () => {
await act(() => root.unmount());
host.remove();
});
async function render(node: ReactNode) {
await act(async () => root.render(<MantineProvider>{node}</MantineProvider>));
}
describe("Custom Widget workbench UI", () => {
test("renders response data through the Mantine Tree with accessible path actions", async () => {
await render(
<ResponseTree
value={{ service: { name: "Homarr", online: true } }}
labels={{ copyPath: "Copy path", pathCopied: "Path copied", insertPath: "Insert path" }}
onInsertDataPath={vi.fn()}
/>,
);
expect(host.querySelector('[role="tree"]')).not.toBeNull();
expect(host.textContent).toContain("service");
expect(host.querySelector('button[aria-label="Copy path"]')).not.toBeNull();
expect(host.querySelector('button[aria-label="Insert path"]')).not.toBeNull();
});
test("keeps mutation preview execution disabled and labels every capability control", 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",
staleDescription: "Run again",
}}
/>,
);
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");
});
test("shows an empty response state without exposing a raw JSON toggle", async () => {
await render(
<PreviewResponsePanel
value={null}
messages={{
empty: "No response",
sampleHint: "Add sample data",
editSample: "Edit sample",
addSample: "Add sample",
copied: "Copied",
copy: "Copy",
sampleLabel: "Sample",
sampleDescription: "JSON",
invalidSample: "Invalid JSON",
cancelSample: "Cancel",
applySample: "Apply",
copyPath: "Copy path",
pathCopied: "Path copied",
insertPath: "Insert path",
openRaw: "Open raw response",
}}
/>,
);
expect(host.textContent).toContain("No response");
expect(host.textContent).not.toContain("View raw JSON");
});
});
@@ -0,0 +1,67 @@
import { describe, expect, it } from "vitest";
import {
analyzeJsxTemplate,
analyzeRequestManifest,
appendDataPath,
createResponseTreeNode,
CUSTOM_JSX_TEMPLATE_LIMIT,
DEFAULT_CUSTOM_WIDGET_FORM_VALUES,
customWidgetFormSchema,
} from "../workbench";
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("accepts 50,000 template characters and rejects 50,001", () => {
expect(analyzeJsxTemplate("x".repeat(CUSTOM_JSX_TEMPLATE_LIMIT))).not.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.arrayContaining([
expect.objectContaining({ code: "legacyNetworkProps" }),
expect.objectContaining({ code: "unknownRequest", value: "missing" }),
]),
);
});
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" })]),
);
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]");
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");
});
});