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