Render validated HTML, SVG, JSX, and TSX fences locally while preserving tool context and preventing explanatory JSON from triggering actions. Co-authored-by: Cursor <cursoragent@cursor.com>
252 lines
11 KiB
JavaScript
252 lines
11 KiB
JavaScript
/*
|
|
* node --test src/preview/jsx-transform.test.js
|
|
*
|
|
* The transform runs on model-authored text, so these cases are the shapes a
|
|
* model actually emits, plus the ones where a naive scanner silently produces
|
|
* code that runs and does the wrong thing (generics read as comparisons, `<` in
|
|
* a string read as a tag). Silent-wrong is the failure mode worth testing;
|
|
* anything that throws is already visible to the user.
|
|
*/
|
|
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { transform, TransformError } from "./jsx-transform.js";
|
|
|
|
// Whitespace is normalised for comparison: the transform drops the space a
|
|
// stripped annotation sat in (`const x: T = 1` -> `const x= 1`), which is
|
|
// invisible to everyone because the Code tab shows the original source, not
|
|
// this output.
|
|
const js = (src) => transform(src).code.replace(/\s+/g, " ").trim();
|
|
|
|
// ── elements ────────────────────────────────────────────────────────────────
|
|
|
|
test("element with no attributes or children", () => {
|
|
assert.equal(js("const a = <div />;"), 'const a = h("div",null);');
|
|
});
|
|
|
|
test("lowercase tags become strings, capitalized stay identifiers", () => {
|
|
assert.equal(js("<div />"), 'h("div",null)');
|
|
assert.equal(js("<App />"), "h(App,null)");
|
|
assert.equal(js("<Foo.Bar />"), "h(Foo.Bar,null)");
|
|
});
|
|
|
|
test("string, expression, boolean and hyphenated attributes", () => {
|
|
assert.equal(js('<a href="/x" />'), 'h("a",{href:"/x"})');
|
|
assert.equal(js("<a n={1 + 2} />"), 'h("a",{n:(1 + 2)})');
|
|
assert.equal(js("<input disabled />"), 'h("input",{disabled:true})');
|
|
assert.equal(js('<a data-id="7" />'), 'h("a",{"data-id":"7"})');
|
|
});
|
|
|
|
test("attribute spread", () => {
|
|
assert.equal(js("<div {...props} id=\"x\" />"), 'h("div",{...(props),id:"x"})');
|
|
});
|
|
|
|
test("children: text, expressions and nesting", () => {
|
|
assert.equal(js("<p>hi</p>"), 'h("p",null,"hi")');
|
|
assert.equal(js("<p>{name}</p>"), 'h("p",null,(name))');
|
|
assert.equal(js("<p>a {b} c</p>"), 'h("p",null,"a ",(b)," c")');
|
|
assert.equal(js("<ul><li>x</li></ul>"), 'h("ul",null,h("li",null,"x"))');
|
|
});
|
|
|
|
test("fragments", () => {
|
|
assert.equal(js("<><a /><b /></>"), 'h(Fragment,null,h("a",null),h("b",null))');
|
|
});
|
|
|
|
test("JSX nested inside an expression child", () => {
|
|
assert.equal(
|
|
js("<ul>{items.map((i) => <li key={i}>{i}</li>)}</ul>"),
|
|
'h("ul",null,(items.map((i) => h("li",{key:(i)},(i)))))',
|
|
);
|
|
});
|
|
|
|
test("JSX comments render nothing", () => {
|
|
assert.equal(js("<div>{/* note */}<a /></div>"), 'h("div",null,h("a",null))');
|
|
});
|
|
|
|
test("whitespace-only lines between elements collapse away", () => {
|
|
assert.equal(
|
|
js("<div>\n <a />\n <b />\n</div>"),
|
|
'h("div",null,h("a",null),h("b",null))',
|
|
);
|
|
});
|
|
|
|
test("text spanning lines keeps single spaces", () => {
|
|
assert.equal(js("<p>\n one\n two\n</p>"), 'h("p",null,"one two")');
|
|
});
|
|
|
|
// ── things that must NOT be treated as JSX ──────────────────────────────────
|
|
|
|
test("comparisons and arrow bodies are left alone", () => {
|
|
assert.equal(js("const t = a < b;"), "const t = a < b;");
|
|
assert.equal(js("if (x < 3 && y > 1) {}"), "if (x < 3 && y > 1) {}");
|
|
assert.equal(js("const f = (a, b) => a < b;"), "const f = (a, b) => a < b;");
|
|
});
|
|
|
|
test("angle brackets inside strings, templates and regexes survive", () => {
|
|
assert.equal(js('const s = "<div>not jsx</div>";'), 'const s = "<div>not jsx</div>";');
|
|
assert.equal(js("const s = `a <b> c`;"), "const s = `a <b> c`;");
|
|
assert.equal(js("const r = /<[a-z]+>/g;"), "const r = /<[a-z]+>/g;");
|
|
});
|
|
|
|
test("template interpolation can still contain JSX", () => {
|
|
assert.equal(js("const s = `${<a />}`;"), "const s = `${h(\"a\",null)}`;");
|
|
});
|
|
|
|
test("division is not mistaken for a regex", () => {
|
|
assert.equal(js("const r = (a + b) / 2 / c;"), "const r = (a + b) / 2 / c;");
|
|
// A `/` right after a call's closing paren: `)` ends a value, so this is
|
|
// division. Treating it as a regex swallowed the rest of the line.
|
|
assert.equal(js("const y = Math.sin((i + s) / 6) * 70;"), "const y = Math.sin((i + s) / 6) * 70;");
|
|
assert.equal(js("const m = xs[0] / total;"), "const m = xs[0] / total;");
|
|
});
|
|
|
|
test("indexing and calls before < are comparisons, not JSX", () => {
|
|
assert.equal(js("if (xs[0] < 3) {}"), "if (xs[0] < 3) {}");
|
|
assert.equal(js("while (f(i) < n) { i++; }"), "while (f(i) < n) { i++; }");
|
|
});
|
|
|
|
// ── TypeScript ──────────────────────────────────────────────────────────────
|
|
|
|
test("parameter and variable annotations are stripped", () => {
|
|
assert.equal(js("function f(a: string, b: number) {}"), "function f(a, b) {}");
|
|
assert.equal(js("const x: number = 5;"), "const x= 5;");
|
|
assert.equal(js("const f = (n: number): string => String(n);"), "const f = (n)=> String(n);");
|
|
});
|
|
|
|
test("interface and type declarations are dropped whole", () => {
|
|
assert.equal(js("interface Props { a: string; b?: number }\nconst x = 1;"), "const x = 1;");
|
|
assert.equal(js("type Id = string | number;\nconst x = 1;"), "const x = 1;");
|
|
});
|
|
|
|
test("generic call arguments are stripped, not read as comparisons", () => {
|
|
// The silent-wrong case: `useState<number>(0)` is valid JS meaning
|
|
// `(useState < number) > (0)`, so getting this wrong yields a boolean.
|
|
assert.equal(js("const [n, setN] = useState<number>(0);"), "const [n, setN] = useState(0);");
|
|
assert.equal(js("useRef<HTMLCanvasElement | null>(null);"), "useRef(null);");
|
|
});
|
|
|
|
test("as-casts and non-null assertions are stripped", () => {
|
|
assert.equal(js("const el = x as HTMLElement;"), "const el = x ;");
|
|
assert.equal(js("const v = raw as const;"), "const v = raw ;");
|
|
assert.equal(js("ref.current!.focus();"), "ref.current.focus();");
|
|
});
|
|
|
|
test("optional parameters keep their default values", () => {
|
|
assert.equal(js("function f(a: number = 3) { return a; }"), "function f(a= 3) { return a; }");
|
|
});
|
|
|
|
test("object literals and ternaries are not mistaken for annotations", () => {
|
|
assert.equal(js("const o = { a: 1, b: 'two' };"), "const o = { a: 1, b: 'two' };");
|
|
assert.equal(js("const v = c ? 1 : 2;"), "const v = c ? 1 : 2;");
|
|
assert.equal(js("el.style = { color: 'red' };"), "el.style = { color: 'red' };");
|
|
// Object literals passed as arguments sit inside a parameter list, where
|
|
// parameter annotations also live.
|
|
assert.equal(js("f({ a: 1, b: 2 });"), "f({ a: 1, b: 2 });");
|
|
assert.equal(js("ctx.fillRect(x, y, { w: 1 });"), "ctx.fillRect(x, y, { w: 1 });");
|
|
});
|
|
|
|
test("ternaries inside a call keep their else-branch", () => {
|
|
// Both of these end in `}` or `)` before the `:`, exactly like a destructured
|
|
// parameter annotation and a return type - only the pending `?` tells them apart.
|
|
assert.equal(js("f(cond ? { a: 1 } : { b: 2 });"), "f(cond ? { a: 1 } : { b: 2 });");
|
|
assert.equal(js("f(cond ? g() : h2());"), "f(cond ? g() : h2());");
|
|
assert.equal(js("const s = ok ? 'y' : 'n';"), "const s = ok ? 'y' : 'n';");
|
|
});
|
|
|
|
test("destructured parameter annotations are stripped", () => {
|
|
assert.equal(js("function C({ start }: Props) {}"), "function C({ start }) {}");
|
|
assert.equal(js("const f = ({ a, b }: P) => a + b;"), "const f = ({ a, b }) => a + b;");
|
|
assert.equal(js("function g([x, y]: Pair) {}"), "function g([x, y]) {}");
|
|
});
|
|
|
|
// ── modules ─────────────────────────────────────────────────────────────────
|
|
|
|
test("imports are dropped - the sandbox has no module loader", () => {
|
|
assert.equal(js('import React, { useState } from "react";\nconst x = 1;'), "const x = 1;");
|
|
assert.equal(js('import "./styles.css";\nconst x = 1;'), "const x = 1;");
|
|
});
|
|
|
|
test("dropped imports report the bindings they would have provided", () => {
|
|
// So a missing name can say where it was supposed to come from, instead of
|
|
// surfacing as "useInView is not defined" at its first use.
|
|
const { imports } = transform(
|
|
'import React, { useState, useEffect } from "react";\n' +
|
|
'import { useInView } from "react-infinite-scroll";\n' +
|
|
'import * as d3 from "d3";\nconst x = 1;',
|
|
);
|
|
assert.deepEqual(imports, [
|
|
{ names: ["React", "useState", "useEffect"], module: "react" },
|
|
{ names: ["useInView"], module: "react-infinite-scroll" },
|
|
{ names: ["d3"], module: "d3" },
|
|
]);
|
|
});
|
|
|
|
test("a malformed import still yields its name and module", () => {
|
|
// Straight from a transcript: a missing closing brace. The import is dropped
|
|
// either way, so the binding it meant to create is what matters.
|
|
const { imports } = transform("import { useInView from 'react-infinite-scroll';\nconst x = 1;");
|
|
assert.deepEqual(imports, [{ names: ["useInView"], module: "react-infinite-scroll" }]);
|
|
});
|
|
|
|
test("export default names the mount target", () => {
|
|
assert.equal(transform("export default function App() {}").defaultExport, "App");
|
|
assert.equal(transform("function A() {}\nexport default A;").defaultExport, "A");
|
|
assert.equal(js("export default function App() {}"), "function App() {}");
|
|
});
|
|
|
|
test("named exports are unwrapped", () => {
|
|
assert.equal(js("export const x = 1;"), "const x = 1;");
|
|
assert.equal(js("export function Chart() {}"), "function Chart() {}");
|
|
});
|
|
|
|
// ── failure is loud ─────────────────────────────────────────────────────────
|
|
|
|
test("mismatched closing tag throws with a line number", () => {
|
|
assert.throws(() => transform("<div>\n<span>x</div>"), (e) =>
|
|
e instanceof TransformError && /does not match/.test(e.message) && /line 2/.test(e.message));
|
|
});
|
|
|
|
test("unterminated element throws", () => {
|
|
assert.throws(() => transform("const a = <div>"), TransformError);
|
|
});
|
|
|
|
// ── a whole component, end to end ───────────────────────────────────────────
|
|
|
|
test("a realistic component transforms to runnable JS", () => {
|
|
const src = `
|
|
import { useState } from "react";
|
|
|
|
interface Props { start: number }
|
|
|
|
export default function Counter({ start }: Props) {
|
|
const [n, setN] = useState<number>(start);
|
|
return (
|
|
<div className="box">
|
|
<button onClick={() => setN(n + 1)}>+1</button>
|
|
<span>{n} clicks</span>
|
|
{n > 3 && <em>many!</em>}
|
|
</div>
|
|
);
|
|
}`;
|
|
const out = transform(src);
|
|
assert.equal(out.defaultExport, "Counter");
|
|
assert.match(out.code, /function Counter\(\{ start \}\)/);
|
|
assert.match(out.code, /useState\(start\)/);
|
|
assert.match(out.code, /h\("button",\{onClick:\(\(\) => setN\(n \+ 1\)\)\},"\+1"\)/);
|
|
assert.doesNotMatch(out.code, /interface|import|: Props|<number>/);
|
|
// The real proof: it parses as JS.
|
|
assert.doesNotThrow(() => new Function(out.code));
|
|
});
|
|
|
|
test("output of every element case parses as JS", () => {
|
|
for (const src of [
|
|
"<div />",
|
|
"<a href=\"/x\">link</a>",
|
|
"<><p>a</p><p>b</p></>",
|
|
"const v = <ul>{xs.map((x) => <li key={x}>{x}</li>)}</ul>;",
|
|
"const v = <Foo {...p} n={1} on={() => f(`${x}`)} />;",
|
|
]) {
|
|
const { code } = transform(src);
|
|
assert.doesNotThrow(() => new Function("h", "Fragment", "xs", "p", "x", "f", code), src);
|
|
}
|
|
});
|