// @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 = ` {(node, meta) => { const label = node.name.toUpperCase(); return {meta.depth}: {label}; }} `; 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 = { name: "cycle" }; cycle.children = [cycle]; const malformed = { name: "malformed", children: "not-an-array" }; const warnings = new Set(); 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(); 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(); 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(); 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(); 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(); 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( 'Unsafe', ), ).toEqual( expect.arrayContaining([ expect.objectContaining({ message: expect.stringContaining("RECURSIVE_LIST_TEMPLATE_REQUIRED") }), ]), ); expect( validateCustomJsxTemplate( "{(node) => node.name}", ), ).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(); 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 = ` {(node) => {node.name}} `; 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( }]} /> , ); }); expect(host.textContent).toContain("RECURSIVE_LIST_RENDER_ERROR"); await act(async () => { root.render( , ); }); 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 = ` {(node) => { const label = node.name; return {label}; }} `; 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, ); }