feat(custom-widgets): Full Mantine v9 + SubFetch support for custom JSX (#6304)
This commit is contained in:
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");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user