// 🤖 Tests for the allowlisted CommonMark subset (issue #531). // node test/markdown.test.mjs // // The vectors in test/event-vectors/markdown.json are the cross-platform // contract: web's parseMessage must return each vector's tree exactly, and // the byte-identical fixture copy keeps the native suite (MarkdownVectorTests) // on the same file. When a twin disagrees, fix the implementation — never the // vector. import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { parseMessage, houseParser, plainText } from "../web/static/markdown.js"; import { replySnippet } from "../web/static/format.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); 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 canonical = readFileSync(join(__dirname, "event-vectors", "markdown.json"), "utf8"); const { vectors } = JSON.parse(canonical); await test("vector file has substantial coverage", () => { assert.ok(vectors.length >= 60, `only ${vectors.length} vectors`); }); for (const v of vectors) { await test(`vector: ${v.name}`, () => { assert.deepEqual(parseMessage(v.input), v.tree, v.note || ""); // Vectors carrying a `plain` field also pin the plain projection (the // sidebar-preview / notification text) on both platforms. if (v.plain !== undefined) assert.strictEqual(plainText(v.input), v.plain); }); } await test("ios vector fixture copy is byte-identical to canonical", () => { const bundled = readFileSync( join(__dirname, "..", "ios", "LChatCore", "Tests", "LChatCoreTests", "Fixtures", "markdown-vector.json"), "utf8" ); assert.strictEqual( bundled, canonical, "run cp test/event-vectors/markdown.json ios/LChatCore/Tests/LChatCoreTests/Fixtures/markdown-vector.json" ); }); // --- unit edges beyond the vector contract ---------------------------------- await test("null/undefined input parses as empty", () => { assert.deepEqual(parseMessage(null), []); assert.deepEqual(parseMessage(undefined), []); }); await test("houseParser returns independent instances", () => { const a = houseParser(); const b = houseParser(); assert.notEqual(a, b); // Both configured identically: the allowlist holds on a fresh instance. const toks = a.parse("# nope", {}); assert.equal(toks[0].type, "paragraph_open"); }); await test("disallowed constructs parse identically on a fresh houseParser", () => { // Guards against parseMessage's singleton drifting from the exported factory // (the composer builds its ProseMirror MarkdownParser from houseParser()). const fresh = houseParser(); for (const src of ["# h", "| a |", "x", "~~s~~", "- [x] t"]) { assert.deepEqual( JSON.stringify(fresh.parse(src, {})), JSON.stringify(houseParser().parse(src, {})) ); } }); await test("adjacent text runs merge into one node", () => { // Escapes and entities produce separate text_special tokens internally; // the tree must present one merged run. const tree = parseMessage("a \\* b & c"); assert.deepEqual(tree, [{ t: "p", children: [{ t: "text", text: "a * b & c" }] }]); }); await test("strike delimiters never pair across lengths", () => { assert.deepEqual(parseMessage("~a~~"), [{ t: "p", children: [{ t: "text", text: "~a~~" }] }]); }); await test("multi-paragraph message with mixed blocks", () => { const tree = parseMessage("intro\n\n- one\n- two\n\n> bye"); assert.deepEqual( tree.map((b) => b.t), ["p", "ul", "quote"] ); }); await test("deeply nested structure stays well-formed", () => { const tree = parseMessage("> - **a** `c`\n> - [d](https://x.com)"); assert.equal(tree[0].t, "quote"); assert.equal(tree[0].children[0].t, "ul"); const inner = tree[0].children[0].items[0][1]; assert.equal(inner.t, "ul"); assert.equal(inner.items[0][0].children[0].t, "link"); }); await test("link hrefs are never non-http(s)", () => { // Belt and braces over the whole vector corpus plus a hostile sample. const walkInline = (nodes) => { for (const n of nodes) { if (n.t === "link") assert.match(n.href, /^https?:\/\//i); if (n.children) walkInline(n.children); } }; const walk = (blocks) => { for (const b of blocks) { if (b.children) b.t === "quote" ? walk(b.children) : walkInline(b.children); if (b.items) b.items.forEach(walk); } }; for (const v of vectors) walk(v.tree); walk(parseMessage("[a](data:text/html,x) [b](vbscript:x) [c](file:///etc) ")); }); // The reaction quote a chat-list row shows ("James reacted 👍 to “buy milk”") // is stamped at react time as replySnippet(plainText(text)) — the same plain // projection every other preview line uses. Pinned here because the stamp // itself lives in DOM code (chat.js toggleReaction, ChatFeedModel on native): // quoting raw text would show the marks the bubble renders as styling, and the // composer's opt-out escapes as visible backslashes. await test("a reaction quote is stamped through the plain projection", () => { assert.equal(replySnippet({ text: plainText("**buy** milk") }), "buy milk"); assert.equal(replySnippet({ text: plainText("\\*lit\\*") }), "*lit*"); // No text (a photo someone reacted to) still labels itself. assert.equal(replySnippet({ text: plainText(""), attachments: [{ type: "image/png" }] }), "📷 Photo"); }); if (failures) { console.error(`\nmarkdown: ${failures} test(s) failed`); process.exit(1); } console.log("\nmarkdown: all tests passed");