// 🤖 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");