// 🤖 Tests for the rich composer's document core (composer-md.js, #531): // markdown round-trips under the zero-escape policy, wire-byte preservation // for plain multi-line text, in-mark minimal escaping, and the doc⇄plaintext // offset mapping the typeahead contract rides on. // node test/composer.test.mjs import assert from "node:assert/strict"; import { composerSchema, parseToDoc, serializeDoc, docText, textOffsetFromPos, posFromTextOffset, } from "../web/static/composer-md.js"; import { richTokens, blockTokens } from "../web/static/format.js"; import { composerToken } from "../web/static/ui.js"; import { parseMessage } from "../web/static/markdown.js"; let failures = 0; async function test(name, fn) { try { await fn(); console.log("PASS " + name); } catch (e) { console.log("FAIL " + name + " — " + (e && e.message)); failures++; } } const roundtrip = (x) => serializeDoc(parseToDoc(x)); // --- rule 1: the wire format must not drift --------------------------------- await test("plain single/multi-line messages serialize byte-identical", () => { for (const x of ["hello", "a\nb", "a\n\nb", "a\nb\n\nc\nd", "line one\nline two"]) { assert.equal(roundtrip(x), x); } }); await test("newlines parse as hard breaks, never paragraph splits", () => { const doc = parseToDoc("a\nb"); assert.equal(doc.childCount, 1, "one paragraph"); let brs = 0; doc.descendants((n) => { if (n.type.name === "hard_break") brs++; }); assert.equal(brs, 1); }); // --- rule 2: zero-escape policy ---------------------------------------------- await test("markdown-shaped plain text serializes verbatim (no backslashes ever)", () => { for (const x of [ "2*3 and 4*5", "snake_case_name", "a ~ b ~ c", "# not a heading here", "-- dashes --", "[not](a link", // unbalanced — stays text "- a\n- b", // shape changes (real list) but bytes must not "> quoted", "1. one", ]) { const out = roundtrip(x); assert.equal(out, x); assert.ok(!out.includes("\\"), "no escapes in " + JSON.stringify(out)); } }); await test("serialize∘parse∘serialize = serialize (idempotence)", () => { for (const x of [ "**b** *i* `c` ~s~", "- a\n - b\n- c", "3. x\n4. y", "> a\n> b", "```js\ncode **here**\n```", "[docs](https://example.com)", "*em with `code` inside*", "para\n\n- list\n\n> quote", ]) { const once = roundtrip(x); assert.equal(roundtrip(once), once, "not idempotent for " + JSON.stringify(x)); } }); await test("marks round-trip byte-identical", () => { for (const x of [ "**bold** and *italic* and `code` and ~strike~", "***bi*** nested", "**bold *inner italic* bold**", "[docs](https://example.com) linked", "```js\nfenced\n```", "> quoted **bold**", "- item **bold**\n- item two", ]) { assert.equal(roundtrip(x), x); } }); // --- in-mark minimal escaping ------------------------------------------------- function inlineDoc(...children) { return composerSchema.node("doc", null, [composerSchema.node("paragraph", null, children)]); } const t = (s, marks) => composerSchema.text(s, marks); const mark = (name, attrs) => composerSchema.marks[name].create(attrs); await test("star inside an em/strong run escapes (and only there)", () => { assert.equal(serializeDoc(inlineDoc(t("2*3", [mark("em")]))), "*2\\*3*"); assert.equal(serializeDoc(inlineDoc(t("a*b", [mark("strong")]))), "**a\\*b**"); assert.equal(serializeDoc(inlineDoc(t("plain 2*3"))), "plain 2*3"); }); await test("tilde inside a strike run escapes; strike emits single tilde", () => { assert.equal(serializeDoc(inlineDoc(t("a~b", [mark("strike")]))), "~a\\~b~"); assert.equal(serializeDoc(inlineDoc(t("s", [mark("strike")]))), "~s~"); }); await test("escaped in-mark stars survive a parse round-trip", () => { const md = "*2\\*3*"; const doc = parseToDoc(md); assert.equal(serializeDoc(doc), md); assert.equal(docText(doc), "2*3"); }); await test("code spans size their delimiters past inner backticks", () => { // prosemirror-markdown pads conservatively whenever the content holds a // backtick; CommonMark strips the one-space padding on parse, so the // content round-trips exactly. const padded = serializeDoc(inlineDoc(t("a`b", [mark("code")]))); assert.equal(padded, "`` a`b ``"); const doc = parseToDoc(padded); assert.equal(docText(doc), "a`b"); assert.equal(serializeDoc(inlineDoc(t("plain", [mark("code")]))), "`plain`"); }); await test("link hrefs with spaces/parens get angle brackets", () => { assert.equal( serializeDoc(inlineDoc(t("x", [mark("link", { href: "https://a.com/b(c)" })]))), "[x]()", ); }); // --- old-renderer twin: what old clients see ---------------------------------- await test("serialized output tokenizes identically under the OLD grammar", () => { // The fleet-lag regression net: a message composed rich must read correctly // on clients still running format.js's regex grammar. const out = roundtrip("**bold** and ~gone~ and `code`"); const toks = richTokens(out); assert.deepEqual( toks.filter((x) => x.mark).map((x) => [x.mark, x.text]), [["b", "bold"], ["s", "gone"], ["code", "code"]], ); const fenced = roundtrip("```\nverbatim\n```"); assert.deepEqual(blockTokens(fenced)[0], { type: "code", text: "verbatim", lang: "" }); }); // --- doc ⇄ plaintext offsets --------------------------------------------------- await test("docText joins blocks and breaks with newlines", () => { assert.equal(docText(parseToDoc("a\nb")), "a\nb"); assert.equal(docText(parseToDoc("a\n\nb")), "a\nb"); // paragraph gap = one join in caret space assert.equal(docText(parseToDoc("- one\n- two")), "one\ntwo"); assert.equal(docText(parseToDoc("> q\n\nafter")), "q\nafter"); assert.equal(docText(parseToDoc("")), ""); }); await test("offset mapping round-trips every caret position", () => { for (const x of ["a\nb", "a\n\nb", "abc", "- one\n- two\n - three", "> q\n\nafter", "```\ncode here\n```", ""]) { const doc = parseToDoc(x); const text = docText(doc); for (let o = 0; o <= text.length; o++) { const pos = posFromTextOffset(doc, o); const back = textOffsetFromPos(doc, pos); assert.equal(back, o, `offset ${o} → pos ${pos} → ${back} in ${JSON.stringify(x)}`); } } }); await test("composerToken works over docText + mapped caret", () => { const doc = parseToDoc("/he"); const text = docText(doc); assert.deepEqual(composerToken(text, 3), { kind: "command", query: "he", start: 0 }); // A slash mid-doc is not a command (start-of-input rule holds across blocks). const doc2 = parseToDoc("hello\n/nope"); assert.equal(composerToken(docText(doc2), docText(doc2).length), null); // Mention and emoji tokens detect at the caret in later blocks. const doc3 = parseToDoc("hi\n@al"); assert.deepEqual(composerToken(docText(doc3), 6), { kind: "mention", query: "al", start: 3 }); }); await test("null/empty parse to a valid empty doc", () => { for (const x of [null, undefined, ""]) { const doc = parseToDoc(x); assert.ok(doc.childCount >= 1); assert.equal(docText(doc), ""); assert.equal(serializeDoc(doc).trim(), ""); } }); await test("edit-loading an image reference round-trips its bytes", () => { // image is disabled in the composer parser: "!"+link, same bytes back out. const md = "![alt](https://x.com/i.png)"; assert.equal(roundtrip(md), md); }); if (failures) { console.error(`\ncomposer: ${failures} test(s) failed`); process.exit(1); } console.log("\ncomposer: all tests passed"); await test("escaped mode: plain markers escape, WYSIWYG marks keep theirs", () => { // Mirrors ComposerAttributesTests' escaped-mode cases byte-for-byte. const doc = composerSchema.node("doc", null, [composerSchema.node("paragraph", null, [ composerSchema.text("lit *x* _y_ "), composerSchema.text("bold", [composerSchema.marks.strong.create()]), ])]); assert.equal(serializeDoc(doc), "lit *x* _y_ **bold**"); assert.equal(serializeDoc(doc, { escapeLiterals: true }), "lit \\*x\\* \\_y\\_ **bold**"); const blocks = composerSchema.node("doc", null, [composerSchema.node("paragraph", null, [ composerSchema.text("- a"), composerSchema.nodes.hard_break.create(), composerSchema.text("2. b"), composerSchema.nodes.hard_break.create(), composerSchema.text("> q"), ])]); assert.equal(serializeDoc(blocks, { escapeLiterals: true }), "\\- a\n2\\. b\n\\> q"); const tick = composerSchema.node("doc", null, [composerSchema.node("paragraph", null, [ composerSchema.text("\\* and `tick`"), ])]); assert.equal(serializeDoc(tick, { escapeLiterals: true }), "\\\\\\* and \\`tick\\`"); // Escaped bytes render as the literal characters in the feed. assert.deepEqual(parseMessage("\\*x\\* \\_y\\_"), [{ t: "p", children: [{ t: "text", text: "*x* _y_" }] }]); });