eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

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
6import assert from "node:assert/strict";
7import {
8 composerSchema,
9 parseToDoc,
10 serializeDoc,
11 docText,
12 textOffsetFromPos,
13 posFromTextOffset,
14} from "../web/static/composer-md.js";
15import { richTokens, blockTokens } from "../web/static/format.js";
16import { composerToken } from "../web/static/ui.js";
17import { parseMessage } from "../web/static/markdown.js";
18
19let failures = 0;
20async 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
30const roundtrip = (x) => serializeDoc(parseToDoc(x));
31
32// --- rule 1: the wire format must not drift ---------------------------------
33
34await 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
40await 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
52await 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
70await 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
86await 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
102function inlineDoc(...children) {
103 return composerSchema.node("doc", null, [composerSchema.node("paragraph", null, children)]);
104}
105const t = (s, marks) => composerSchema.text(s, marks);
106const mark = (name, attrs) => composerSchema.marks[name].create(attrs);
107
108await 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
114await 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
119await 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
126await 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
137await 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
146await 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
161await 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
169await 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
181await 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
193await 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
202await 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 = "![alt](https://x.com/i.png)";
205 assert.equal(roundtrip(md), md);
206});
207
208if (failures) {
209 console.error(`\ncomposer: ${failures} test(s) failed`);
210 process.exit(1);
211}
212console.log("\ncomposer: all tests passed");
213
214await 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