| 1 | // 🤖 Tests for the allowlisted CommonMark subset (issue #531). |
| 2 | // node test/markdown.test.mjs |
| 3 | // |
| 4 | // The vectors in test/event-vectors/markdown.json are the cross-platform |
| 5 | // contract: web's parseMessage must return each vector's tree exactly, and |
| 6 | // the byte-identical fixture copy keeps the native suite (MarkdownVectorTests) |
| 7 | // on the same file. When a twin disagrees, fix the implementation — never the |
| 8 | // vector. |
| 9 | import assert from "node:assert/strict"; |
| 10 | import { readFileSync } from "node:fs"; |
| 11 | import { dirname, join } from "node:path"; |
| 12 | import { fileURLToPath } from "node:url"; |
| 13 | import { parseMessage, houseParser, plainText } from "../web/static/markdown.js"; |
| 14 | import { replySnippet } from "../web/static/format.js"; |
| 15 | |
| 16 | const __dirname = dirname(fileURLToPath(import.meta.url)); |
| 17 | |
| 18 | let failures = 0; |
| 19 | async function test(name, fn) { |
| 20 | try { |
| 21 | await fn(); |
| 22 | console.log("PASS " + name); |
| 23 | } catch (e) { |
| 24 | console.log("FAIL " + name + " — " + (e && e.message)); |
| 25 | failures++; |
| 26 | } |
| 27 | } |
| 28 | |
| 29 | const canonical = readFileSync(join(__dirname, "event-vectors", "markdown.json"), "utf8"); |
| 30 | const { vectors } = JSON.parse(canonical); |
| 31 | |
| 32 | await test("vector file has substantial coverage", () => { |
| 33 | assert.ok(vectors.length >= 60, `only ${vectors.length} vectors`); |
| 34 | }); |
| 35 | |
| 36 | for (const v of vectors) { |
| 37 | await test(`vector: ${v.name}`, () => { |
| 38 | assert.deepEqual(parseMessage(v.input), v.tree, v.note || ""); |
| 39 | // Vectors carrying a `plain` field also pin the plain projection (the |
| 40 | // sidebar-preview / notification text) on both platforms. |
| 41 | if (v.plain !== undefined) assert.strictEqual(plainText(v.input), v.plain); |
| 42 | }); |
| 43 | } |
| 44 | |
| 45 | await test("ios vector fixture copy is byte-identical to canonical", () => { |
| 46 | const bundled = readFileSync( |
| 47 | join(__dirname, "..", "ios", "LChatCore", "Tests", "LChatCoreTests", "Fixtures", "markdown-vector.json"), |
| 48 | "utf8" |
| 49 | ); |
| 50 | assert.strictEqual( |
| 51 | bundled, |
| 52 | canonical, |
| 53 | "run cp test/event-vectors/markdown.json ios/LChatCore/Tests/LChatCoreTests/Fixtures/markdown-vector.json" |
| 54 | ); |
| 55 | }); |
| 56 | |
| 57 | // --- unit edges beyond the vector contract ---------------------------------- |
| 58 | |
| 59 | await test("null/undefined input parses as empty", () => { |
| 60 | assert.deepEqual(parseMessage(null), []); |
| 61 | assert.deepEqual(parseMessage(undefined), []); |
| 62 | }); |
| 63 | |
| 64 | await test("houseParser returns independent instances", () => { |
| 65 | const a = houseParser(); |
| 66 | const b = houseParser(); |
| 67 | assert.notEqual(a, b); |
| 68 | // Both configured identically: the allowlist holds on a fresh instance. |
| 69 | const toks = a.parse("# nope", {}); |
| 70 | assert.equal(toks[0].type, "paragraph_open"); |
| 71 | }); |
| 72 | |
| 73 | await test("disallowed constructs parse identically on a fresh houseParser", () => { |
| 74 | // Guards against parseMessage's singleton drifting from the exported factory |
| 75 | // (the composer builds its ProseMirror MarkdownParser from houseParser()). |
| 76 | const fresh = houseParser(); |
| 77 | for (const src of ["# h", "| a |", "<b>x</b>", "~~s~~", "- [x] t"]) { |
| 78 | assert.deepEqual( |
| 79 | JSON.stringify(fresh.parse(src, {})), |
| 80 | JSON.stringify(houseParser().parse(src, {})) |
| 81 | ); |
| 82 | } |
| 83 | }); |
| 84 | |
| 85 | await test("adjacent text runs merge into one node", () => { |
| 86 | // Escapes and entities produce separate text_special tokens internally; |
| 87 | // the tree must present one merged run. |
| 88 | const tree = parseMessage("a \\* b & c"); |
| 89 | assert.deepEqual(tree, [{ t: "p", children: [{ t: "text", text: "a * b & c" }] }]); |
| 90 | }); |
| 91 | |
| 92 | await test("strike delimiters never pair across lengths", () => { |
| 93 | assert.deepEqual(parseMessage("~a~~"), [{ t: "p", children: [{ t: "text", text: "~a~~" }] }]); |
| 94 | }); |
| 95 | |
| 96 | await test("multi-paragraph message with mixed blocks", () => { |
| 97 | const tree = parseMessage("intro\n\n- one\n- two\n\n> bye"); |
| 98 | assert.deepEqual( |
| 99 | tree.map((b) => b.t), |
| 100 | ["p", "ul", "quote"] |
| 101 | ); |
| 102 | }); |
| 103 | |
| 104 | await test("deeply nested structure stays well-formed", () => { |
| 105 | const tree = parseMessage("> - **a** `c`\n> - [d](https://x.com)"); |
| 106 | assert.equal(tree[0].t, "quote"); |
| 107 | assert.equal(tree[0].children[0].t, "ul"); |
| 108 | const inner = tree[0].children[0].items[0][1]; |
| 109 | assert.equal(inner.t, "ul"); |
| 110 | assert.equal(inner.items[0][0].children[0].t, "link"); |
| 111 | }); |
| 112 | |
| 113 | await test("link hrefs are never non-http(s)", () => { |
| 114 | // Belt and braces over the whole vector corpus plus a hostile sample. |
| 115 | const walkInline = (nodes) => { |
| 116 | for (const n of nodes) { |
| 117 | if (n.t === "link") assert.match(n.href, /^https?:\/\//i); |
| 118 | if (n.children) walkInline(n.children); |
| 119 | } |
| 120 | }; |
| 121 | const walk = (blocks) => { |
| 122 | for (const b of blocks) { |
| 123 | if (b.children) b.t === "quote" ? walk(b.children) : walkInline(b.children); |
| 124 | if (b.items) b.items.forEach(walk); |
| 125 | } |
| 126 | }; |
| 127 | for (const v of vectors) walk(v.tree); |
| 128 | walk(parseMessage("[a](data:text/html,x) [b](vbscript:x) [c](file:///etc) <ftp://x.com>")); |
| 129 | }); |
| 130 | |
| 131 | // The reaction quote a chat-list row shows ("James reacted 👍 to “buy milk”") |
| 132 | // is stamped at react time as replySnippet(plainText(text)) — the same plain |
| 133 | // projection every other preview line uses. Pinned here because the stamp |
| 134 | // itself lives in DOM code (chat.js toggleReaction, ChatFeedModel on native): |
| 135 | // quoting raw text would show the marks the bubble renders as styling, and the |
| 136 | // composer's opt-out escapes as visible backslashes. |
| 137 | await test("a reaction quote is stamped through the plain projection", () => { |
| 138 | assert.equal(replySnippet({ text: plainText("**buy** milk") }), "buy milk"); |
| 139 | assert.equal(replySnippet({ text: plainText("\\*lit\\*") }), "*lit*"); |
| 140 | // No text (a photo someone reacted to) still labels itself. |
| 141 | assert.equal(replySnippet({ text: plainText(""), attachments: [{ type: "image/png" }] }), "📷 Photo"); |
| 142 | }); |
| 143 | |
| 144 | if (failures) { |
| 145 | console.error(`\nmarkdown: ${failures} test(s) failed`); |
| 146 | process.exit(1); |
| 147 | } |
| 148 | console.log("\nmarkdown: all tests passed"); |
| 149 | |