289 lines
11 KiB
TypeScript
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,
|
|
);
|
|
}
|