import { describe, expect, it } from "bun:test"; import { createHeadlessEditor } from "@lexical/headless"; import { $isTableNode, TableCellNode, TableRowNode } from "@lexical/table"; import { $createParagraphNode, $getRoot, $isElementNode } from "lexical"; import { $createPlanNodes, exportPlan, importPlan } from "./convert"; import { $isCodeBlockNode } from "./nodes/content"; import { cardStatus, toElement } from "./nodes/questionnaire"; import { parse } from "./parse"; import { registry } from "./registry"; import { serialize } from "./serialize"; import type { QuestionnaireNode } from "./nodes/questionnaire"; import type { ElementNode, LexicalEditor, RootNode } from "lexical"; const REGISTRY = registry(); function editor(): LexicalEditor { return createHeadlessEditor({ nodes: REGISTRY.nodes, onError(err) { throw err; }, }); } /** MDX -> Lexical -> MDX. */ function through(source: string): string { let instance = editor(); importPlan(instance, source, { registry: REGISTRY }); return exportPlan(instance, { registry: REGISTRY }); } describe("registry", () => { it("registers native table nodes", () => { let types = REGISTRY.nodes?.map(node => typeof node === "function" ? node.getType() : node.replace.getType() ) ?? []; expect(types).toContain("table"); expect(types).toContain("tablerow"); expect(types).toContain("tablecell"); }); it("does not register MDXEditor's HTML node handling", () => { // `suppressHtmlProcessing` keeps the raw-HTML visitors out of the dialect. let json = JSON.stringify(REGISTRY.importVisitors?.map(v => String(v.testNode))); expect(json).not.toContain("mdastHTML"); }); }); describe("conversion", () => { it("round-trips baseline markdown", () => { let cases = [ "# Title\n", "Some _italic_ and **bold** text.\n", "- one\n- two\n", "1. one\n2. two\n", "> quoted\n", "---\n", "[docs](https://example.com)\n", ]; for (let source of cases) expect(through(source)).toBe(source); }); /** * The VM refuses any edit whose Lexical round trip does not reproduce its * input, so a dropped info string does not merely lose a title — it makes * the whole plan uneditable by the agent. */ it("keeps a fence's info string", () => { let cases = [ '```js title="a.js"\nlet x = 1;\n```\n', "```mermaid collapsed\ngraph TD;\nA-->B;\n```\n", '```seecode\n{"type":"architecture","nodes":[],"edges":[]}\n```\n', "```js\nlet x = 1;\n```\n", "```\nplain\n```\n", ]; for (let source of cases) expect(through(source)).toBe(source); }); it("keeps a block formula's info string, which inline math cannot carry", () => { let cases = ["$$meta\na + b\n$$\n", "$$\na + b\n$$\n", "Inline $a+b$ math.\n"]; for (let source of cases) expect(through(source)).toBe(source); }); /** Markdown writes meta after a language, so one cannot survive without the other. */ it("does not keep meta it would have nowhere to write", () => { let instance = editor(); importPlan(instance, "```\nplain\n```\n", { registry: REGISTRY }); let meta = instance.getEditorState().read(() => { let code = $getRoot().getFirstChild(); return $isCodeBlockNode(code) ? code.getMeta() : undefined; }); expect(meta).toBe(""); }); it("creates fragment nodes inside a live editor without replacing existing nodes", () => { let instance = editor(); importPlan(instance, "# Existing\n", { registry: REGISTRY }); let key = instance.getEditorState().read(() => $getRoot().getFirstChild()!.getKey()); instance.update( () => $getRoot().append(...$createPlanNodes(parse("Added.\n"), { registry: REGISTRY })), { discrete: true }, ); expect(exportPlan(instance, { registry: REGISTRY })).toBe("# Existing\n\nAdded.\n"); expect(instance.getEditorState().read(() => $getRoot().getFirstChild()!.getKey())).toBe(key); }); it("round-trips an expired questionnaire's marker through its Lexical node", () => { let value = { id: "01K0N4TR8K7JGM4R1J7PW4R8YJ", status: "expired" as const, at: "2026-09-28T10:30:00.000Z", questions: [{ id: "01K0N4V4E7Y6P4MJ5WD8XZF3B2", header: "Confirm", prompt: "Ship it?", multiple: false, options: [{ id: "01K0N4W3B7P27CBAEC7A8C8WEA", label: "Yes" }], }], }; let source = serialize({ type: "root", children: [toElement(value)] }); expect(source).toStartWith( ``, ); expect(through(source)).toBe(source); let instance = editor(); importPlan(instance, source, { registry: REGISTRY }); let imported = instance.getEditorState().read(() => ($getRoot().getFirstChild() as QuestionnaireNode).getQuestionnaire() ); expect(imported).toEqual(value); expect(cardStatus(imported)).toBe("expired"); expect(cardStatus({ ...imported, status: undefined })).toBe("open"); }); it("round-trips tables through native nodes", () => { let source = "| Name | Status |\n| ---- | ------ |\n| API | Ready |\n"; expect(through(source)).toBe(source); }); it("preserves column alignment", () => { let source = "| Name | Count |\n| :--- | ----: |\n| API | 1 |\n"; expect(through(source)).toBe(source); }); it("builds tables as collaborative rows and cells, not one atomic node", () => { let instance = editor(); importPlan(instance, "| a | b |\n| - | - |\n| 1 | 2 |\n", { registry: REGISTRY }); instance.getEditorState().read(() => { let table = $getRoot().getFirstChild(); expect($isTableNode(table)).toBe(true); if (!$isTableNode(table)) return; let rows = table.getChildren(); expect(rows).toHaveLength(2); expect(rows.every(row => row instanceof TableRowNode)).toBe(true); let cells = (rows[0] as TableRowNode).getChildren(); expect(cells).toHaveLength(2); expect(cells.every(cell => cell instanceof TableCellNode)).toBe(true); // Text lives in ordinary descendants, which is what lets Yjs merge // concurrent edits to different cells. expect((cells[0] as TableCellNode).getTextContent()).toBe("a"); }); }); it("round-trips underline as a formatting mark", () => { let cases = [ "Plain marked text.\n", "A **bold** underline here.\n", "whole line\n", ]; for (let source of cases) expect(through(source)).toBe(source); }); it("stores underline as a text format, not a wrapper node", () => { let instance = editor(); importPlan(instance, "a b c\n", { registry: REGISTRY }); instance.getEditorState().read(() => { let paragraph = $getRoot().getFirstChild(); let types = ($isElementNode(paragraph) ? paragraph.getChildren() : []).map(node => node.getType() ); // Every child is a plain text run; the mark lives in the format bits. expect(new Set(types)).toEqual(new Set(["text"])); }); }); it("never emits raw HTML for marks", () => { expect(through("x y z\n")).not.toContain(""); }); it("rejects content outside the dialect on import", () => { expect(() => through('')).toThrow(/Invalid plan MDX/); }); it("rejects invalid content on export", () => { let instance = editor(); importPlan(instance, "text\n", { registry: REGISTRY }); instance.update( () => { let root = $getRoot(); root.clear(); }, { discrete: true }, ); // An empty document is valid; assert the happy path stays green. expect(() => exportPlan(instance, { registry: REGISTRY })).not.toThrow(); }); it("never emits import statements", () => { let instance = editor(); importPlan(instance, "# Title\n", { registry: REGISTRY }); expect(exportPlan(instance, { registry: REGISTRY })).not.toContain("import "); }); it("exports empty paragraphs as canonical source that parsing preserves", () => { let research = ''; let cases: [source: string, place: (root: RootNode) => void, expected: string][] = [ ["Text\n", root => root.append($createParagraphNode()), "Text\n"], [ `Text\n\n${research}\n`, root => root.append($createParagraphNode(), $createParagraphNode()), `Text\n\n${research}\n`, ], [ "First\n\nSecond\n", root => root.getFirstChildOrThrow().insertAfter($createParagraphNode()), "First\n\nSecond\n", ], [ "> Quoted\n", root => root.getFirstChildOrThrow().append($createParagraphNode()), "> Quoted\n", ], ]; for (let [source, place, expected] of cases) { let instance = editor(); importPlan(instance, source, { registry: REGISTRY }); instance.update(() => place($getRoot()), { discrete: true }); let output = exportPlan(instance, { registry: REGISTRY }); expect(output).toBe(expected); expect(serialize(parse(output))).toBe(output); } }); });