Files
homarr/packages/custom-widgets/src/test/recursive-list.spec.tsx
T

289 lines
11 KiB
TypeScript

// @vitest-environment jsdom
import { act, createElement } from "react";
import { createRoot } from "react-dom/client";
import { renderToStaticMarkup } from "react-dom/server";
import { MantineProvider } from "@mantine/core";
import { describe, expect, test, vi } from "vitest";
import { validateCustomJsxTemplate } from "../jsx/analyzer";
import { Budget, DEFAULT_BUDGETS } from "../jsx/interpreter-foundation";
import { SafeJsxError } from "../jsx/interpreter-foundation";
import { renderSafeJsx } from "../jsx/interpreter";
import { buildTrustedRecursiveList, TrustedRecursiveList } from "../jsx/recursive-list";
describe("RecursiveList", () => {
test("renders arbitrary-depth data through the trusted template slot", () => {
const template = `<RecursiveList data={data.chain} childrenPath="children" keyPath="name" defaultExpandedDepth={16}>
{(node, meta) => {
const label = node.name.toUpperCase();
return <Text>{meta.depth}: {label}</Text>;
}}
</RecursiveList>`;
expect(validateCustomJsxTemplate(template).filter(({ severity }) => severity === "error")).toEqual([]);
const rendered = renderSafeJsx({
template,
components: {
RecursiveList: TrustedRecursiveList as never,
Text: ((props: { children?: unknown }) => createElement("span", null, props.children as never)) as never,
},
bindings: { data: { chain: deepTree(8) } },
});
const html = renderToStaticMarkup(createElement(MantineProvider, null, rendered.node));
expect(html).toContain("0: LEVEL-0");
expect(html).toContain("7: LEVEL-7");
});
test("contains malformed children, cycles, depth limits, and node limits", () => {
const cycle: Record<string, unknown> = { name: "cycle" };
cycle.children = [cycle];
const malformed = { name: "malformed", children: "not-an-array" };
const warnings = new Set<string>();
const nodes = buildTrustedRecursiveList({
data: [cycle, malformed],
childrenPath: "children",
keyPath: "name",
maxDepth: 2,
maxNodes: 20,
warnings,
budget: new Budget(DEFAULT_BUDGETS),
render: (node) => String((node as { name: string }).name),
});
expect(nodes).toHaveLength(2);
expect(nodes[0]?.children?.[0]?.label).toBeTruthy();
expect(nodes[1]?.children?.[0]?.label).toBeTruthy();
expect([...warnings]).toEqual(
expect.arrayContaining([
expect.stringContaining("RECURSIVE_LIST_CYCLE"),
expect.stringContaining("RECURSIVE_LIST_INVALID_CHILDREN"),
]),
);
const cappedWarnings = new Set<string>();
const capped = buildTrustedRecursiveList({
data: Array.from({ length: 100 }, (_, index) => (index % 2 === 0 ? index : { name: `node-${index}` })),
childrenPath: "children",
keyPath: "name",
maxNodes: 4,
warnings: cappedWarnings,
budget: new Budget(DEFAULT_BUDGETS),
render: () => null,
});
expect(capped).toHaveLength(4);
expect(countTreeNodes(capped)).toBe(4);
expect([...cappedWarnings]).toEqual(
expect.arrayContaining([
expect.stringContaining("RECURSIVE_LIST_INVALID_NODE"),
expect.stringContaining("RECURSIVE_LIST_NODE_LIMIT"),
]),
);
const exactWarnings = new Set<string>();
const exact = buildTrustedRecursiveList({
data: Array.from({ length: 4 }, (_, index) => ({ name: `exact-${index}` })),
childrenPath: "children",
keyPath: "name",
maxNodes: 4,
warnings: exactWarnings,
budget: new Budget(DEFAULT_BUDGETS),
render: (node) => (node as { name: string }).name,
});
expect(exact).toHaveLength(4);
expect([...exactWarnings].some((warning) => warning.includes("NODE_LIMIT"))).toBe(false);
const oneRoot = buildTrustedRecursiveList({
data: { name: "only" },
childrenPath: "children",
keyPath: "name",
maxNodes: 1,
warnings: new Set(),
budget: new Budget(DEFAULT_BUDGETS),
render: (node) => (node as { name: string }).name,
});
expect(oneRoot).toHaveLength(1);
expect(oneRoot[0]?.label).toBe("only");
const nestedWarnings = new Set<string>();
const nestedCapped = buildTrustedRecursiveList({
data: [{ name: "malformed", children: "not-an-array" }, { name: "later" }],
childrenPath: "children",
keyPath: "name",
maxNodes: 2,
warnings: nestedWarnings,
budget: new Budget(DEFAULT_BUDGETS),
render: (node) => (node as { name: string }).name,
});
expect(countTreeNodes(nestedCapped)).toBe(2);
expect([...nestedWarnings]).toEqual(expect.arrayContaining([expect.stringContaining("RECURSIVE_LIST_NODE_LIMIT")]));
const depthWarnings = new Set<string>();
const depthLimited = buildTrustedRecursiveList({
data: deepTree(8),
childrenPath: "children",
keyPath: "name",
maxDepth: 2,
warnings: depthWarnings,
budget: new Budget(DEFAULT_BUDGETS),
render: () => null,
});
expect(depthLimited[0]?.children?.[0]?.children?.[0]?.label).toBeTruthy();
expect([...depthWarnings]).toEqual(expect.arrayContaining([expect.stringContaining("RECURSIVE_LIST_DEPTH_LIMIT")]));
});
test("rejects unsafe paths and invalid authored slots", () => {
const warnings = new Set<string>();
const invalidPath = buildTrustedRecursiveList({
data: { name: "root" },
childrenPath: "__proto__.children",
keyPath: "name",
warnings,
budget: new Budget(DEFAULT_BUDGETS),
render: () => null,
});
expect(invalidPath).toHaveLength(1);
expect([...warnings]).toEqual(expect.arrayContaining([expect.stringContaining("RECURSIVE_LIST_INVALID_PATH")]));
expect(
validateCustomJsxTemplate(
'<RecursiveList data={data.tree} childrenPath="children" keyPath="name"><Text>Unsafe</Text></RecursiveList>',
),
).toEqual(
expect.arrayContaining([
expect.objectContaining({ message: expect.stringContaining("RECURSIVE_LIST_TEMPLATE_REQUIRED") }),
]),
);
expect(
validateCustomJsxTemplate(
"<RecursiveList data={data.tree} childrenPath={3} keyPath={false}>{(node) => node.name}</RecursiveList>",
),
).toEqual(
expect.arrayContaining([expect.objectContaining({ message: expect.stringContaining("INVALID_PROP_VALUE") })]),
);
});
test("contains ordinary branch errors even when their text resembles a budget failure", () => {
const warnings = new Set<string>();
const nodes = buildTrustedRecursiveList({
data: { name: "root" },
childrenPath: "children",
keyPath: "name",
warnings,
budget: new Budget(DEFAULT_BUDGETS),
render: () => {
throw new SafeJsxError("API field exceeded the operation limit yesterday");
},
});
expect(nodes).toHaveLength(1);
expect(nodes[0]?.label).toBeTruthy();
expect([...warnings]).toEqual(expect.arrayContaining([expect.stringContaining("RECURSIVE_LIST_BRANCH_ERROR")]));
});
test("diagnoses unsafe spread props and consumes unsupported bind without crashing", () => {
const template = `<RecursiveList {...data.props} bind="tree" data={data.tree} childrenPath="children" keyPath="name">
{(node) => <Text>{node.name}</Text>}
</RecursiveList>`;
const rendered = renderSafeJsx({
template,
components: {
RecursiveList: TrustedRecursiveList as never,
Text: ((props: { children?: unknown }) => createElement("span", null, props.children as never)) as never,
},
bindings: {
data: {
tree: { name: "root" },
props: { onClick: () => undefined, href: "javascript:alert(1)" },
},
},
});
expect(rendered.warnings).toEqual(
expect.arrayContaining([
expect.stringContaining("BINDING_UNAVAILABLE"),
expect.stringContaining("BLOCKED_CAPABILITY"),
expect.stringContaining("INVALID_PROP_VALUE"),
]),
);
});
test("resets its contained render error when a corrected node model arrives", async () => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
window.matchMedia = vi.fn().mockImplementation(() => ({
matches: false,
media: "",
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
const BrokenLabel = () => {
throw new Error("broken label");
};
try {
await act(async () => {
root.render(
<MantineProvider>
<TrustedRecursiveList nodes={[{ value: "broken", label: <BrokenLabel /> }]} />
</MantineProvider>,
);
});
expect(host.textContent).toContain("RECURSIVE_LIST_RENDER_ERROR");
await act(async () => {
root.render(
<MantineProvider>
<TrustedRecursiveList nodes={[{ value: "recovered", label: "Recovered" }]} />
</MantineProvider>,
);
});
expect(host.textContent).toContain("Recovered");
expect(host.textContent).not.toContain("RECURSIVE_LIST_RENDER_ERROR");
} finally {
await act(async () => root.unmount());
consoleError.mockRestore();
host.remove();
}
});
test("shares the parent interpreter operation budget across every recursive callback", () => {
const template = `<RecursiveList data={data.chain} childrenPath="children" keyPath="name" defaultExpandedDepth={16}>
{(node) => {
const label = node.name;
return <Text>{label}</Text>;
}}
</RecursiveList>`;
expect(() =>
renderSafeJsx({
template,
components: {
RecursiveList: TrustedRecursiveList as never,
Text: (() => null) as never,
},
bindings: { data: { chain: deepTree(8) } },
budgets: { maxOperations: 30 },
}),
).toThrow("operation limit");
});
});
function deepTree(depth: number) {
const root: { name: string; children?: unknown[] } = { name: "level-0" };
let current = root;
for (let index = 1; index < depth; index += 1) {
const child: { name: string; children?: unknown[] } = { name: `level-${index}` };
current.children = [child];
current = child;
}
return root;
}
function countTreeNodes(nodes: readonly { children?: readonly unknown[] }[]): number {
return nodes.reduce(
(count, node) => count + 1 + countTreeNodes((node.children ?? []) as readonly { children?: readonly unknown[] }[]),
0,
);
}