| 1 | // 🤖 Tests for the rich composer's document core (composer-md.js, #531): |
| 2 | // markdown round-trips under the zero-escape policy, wire-byte preservation |
| 3 | // for plain multi-line text, in-mark minimal escaping, and the doc⇄plaintext |
| 4 | // offset mapping the typeahead contract rides on. |
| 5 | // node test/composer.test.mjs |
| 6 | import assert from "node:assert/strict"; |
| 7 | import { |
| 8 | composerSchema, |
| 9 | parseToDoc, |
| 10 | serializeDoc, |
| 11 | docText, |
| 12 | textOffsetFromPos, |
| 13 | posFromTextOffset, |
| 14 | } from "../web/static/composer-md.js"; |
| 15 | import { richTokens, blockTokens } from "../web/static/format.js"; |
| 16 | import { composerToken } from "../web/static/ui.js"; |
| 17 | import { parseMessage } from "../web/static/markdown.js"; |
| 18 | |
| 19 | let failures = 0; |
| 20 | async function test(name, fn) { |
| 21 | try { |
| 22 | await fn(); |
| 23 | console.log("PASS " + name); |
| 24 | } catch (e) { |
| 25 | console.log("FAIL " + name + " — " + (e && e.message)); |
| 26 | failures++; |
| 27 | } |
| 28 | } |
| 29 | |
| 30 | const roundtrip = (x) => serializeDoc(parseToDoc(x)); |
| 31 | |
| 32 | // --- rule 1: the wire format must not drift --------------------------------- |
| 33 | |
| 34 | await test("plain single/multi-line messages serialize byte-identical", () => { |
| 35 | for (const x of ["hello", "a\nb", "a\n\nb", "a\nb\n\nc\nd", "line one\nline two"]) { |
| 36 | assert.equal(roundtrip(x), x); |
| 37 | } |
| 38 | }); |
| 39 | |
| 40 | await test("newlines parse as hard breaks, never paragraph splits", () => { |
| 41 | const doc = parseToDoc("a\nb"); |
| 42 | assert.equal(doc.childCount, 1, "one paragraph"); |
| 43 | let brs = 0; |
| 44 | doc.descendants((n) => { |
| 45 | if (n.type.name === "hard_break") brs++; |
| 46 | }); |
| 47 | assert.equal(brs, 1); |
| 48 | }); |
| 49 | |
| 50 | // --- rule 2: zero-escape policy ---------------------------------------------- |
| 51 | |
| 52 | await test("markdown-shaped plain text serializes verbatim (no backslashes ever)", () => { |
| 53 | for (const x of [ |
| 54 | "2*3 and 4*5", |
| 55 | "snake_case_name", |
| 56 | "a ~ b ~ c", |
| 57 | "# not a heading here", |
| 58 | "-- dashes --", |
| 59 | "[not](a link", // unbalanced — stays text |
| 60 | "- a\n- b", // shape changes (real list) but bytes must not |
| 61 | "> quoted", |
| 62 | "1. one", |
| 63 | ]) { |
| 64 | const out = roundtrip(x); |
| 65 | assert.equal(out, x); |
| 66 | assert.ok(!out.includes("\\"), "no escapes in " + JSON.stringify(out)); |
| 67 | } |
| 68 | }); |
| 69 | |
| 70 | await test("serialize∘parse∘serialize = serialize (idempotence)", () => { |
| 71 | for (const x of [ |
| 72 | "**b** *i* `c` ~s~", |
| 73 | "- a\n - b\n- c", |
| 74 | "3. x\n4. y", |
| 75 | "> a\n> b", |
| 76 | "```js\ncode **here**\n```", |
| 77 | "[docs](https://example.com)", |
| 78 | "*em with `code` inside*", |
| 79 | "para\n\n- list\n\n> quote", |
| 80 | ]) { |
| 81 | const once = roundtrip(x); |
| 82 | assert.equal(roundtrip(once), once, "not idempotent for " + JSON.stringify(x)); |
| 83 | } |
| 84 | }); |
| 85 | |
| 86 | await test("marks round-trip byte-identical", () => { |
| 87 | for (const x of [ |
| 88 | "**bold** and *italic* and `code` and ~strike~", |
| 89 | "***bi*** nested", |
| 90 | "**bold *inner italic* bold**", |
| 91 | "[docs](https://example.com) linked", |
| 92 | "```js\nfenced\n```", |
| 93 | "> quoted **bold**", |
| 94 | "- item **bold**\n- item two", |
| 95 | ]) { |
| 96 | assert.equal(roundtrip(x), x); |
| 97 | } |
| 98 | }); |
| 99 | |
| 100 | // --- in-mark minimal escaping ------------------------------------------------- |
| 101 | |
| 102 | function inlineDoc(...children) { |
| 103 | return composerSchema.node("doc", null, [composerSchema.node("paragraph", null, children)]); |
| 104 | } |
| 105 | const t = (s, marks) => composerSchema.text(s, marks); |
| 106 | const mark = (name, attrs) => composerSchema.marks[name].create(attrs); |
| 107 | |
| 108 | await test("star inside an em/strong run escapes (and only there)", () => { |
| 109 | assert.equal(serializeDoc(inlineDoc(t("2*3", [mark("em")]))), "*2\\*3*"); |
| 110 | assert.equal(serializeDoc(inlineDoc(t("a*b", [mark("strong")]))), "**a\\*b**"); |
| 111 | assert.equal(serializeDoc(inlineDoc(t("plain 2*3"))), "plain 2*3"); |
| 112 | }); |
| 113 | |
| 114 | await test("tilde inside a strike run escapes; strike emits single tilde", () => { |
| 115 | assert.equal(serializeDoc(inlineDoc(t("a~b", [mark("strike")]))), "~a\\~b~"); |
| 116 | assert.equal(serializeDoc(inlineDoc(t("s", [mark("strike")]))), "~s~"); |
| 117 | }); |
| 118 | |
| 119 | await test("escaped in-mark stars survive a parse round-trip", () => { |
| 120 | const md = "*2\\*3*"; |
| 121 | const doc = parseToDoc(md); |
| 122 | assert.equal(serializeDoc(doc), md); |
| 123 | assert.equal(docText(doc), "2*3"); |
| 124 | }); |
| 125 | |
| 126 | await test("code spans size their delimiters past inner backticks", () => { |
| 127 | // prosemirror-markdown pads conservatively whenever the content holds a |
| 128 | // backtick; CommonMark strips the one-space padding on parse, so the |
| 129 | // content round-trips exactly. |
| 130 | const padded = serializeDoc(inlineDoc(t("a`b", [mark("code")]))); |
| 131 | assert.equal(padded, "`` a`b ``"); |
| 132 | const doc = parseToDoc(padded); |
| 133 | assert.equal(docText(doc), "a`b"); |
| 134 | assert.equal(serializeDoc(inlineDoc(t("plain", [mark("code")]))), "`plain`"); |
| 135 | }); |
| 136 | |
| 137 | await test("link hrefs with spaces/parens get angle brackets", () => { |
| 138 | assert.equal( |
| 139 | serializeDoc(inlineDoc(t("x", [mark("link", { href: "https://a.com/b(c)" })]))), |
| 140 | "[x](<https://a.com/b(c)>)", |
| 141 | ); |
| 142 | }); |
| 143 | |
| 144 | // --- old-renderer twin: what old clients see ---------------------------------- |
| 145 | |
| 146 | await test("serialized output tokenizes identically under the OLD grammar", () => { |
| 147 | // The fleet-lag regression net: a message composed rich must read correctly |
| 148 | // on clients still running format.js's regex grammar. |
| 149 | const out = roundtrip("**bold** and ~gone~ and `code`"); |
| 150 | const toks = richTokens(out); |
| 151 | assert.deepEqual( |
| 152 | toks.filter((x) => x.mark).map((x) => [x.mark, x.text]), |
| 153 | [["b", "bold"], ["s", "gone"], ["code", "code"]], |
| 154 | ); |
| 155 | const fenced = roundtrip("```\nverbatim\n```"); |
| 156 | assert.deepEqual(blockTokens(fenced)[0], { type: "code", text: "verbatim", lang: "" }); |
| 157 | }); |
| 158 | |
| 159 | // --- doc ⇄ plaintext offsets --------------------------------------------------- |
| 160 | |
| 161 | await test("docText joins blocks and breaks with newlines", () => { |
| 162 | assert.equal(docText(parseToDoc("a\nb")), "a\nb"); |
| 163 | assert.equal(docText(parseToDoc("a\n\nb")), "a\nb"); // paragraph gap = one join in caret space |
| 164 | assert.equal(docText(parseToDoc("- one\n- two")), "one\ntwo"); |
| 165 | assert.equal(docText(parseToDoc("> q\n\nafter")), "q\nafter"); |
| 166 | assert.equal(docText(parseToDoc("")), ""); |
| 167 | }); |
| 168 | |
| 169 | await test("offset mapping round-trips every caret position", () => { |
| 170 | for (const x of ["a\nb", "a\n\nb", "abc", "- one\n- two\n - three", "> q\n\nafter", "```\ncode here\n```", ""]) { |
| 171 | const doc = parseToDoc(x); |
| 172 | const text = docText(doc); |
| 173 | for (let o = 0; o <= text.length; o++) { |
| 174 | const pos = posFromTextOffset(doc, o); |
| 175 | const back = textOffsetFromPos(doc, pos); |
| 176 | assert.equal(back, o, `offset ${o} → pos ${pos} → ${back} in ${JSON.stringify(x)}`); |
| 177 | } |
| 178 | } |
| 179 | }); |
| 180 | |
| 181 | await test("composerToken works over docText + mapped caret", () => { |
| 182 | const doc = parseToDoc("/he"); |
| 183 | const text = docText(doc); |
| 184 | assert.deepEqual(composerToken(text, 3), { kind: "command", query: "he", start: 0 }); |
| 185 | // A slash mid-doc is not a command (start-of-input rule holds across blocks). |
| 186 | const doc2 = parseToDoc("hello\n/nope"); |
| 187 | assert.equal(composerToken(docText(doc2), docText(doc2).length), null); |
| 188 | // Mention and emoji tokens detect at the caret in later blocks. |
| 189 | const doc3 = parseToDoc("hi\n@al"); |
| 190 | assert.deepEqual(composerToken(docText(doc3), 6), { kind: "mention", query: "al", start: 3 }); |
| 191 | }); |
| 192 | |
| 193 | await test("null/empty parse to a valid empty doc", () => { |
| 194 | for (const x of [null, undefined, ""]) { |
| 195 | const doc = parseToDoc(x); |
| 196 | assert.ok(doc.childCount >= 1); |
| 197 | assert.equal(docText(doc), ""); |
| 198 | assert.equal(serializeDoc(doc).trim(), ""); |
| 199 | } |
| 200 | }); |
| 201 | |
| 202 | await test("edit-loading an image reference round-trips its bytes", () => { |
| 203 | // image is disabled in the composer parser: "!"+link, same bytes back out. |
| 204 | const md = ""; |
| 205 | assert.equal(roundtrip(md), md); |
| 206 | }); |
| 207 | |
| 208 | if (failures) { |
| 209 | console.error(`\ncomposer: ${failures} test(s) failed`); |
| 210 | process.exit(1); |
| 211 | } |
| 212 | console.log("\ncomposer: all tests passed"); |
| 213 | |
| 214 | await test("escaped mode: plain markers escape, WYSIWYG marks keep theirs", () => { |
| 215 | // Mirrors ComposerAttributesTests' escaped-mode cases byte-for-byte. |
| 216 | const doc = composerSchema.node("doc", null, [composerSchema.node("paragraph", null, [ |
| 217 | composerSchema.text("lit *x* _y_ "), |
| 218 | composerSchema.text("bold", [composerSchema.marks.strong.create()]), |
| 219 | ])]); |
| 220 | assert.equal(serializeDoc(doc), "lit *x* _y_ **bold**"); |
| 221 | assert.equal(serializeDoc(doc, { escapeLiterals: true }), "lit \\*x\\* \\_y\\_ **bold**"); |
| 222 | const blocks = composerSchema.node("doc", null, [composerSchema.node("paragraph", null, [ |
| 223 | composerSchema.text("- a"), composerSchema.nodes.hard_break.create(), |
| 224 | composerSchema.text("2. b"), composerSchema.nodes.hard_break.create(), |
| 225 | composerSchema.text("> q"), |
| 226 | ])]); |
| 227 | assert.equal(serializeDoc(blocks, { escapeLiterals: true }), "\\- a\n2\\. b\n\\> q"); |
| 228 | const tick = composerSchema.node("doc", null, [composerSchema.node("paragraph", null, [ |
| 229 | composerSchema.text("\\* and `tick`"), |
| 230 | ])]); |
| 231 | assert.equal(serializeDoc(tick, { escapeLiterals: true }), "\\\\\\* and \\`tick\\`"); |
| 232 | // Escaped bytes render as the literal characters in the feed. |
| 233 | assert.deepEqual(parseMessage("\\*x\\* \\_y\\_"), [{ t: "p", children: [{ t: "text", text: "*x* _y_" }] }]); |
| 234 | }); |
| 235 | |