import { MantineProvider } from "@mantine/core"; import * as MantineCharts from "@mantine/charts"; import * as MantineCore from "@mantine/core"; import * as MantineDates from "@mantine/dates"; import { act, createElement } from "react"; import { createRoot } from "react-dom/client"; import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import type { ReactNode } from "react"; import type { Root } from "react-dom/client"; import { customJsxComponentRegistry, customJsxExamples, enabledCustomJsxComponents } from "@homarr/custom-widgets/core"; import { renderSafeJsx, SafeJsxError, sanitizeCustomJsxProps } from "@homarr/custom-widgets/jsx"; import CustomJsxDisplay from "./custom-jsx-display"; import { createWhitelistedComponents, SAFE_BINDINGS } from "./jsx-whitelist"; const WHITELISTED_COMPONENTS = createWhitelistedComponents({ copy: "Copy", copied: "Copied" }); vi.mock("@homarr/translation/client", () => ({ useScopedI18n: () => (key: string, params?: Record) => key === "templateWarnings" ? `${params?.count ?? "0"} template warning(s):` : key, })); const isComponentLikeExport = ([name, value]: [string, unknown]): boolean => { if (!/^[A-Z][A-Za-z0-9]*$/.test(name) || name.endsWith("Context")) return false; if (typeof value === "function") return true; if (!value || typeof value !== "object") return false; const reactType = (value as { $$typeof?: unknown }).$$typeof; return reactType === Symbol.for("react.forward_ref") || reactType === Symbol.for("react.memo"); }; describe("CustomJsxDisplay", () => { let container: HTMLDivElement; let root: Root; beforeAll(() => { Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { value: true, writable: true }); Object.defineProperty(window, "matchMedia", { writable: true, value: (query: string) => ({ matches: false, media: query, onchange: null, addListener: () => undefined, removeListener: () => undefined, addEventListener: () => undefined, removeEventListener: () => undefined, dispatchEvent: () => false, }), }); }); afterEach(async () => { await act(async () => root?.unmount()); container?.remove(); }); const renderDisplay = async (data: Record) => { container = document.createElement("div"); document.body.appendChild(container); root = createRoot(container); await act(async () => { root.render(createElement(MantineProvider, null, createElement(CustomJsxDisplay, { data }))); }); }; it("renders Mantine components with data bindings", async () => { await renderDisplay({ template: "{data.name}{data.count}", data: { name: "Server A", count: "3" }, }); expect(container.textContent).toContain("Server A"); expect(container.textContent).toContain("3"); }); it("blocks script tags and event handlers", async () => { await renderDisplay({ template: 'Safe', data: {}, }); expect(document.title).not.toBe("hacked"); expect(container.querySelector("script")).toBeNull(); expect(container.textContent).toContain("Safe"); }); it("supports bounded collection callbacks and safe string helpers", async () => { await renderDisplay({ template: '{data.items.filter((item) => item.enabled).slice(0, 2).map((item, i) => {String(i + 1) + ". " + item.name.toUpperCase()})}', data: { items: [ { name: "alpha", enabled: true }, { name: "hidden", enabled: false }, { name: "beta", enabled: true }, { name: "gamma", enabled: true }, ], }, }); expect(container.textContent).toContain("1. ALPHA"); expect(container.textContent).toContain("2. BETA"); expect(container.textContent).not.toContain("GAMMA"); }); it("renders fetched data through an interpreter-created SubFetch child", async () => { container = document.createElement("div"); document.body.appendChild(container); root = createRoot(container); const rendered = renderSafeJsx({ template: "{(detail, meta) => {detail.types.map((entry) => entry.name).join(', ')} · {meta.status}}", components: { SubFetch: ((props: { render?: (data: unknown, metadata: unknown) => ReactNode }) => props.render?.({ types: [{ name: "grass" }, { name: "poison" }] }, { status: 200 })) as never, Text: ((props: { children?: ReactNode }) => createElement("span", null, props.children)) as never, }, bindings: {}, }); await act(async () => root.render(rendered.node)); expect(container.textContent).toBe("grass, poison · 200"); }); it("blocks reflective properties assembled with string concatenation", async () => { await renderDisplay({ template: '{data["con" + "structor"]}', data: { value: 1 }, }); expect(container.textContent).toContain("reflective property"); }); it.each([ '{data["constr\\u0075ctor"]}', '{data["__pro" + "to__"]}', '{String["b" + "ind"](null)}', "{data.items.map.call(null, (item) => item)}", "{data.items.map.apply(null, [])}", ])("blocks Unicode, computed, and call/apply reflective escapes", (template) => { expect(() => renderSafeJsx({ template, components: { Text: (() => null) as never }, bindings: SAFE_BINDINGS({ items: [1] }), }), ).toThrow(/reflective property/); }); it("never resolves inherited data properties", () => { const inherited = Object.create({ secret: "leak" }) as Record; inherited.visible = "safe"; const bindings = SAFE_BINDINGS(inherited); const data = bindings.data as Record; expect(data.visible).toBe("safe"); expect(data.secret).toBeUndefined(); expect(Object.getPrototypeOf(data)).toBeNull(); }); it("blocks direct global calls but allows benign text containing fetch", async () => { await renderDisplay({ template: "fetch is available through controlled requests", data: {} }); expect(container.textContent).toContain("fetch is available through controlled requests"); await act(async () => { root.render( createElement( MantineProvider, null, createElement(CustomJsxDisplay, { data: { template: '{fetch("/private")}', data: {} } }), ), ); }); expect(container.textContent).toContain("Unknown binding: fetch"); }); it("strips polymorphic, callback, unsafe URL, and escaping style props", () => { const props = sanitizeCustomJsxProps({ component: "iframe", renderRoot: "iframe", withinPortal: true, onClick: () => undefined, href: "javascript:alert(1)", style: { position: "fixed", zIndex: 999_999, flex: 1, color: "red" }, }); expect(props).toEqual({ style: { flex: 1, color: "red" } }); }); it("enforces collection and template length budgets", () => { const bindings = SAFE_BINDINGS({ items: [1, 2, 3] }); expect(() => renderSafeJsx({ template: "{data.items.map((item) => {item})}", components: { Text: ((props: { children?: unknown }) => props.children) as never, Stack: (() => null) as never, }, bindings, budgets: { maxCollectionItems: 2 }, }), ).toThrow(/collection limit/); expect(() => renderSafeJsx({ template: " ".repeat(50_001), components: {}, bindings: {} })).toThrow( new SafeJsxError("Template exceeds the 50000 character limit"), ); expect(() => renderSafeJsx({ template: '{"x".padStart(1000, "x")}', components: { Text: (() => null) as never }, bindings: {}, budgets: { maxStringLength: 10 }, }), ).toThrow(/string length limit/); expect(() => renderSafeJsx({ template: "".repeat(10_000), components: { B: (() => null) as never }, bindings: {}, }), ).toThrow("Template exceeded the rendered node limit (10000)"); }); describe("safe bindings security", () => { it("exposes wrapper functions, not native constructors", () => { const bindings = SAFE_BINDINGS({ value: 7 }); expect(typeof bindings.String).toBe("function"); expect(bindings.String(42)).toBe("42"); expect(typeof bindings.Number).toBe("function"); expect(bindings.Number("42")).toBe(42); expect(typeof bindings.Boolean).toBe("function"); expect(bindings.Boolean(1)).toBe(true); expect(bindings.String).not.toBe(String); expect(bindings.Number).not.toBe(Number); expect(bindings.Boolean).not.toBe(Boolean); }); it("uses null-prototype objects for Math/JSON/Array/Object helpers", () => { const bindings = SAFE_BINDINGS({}); expect(Object.getPrototypeOf(bindings.Math)).toBeNull(); expect(Object.getPrototypeOf(bindings.JSON)).toBeNull(); expect(Object.getPrototypeOf(bindings.Array)).toBeNull(); expect(Object.getPrototypeOf(bindings.Object)).toBeNull(); }); it("sanitizes data to strip constructor/__proto__/prototype keys", () => { const maliciousData = { name: "safe", constructor: "should be stripped", __proto__: "should be stripped", prototype: "should be stripped", nested: { value: 1, constructor: "also stripped", }, list: [{ constructor: "stripped", val: 2 }], }; const bindings = SAFE_BINDINGS(maliciousData); const data = bindings.data as Record; expect(data.name).toBe("safe"); expect("constructor" in data).toBe(false); expect("__proto__" in data).toBe(false); expect("prototype" in data).toBe(false); const nested = data.nested as Record; expect(nested.value).toBe(1); expect("constructor" in nested).toBe(false); const list = data.list as Array>; const firstItem = list[0] as Record; expect(firstItem.val).toBe(2); expect("constructor" in firstItem).toBe(false); }); it("data object has no prototype chain to Function", () => { const bindings = SAFE_BINDINGS({ value: 1 }); const data = bindings.data as Record; expect(Object.getPrototypeOf(data)).toBeNull(); expect("constructor" in data).toBe(false); }); it("does not expose eval, Function, fetch, window, document", () => { const bindings = SAFE_BINDINGS({}); expect("eval" in bindings).toBe(false); expect("Function" in bindings).toBe(false); expect("fetch" in bindings).toBe(false); expect("window" in bindings).toBe(false); expect("document" in bindings).toBe(false); }); it("Math helpers work correctly", () => { const bindings = SAFE_BINDINGS({}); const math = bindings.Math as { round: (v: number) => number; floor: (v: number) => number; ceil: (v: number) => number; abs: (v: number) => number; }; expect(math.round(3.7)).toBe(4); expect(math.floor(3.7)).toBe(3); expect(math.ceil(3.2)).toBe(4); expect(math.abs(-5)).toBe(5); }); it("Date helpers only return primitives and getDay returns the weekday", () => { const bindings = SAFE_BINDINGS({}); const date = bindings.Date as { create: (value: string) => number; getDay: (value: string) => number; }; expect(typeof date.create("2024-01-07T12:00:00Z")).toBe("number"); expect(date.getDay("2024-01-07T12:00:00Z")).toBe(0); }); it("JSON.stringify works correctly", () => { const bindings = SAFE_BINDINGS({}); const json = bindings.JSON as { stringify: (v: unknown) => string }; expect(json.stringify({ a: 1 })).toBe('{"a":1}'); }); it("Object/Array helpers work correctly", () => { const bindings = SAFE_BINDINGS({}); const obj = bindings.Object as { keys: (v: object) => string[]; values: (v: object) => unknown[]; entries: (v: object) => [string, unknown][]; }; const arr = bindings.Array as { isArray: (v: unknown) => boolean }; expect(obj.keys({ a: 1, b: 2 })).toEqual(["a", "b"]); expect(obj.values({ a: 1 })).toEqual([1]); expect(arr.isArray([1, 2])).toBe(true); expect(arr.isArray("not array")).toBe(false); }); }); }); describe("Custom JSX component registry", () => { it.each([ ["@mantine/core", MantineCore], ["@mantine/charts", MantineCharts], ["@mantine/dates", MantineDates], ] as const)("classifies every component-like %s export", (packageName, moduleExports) => { const classified = new Set( customJsxComponentRegistry .filter((component) => component.package === packageName) .flatMap(({ name }) => [name, name.replaceAll(".", "")]), ); const unclassified = Object.entries(moduleExports) .filter(isComponentLikeExport) .map(([name]) => name) .filter((name) => !classified.has(name)); expect(unclassified, `Unclassified ${packageName} component exports`).toEqual([]); }); it("has a runtime implementation for every enabled registry component", () => { const missing = enabledCustomJsxComponents .map(({ name }) => name) .filter((name) => !Object.hasOwn(WHITELISTED_COMPONENTS, name)); expect(missing, "Enabled components without a runtime implementation").toEqual([]); }); }); const renderTemplate = (template: string, data: Record = {}) => renderSafeJsx({ template, components: WHITELISTED_COMPONENTS, bindings: SAFE_BINDINGS(data) }); describe("new collection/string methods", () => { let container: HTMLDivElement; let root: Root; afterEach(async () => { await act(async () => root?.unmount()); container?.remove(); }); const renderAndGetText = async (template: string, data: Record = {}) => { container = document.createElement("div"); document.body.appendChild(container); root = createRoot(container); await act(async () => { root.render(createElement(MantineProvider, null, createElement(CustomJsxDisplay, { data: { template, data } }))); }); return container.textContent ?? ""; }; it("find returns the first matching item", async () => { const text = await renderAndGetText(`{data.items.find((x) => x.id === 2).name}`, { items: [ { id: 1, name: "a" }, { id: 2, name: "b" }, ], }); expect(text).toContain("b"); }); it("findIndex returns correct index", async () => { const text = await renderAndGetText(`{data.items.findIndex((x) => x > 5)}`, { items: [1, 3, 7, 9] }); expect(text).toContain("2"); }); it("some returns boolean", async () => { const text = await renderAndGetText(`{data.items.some((x) => x > 5) ? "yes" : "no"}`, { items: [1, 2, 3], }); expect(text).toContain("no"); }); it("every returns boolean", async () => { const text = await renderAndGetText(`{data.items.every((x) => x > 0) ? "all" : "nope"}`, { items: [1, 2, 3], }); expect(text).toContain("all"); }); it("sort with comparator returns stable order on NaN", async () => { const text = await renderAndGetText( `{data.items.sort((a, b) => a.v - b.v).map((x) => x.n).join(",")}`, { items: [{ n: "b", v: 2 }, { n: "a", v: 1 }, { n: "c" }] }, ); expect(text).toContain("a,"); expect(text).toContain("b"); }); it("reduce accumulates values", async () => { const text = await renderAndGetText(`{data.nums.reduce((acc, n) => acc + n, 0)}`, { nums: [1, 2, 3], }); expect(text).toContain("6"); }); it("at returns element by negative index", async () => { const text = await renderAndGetText(`{data.items.at(-1)}`, { items: ["a", "b", "c"] }); expect(text).toContain("c"); }); it("flat(2) throws SafeJsxError", () => { expect(() => renderTemplate(`{data.items.flat(2)}`, { items: [[1, [2]]] })).toThrow(SafeJsxError); }); it("flat(1) flattens one level", async () => { const text = await renderAndGetText(`{data.items.flat().join(",")}`, { items: [[1, 2], [3]] }); expect(text).toContain("1,2,3"); }); it("reverse returns reversed copy", async () => { const text = await renderAndGetText(`{data.items.reverse().join(",")}`, { items: [1, 2, 3] }); expect(text).toContain("3,2,1"); }); it("string replaceAll works", async () => { const text = await renderAndGetText(`{data.s.replaceAll("o", "0")}`, { s: "foobar" }); expect(text).toContain("f00bar"); }); it("string replaceAll with empty search is a no-op", async () => { const text = await renderAndGetText(`{data.s.replaceAll("", "x")}`, { s: "abc" }); expect(text).toContain("abc"); }); it("string repeat caps at budget", async () => { const text = await renderAndGetText(`{data.s.repeat(3)}`, { s: "hi" }); expect(text).toContain("hihihi"); }); it("string indexOf and lastIndexOf return positions", async () => { const text = await renderAndGetText(`{data.s.indexOf("l")}-{data.s.lastIndexOf("l")}`, { s: "hello", }); expect(text).toContain("2-3"); }); it("string trimStart and trimEnd work", async () => { const text = await renderAndGetText(`{data.s.trimStart().trimEnd()}`, { s: " hi " }); expect(text).toContain("hi"); }); it("filter(Boolean) removes falsy values", async () => { const text = await renderAndGetText(`{data.items.filter(Boolean).join(",")}`, { items: ["a", "", "b", null, "c"], }); expect(text).toContain("a,b,c"); }); it("pop returns last element", async () => { const text = await renderAndGetText(`{data.url.split("/").filter(Boolean).pop()}`, { url: "https://pokeapi.co/api/v2/pokemon/1/", }); expect(text).toContain("1"); }); it("String(n).padStart works for number formatting", async () => { const text = await renderAndGetText(`{String(data.n + 1).padStart(4, "0")}`, { n: 5 }); expect(text).toContain("0006"); }); it("number arithmetic in string concatenation", async () => { const text = await renderAndGetText(`{"https://example.com/" + (data.index + 1) + ".png"}`, { index: 3, }); expect(text).toContain("https://example.com/4.png"); }); }); describe("shared Custom JSX examples", () => { const sampleData: Record = { name: "Server A", status: "online", value: 42, count: 1351, results: [{ name: "bulbasaur" }, { name: "ivysaur" }], holdings: [{ symbol: "HOMR", price: 42.5, change: 1.2, history: [38, 40, 42.5] }], timeline: [ { date: "2026-07-11", value: 40 }, { date: "2026-07-12", value: 42.5 }, ], albumArt: "https://example.com/cover.jpg", album: "Album", title: "Track", artist: "Artist", progress: 55, playing: true, shuffle: false, state: "on", }; it.each(customJsxExamples)("parses and creates the $id renderer tree", (example) => { const rendered = renderSafeJsx({ template: example.template, components: WHITELISTED_COMPONENTS, bindings: SAFE_BINDINGS(sampleData), }); expect(rendered.warnings).toEqual([]); expect(rendered.node).not.toBeNull(); }); });