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 text formatting (link detection, file sizes). node test/format.test.mjs
2import assert from "node:assert/strict";
3import { tokenizeText, richTokens, blockTokens, firstLink, formatBytes, replySnippet, reactionLine, emojiOnlyCount, typingSummary, autoGroupTitle, nameList, membershipLine, youtubeId, changelogParagraphs, formatCountdown } from "../web/static/format.js";
4import { emojify, emojiMatches } from "../web/static/emoji.js";
5
6let failures = 0;
7async function test(name, fn) {
8 try {
9 await fn();
10 console.log("PASS " + name);
11 } catch (e) {
12 console.log("FAIL " + name + " β€” " + (e && e.message));
13 failures++;
14 }
15}
16
17// Reconstructing the token texts must always equal the input (no data lost).
18function roundtrip(text) {
19 return tokenizeText(text)
20 .map((t) => t.text)
21 .join("");
22}
23
24await test("plain text has no links and round-trips", () => {
25 const toks = tokenizeText("just a normal message");
26 assert.equal(toks.length, 1);
27 assert.equal(toks[0].href, undefined);
28 assert.equal(roundtrip("just a normal message"), "just a normal message");
29});
30
31await test("https URL becomes a link", () => {
32 const toks = tokenizeText("see https://example.com/page now");
33 const link = toks.find((t) => t.href);
34 assert.equal(link.text, "https://example.com/page");
35 assert.equal(link.href, "https://example.com/page");
36});
37
38await test("bare www. gets an https href", () => {
39 const link = tokenizeText("go to www.example.com").find((t) => t.href);
40 assert.equal(link.text, "www.example.com");
41 assert.equal(link.href, "https://www.example.com");
42});
43
44await test("trailing punctuation is not part of the link", () => {
45 const toks = tokenizeText("look at https://example.com.");
46 const link = toks.find((t) => t.href);
47 assert.equal(link.href, "https://example.com");
48 assert.equal(roundtrip("look at https://example.com."), "look at https://example.com.");
49});
50
51await test("a URL in parentheses keeps the paren out of the link", () => {
52 const toks = tokenizeText("(https://example.com)");
53 const link = toks.find((t) => t.href);
54 assert.equal(link.href, "https://example.com");
55 assert.equal(roundtrip("(https://example.com)"), "(https://example.com)");
56});
57
58await test("javascript: scheme is NOT linkified", () => {
59 const toks = tokenizeText("javascript:alert(1)");
60 assert.ok(!toks.some((t) => t.href));
61});
62
63await test("multiple links in one message", () => {
64 const links = tokenizeText("a https://one.com b http://two.com c").filter((t) => t.href);
65 assert.equal(links.length, 2);
66});
67
68await test("newlines and text are preserved around links", () => {
69 const input = "line1\nhttps://x.com\nline3";
70 assert.equal(roundtrip(input), input);
71});
72
73await test("bare domain (no scheme) becomes a link", () => {
74 const link = tokenizeText("see rte.ie for news").find((t) => t.href);
75 assert.equal(link.text, "rte.ie");
76 assert.equal(link.href, "https://rte.ie");
77});
78
79await test("bare multi-part domain links, and a bare domain with a path", () => {
80 assert.equal(tokenizeText("on guardian.co.uk today").find((t) => t.href).href, "https://guardian.co.uk");
81 assert.equal(tokenizeText("rte.ie/news here").find((t) => t.href).href, "https://rte.ie/news");
82});
83
84await test("file names / abbreviations / versions are NOT linkified", () => {
85 for (const s of ["node.js", "index.html", "main.go", "see e.g. this", "v1.2.3"]) {
86 assert.ok(!tokenizeText(s).some((t) => t.href), s);
87 assert.equal(roundtrip(s), s);
88 }
89});
90
91await test("an email's domain is not linkified", () => {
92 assert.ok(!tokenizeText("ping paul@rte.ie now").some((t) => t.href));
93});
94
95await test("formatBytes is human readable", () => {
96 assert.equal(formatBytes(500), "500 B");
97 assert.equal(formatBytes(1536), "1.5 KB");
98 assert.equal(formatBytes(5 * 1024 * 1024), "5.0 MB");
99});
100
101await test("replySnippet returns trimmed, single-line text", () => {
102 assert.equal(replySnippet({ text: " hello world\n" }), "hello world");
103});
104
105await test("replySnippet truncates long text with an ellipsis", () => {
106 const s = replySnippet({ text: "a".repeat(200) }, 10);
107 assert.equal(s.length, 10);
108 assert.ok(s.endsWith("…"));
109});
110
111await test("replySnippet labels photo-only messages", () => {
112 assert.equal(replySnippet({ text: "", attachments: [{ type: "image/png" }] }), "πŸ“· Photo");
113});
114
115await test("replySnippet labels non-image attachments by name", () => {
116 assert.equal(replySnippet({ attachments: [{ type: "application/pdf", name: "spec.pdf" }] }), "πŸ“Ž spec.pdf");
117});
118
119await test("replySnippet caps an over-long attachment name", () => {
120 const s = replySnippet({ attachments: [{ type: "application/pdf", name: "x".repeat(200) }] }, 20);
121 assert.equal(s.length, 20);
122 assert.ok(s.startsWith("πŸ“Ž ") && s.endsWith("…"));
123});
124
125await test("replySnippet is empty for an empty message", () => {
126 assert.equal(replySnippet({}), "");
127 assert.equal(replySnippet(null), "");
128});
129
130await test("reactionLine quotes what was reacted to", () => {
131 assert.equal(
132 reactionLine("James", { type: "reaction", emoji: "πŸ‘", on: true, targetText: "buy milk" }),
133 "James reacted πŸ‘ to β€œbuy milk”",
134 );
135});
136
137await test("reactionLine speaks for an un-react too", () => {
138 // The list must say the same thing after a reload as it did live, and on
139 // reload the un-react event is all it has.
140 assert.equal(
141 reactionLine("James", { type: "reaction", emoji: "❀️", on: false, targetText: "shipped it" }),
142 "James removed ❀️ from β€œshipped it”",
143 );
144});
145
146await test("reactionLine falls back to the bare form without a snippet", () => {
147 // Reactions sent before targetText shipped β€” the primary path on rollout.
148 assert.equal(reactionLine("James", { type: "reaction", emoji: "πŸ‘", on: true }), "James reacted πŸ‘");
149 assert.equal(reactionLine("James", { type: "reaction", emoji: "πŸ‘", on: false }), "James removed πŸ‘");
150});
151
152await test("reactionLine clamps a long quote", () => {
153 const line = reactionLine("Jo", { emoji: "πŸ‘", on: true, targetText: "a".repeat(200) }, 10);
154 assert.ok(line.startsWith("Jo reacted πŸ‘ to β€œ"));
155 assert.ok(line.endsWith("…”"));
156});
157
158await test("reactionLine takes the caller's actor, including β€œYou”", () => {
159 assert.equal(reactionLine("You", { emoji: "πŸŽ‰", on: true, targetText: "hi" }), "You reacted πŸŽ‰ to β€œhi”");
160 assert.equal(reactionLine("", { emoji: "πŸŽ‰", on: true }), "Someone reacted πŸŽ‰");
161});
162
163await test("emojiOnlyCount counts 1–3 all-emoji messages", () => {
164 assert.equal(emojiOnlyCount("πŸ‘"), 1);
165 assert.equal(emojiOnlyCount("πŸ˜€πŸ˜‚"), 2);
166 assert.equal(emojiOnlyCount("πŸŽ‰πŸŽ‰πŸŽ‰"), 3);
167});
168
169await test("emojiOnlyCount treats a ZWJ family / skin tone as one emoji", () => {
170 assert.equal(emojiOnlyCount("πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦"), 1);
171 assert.equal(emojiOnlyCount("πŸ‘πŸ½"), 1);
172});
173
174await test("emojiOnlyCount handles a flag (regional indicators) as one emoji", () => {
175 assert.equal(emojiOnlyCount("πŸ‡¬πŸ‡§"), 1);
176});
177
178await test("emojiOnlyCount allows whitespace between emoji", () => {
179 assert.equal(emojiOnlyCount("πŸ‘ πŸ‘"), 2);
180});
181
182await test("emojiOnlyCount is 0 for more than 3 emoji", () => {
183 assert.equal(emojiOnlyCount("πŸ‘πŸ‘πŸ‘πŸ‘"), 0);
184});
185
186await test("emojiOnlyCount is 0 when any text accompanies the emoji", () => {
187 assert.equal(emojiOnlyCount("hi πŸ‘"), 0);
188 assert.equal(emojiOnlyCount("πŸ‘ nice"), 0);
189});
190
191await test("emojiOnlyCount is 0 for empty or whitespace-only text", () => {
192 assert.equal(emojiOnlyCount(""), 0);
193 assert.equal(emojiOnlyCount(" "), 0);
194 assert.equal(emojiOnlyCount(null), 0);
195});
196
197// Reconstructing richTokens text must equal the input (no data lost) β€” markers
198// are consumed, so compare against the input with markers stripped.
199const richText = (s) => richTokens(s).map((t) => t.text).join("");
200
201await test("richTokens: bold / italic / strike / code", () => {
202 assert.deepEqual(richTokens("**big**"), [{ text: "big", mark: "b" }]);
203 assert.deepEqual(richTokens("*em*"), [{ text: "em", mark: "i" }]);
204 assert.deepEqual(richTokens("_em_"), [{ text: "em", mark: "i" }]);
205 assert.deepEqual(richTokens("~no~"), [{ text: "no", mark: "s" }]);
206 assert.deepEqual(richTokens("`x = 1`"), [{ text: "x = 1", mark: "code" }]);
207});
208
209await test("richTokens: bold beats italic at a double asterisk", () => {
210 assert.deepEqual(richTokens("**b**"), [{ text: "b", mark: "b" }]);
211});
212
213await test("richTokens: triple asterisk is combined bold italic", () => {
214 assert.deepEqual(richTokens("***both***"), [{ text: "both", mark: "bi" }]);
215 assert.deepEqual(richTokens("a ***b c*** d"), [
216 { text: "a " },
217 { text: "b c", mark: "bi" },
218 { text: " d" },
219 ]);
220 // Neighbours don't bleed: bi next to bold, and markers still can't span lines.
221 assert.deepEqual(richTokens("***x*** **y**"), [
222 { text: "x", mark: "bi" },
223 { text: " " },
224 { text: "y", mark: "b" },
225 ]);
226 assert.deepEqual(richTokens("***a\nb***"), [{ text: "***a\nb***" }]);
227});
228
229await test("richTokens: mixes plain text, a mark, and a link", () => {
230 const t = richTokens("hi *there* see https://x.com");
231 assert.deepEqual(t[0], { text: "hi " });
232 assert.deepEqual(t[1], { text: "there", mark: "i" });
233 assert.equal(t[2].text, " see ");
234 assert.equal(t[t.length - 1].href, "https://x.com");
235});
236
237await test("richTokens: no formatting inside a URL", () => {
238 const t = richTokens("https://x.com/a_b_c");
239 assert.equal(t.length, 1);
240 assert.equal(t[0].href, "https://x.com/a_b_c");
241 assert.equal(t[0].mark, undefined);
242});
243
244await test("richTokens: underscores mid-word do NOT italicize", () => {
245 assert.deepEqual(richTokens("my_file_name"), [{ text: "my_file_name" }]);
246});
247
248await test("richTokens: plain text passes through unmarked", () => {
249 assert.deepEqual(richTokens("just text"), [{ text: "just text" }]);
250 assert.equal(richText("a *b* c `d` e"), "a b c d e");
251});
252
253await test("blockTokens: plain text is one untouched p block", () => {
254 assert.deepEqual(blockTokens("hello\n\nworld"), [{ type: "p", text: "hello\n\nworld" }]);
255 assert.deepEqual(blockTokens(""), [{ type: "p", text: "" }]);
256 assert.deepEqual(blockTokens(null), [{ type: "p", text: "" }]);
257});
258
259await test("blockTokens: fenced code block, markers verbatim inside", () => {
260 const blocks = blockTokens("look:\n```\nif (a > b) { *notBold* }\n```\nnice");
261 assert.deepEqual(blocks, [
262 { type: "p", text: "look:" },
263 { type: "code", text: "if (a > b) { *notBold* }", lang: "" },
264 { type: "p", text: "nice" },
265 ]);
266});
267
268await test("blockTokens: fence captures a language tag", () => {
269 const blocks = blockTokens("```js\nconst x = 1;\n```");
270 assert.deepEqual(blocks, [{ type: "code", text: "const x = 1;", lang: "js" }]);
271});
272
273await test("blockTokens: unterminated fence runs to the end", () => {
274 const blocks = blockTokens("```\nline one\nline two");
275 assert.deepEqual(blocks, [{ type: "code", text: "line one\nline two", lang: "" }]);
276});
277
278await test("blockTokens: inline backticks on one line are not a fence", () => {
279 const blocks = blockTokens("```js``` opens a fence");
280 assert.deepEqual(blocks, [{ type: "p", text: "```js``` opens a fence" }]);
281});
282
283await test("blockTokens: consecutive > lines merge into one quote", () => {
284 const blocks = blockTokens("> two roads diverged\n> in a yellow wood\nso true");
285 assert.deepEqual(blocks, [
286 { type: "quote", text: "two roads diverged\nin a yellow wood" },
287 { type: "p", text: "so true" },
288 ]);
289});
290
291await test("blockTokens: bare > continues a quote as a blank line", () => {
292 const blocks = blockTokens("> first\n>\n> second");
293 assert.deepEqual(blocks, [{ type: "quote", text: "first\n\nsecond" }]);
294});
295
296await test("blockTokens: mid-line > is not a quote", () => {
297 assert.deepEqual(blockTokens("5 > 3 and 2 > 1"), [{ type: "p", text: "5 > 3 and 2 > 1" }]);
298});
299
300await test("blockTokens: blank lines around blocks are dropped", () => {
301 const blocks = blockTokens("intro\n\n> quoted\n\noutro");
302 assert.deepEqual(blocks, [
303 { type: "p", text: "intro" },
304 { type: "quote", text: "quoted" },
305 { type: "p", text: "outro" },
306 ]);
307});
308
309await test("firstLink finds a URL but ignores those inside code fences", () => {
310 assert.equal(firstLink("```\nhttps://example.com/in-code\n```\nsee https://example.com/real"),
311 "https://example.com/real");
312 assert.equal(firstLink("> see https://example.com/quoted"), "https://example.com/quoted");
313 assert.equal(firstLink("```\nhttps://example.com/only-code\n```"), null);
314});
315
316await test("emojify: known shortcodes β†’ emoji, incl. +1 / -1", () => {
317 assert.equal(emojify("nice :tada:"), "nice πŸŽ‰");
318 assert.equal(emojify(":+1: / :-1:"), "πŸ‘ / πŸ‘Ž");
319 assert.equal(emojify(":fire::fire:"), "πŸ”₯πŸ”₯");
320});
321
322await test("emojify: unknown shortcodes are left untouched", () => {
323 assert.equal(emojify("a :notarealemoji: b"), "a :notarealemoji: b");
324});
325
326await test("emojify: a lone shortcode becomes a single emoji (jumbo-eligible)", () => {
327 assert.equal(emojiOnlyCount(emojify(":tada:")), 1);
328});
329
330await test("emojify: URLs and times are not mangled", () => {
331 assert.equal(emojify("see https://x.com at 10:30"), "see https://x.com at 10:30");
332});
333
334await test("typingSummary is empty when no one is typing", () => {
335 assert.equal(typingSummary([]), "");
336 assert.equal(typingSummary(null), "");
337});
338
339await test("typingSummary names one and two typers", () => {
340 assert.equal(typingSummary(["Sophie"]), "Sophie is typing…");
341 assert.equal(typingSummary(["Sophie", "James"]), "Sophie and James are typing…");
342});
343
344await test("typingSummary collapses three or more typers", () => {
345 assert.equal(typingSummary(["A", "B", "C"]), "Several people are typing…");
346});
347
348await test("typingSummary ignores empty names", () => {
349 assert.equal(typingSummary(["Sophie", ""]), "Sophie is typing…");
350});
351
352await test("autoGroupTitle lists members and collapses past three", () => {
353 assert.equal(autoGroupTitle([]), "Group");
354 assert.equal(autoGroupTitle(["Paul"]), "Paul");
355 assert.equal(autoGroupTitle(["Paul", "Ciara"]), "Paul & Ciara");
356 assert.equal(autoGroupTitle(["Paul", "Ciara", "Luan"]), "Paul, Ciara & Luan");
357 assert.equal(autoGroupTitle(["Paul", "Ciara", "Luan", "Mabel"]), "Paul, Ciara & 2 more");
358 // The worked example: two shown + the rest counted.
359 const many = ["Paul", "Ciara", ...Array.from({ length: 24 }, (_, i) => "P" + i)];
360 assert.equal(autoGroupTitle(many), "Paul, Ciara & 24 more");
361});
362
363await test("autoGroupTitle ignores empty names", () => {
364 assert.equal(autoGroupTitle(["Paul", "", "Ciara"]), "Paul & Ciara");
365 assert.equal(autoGroupTitle(null), "Group");
366});
367
368await test("nameList joins names naturally with 'and'", () => {
369 assert.equal(nameList([]), "");
370 assert.equal(nameList(["Alice"]), "Alice");
371 assert.equal(nameList(["Alice", "Bob"]), "Alice and Bob");
372 assert.equal(nameList(["Alice", "Bob", "Dave"]), "Alice, Bob and Dave");
373 assert.equal(nameList(["Alice", "Bob", "Carol", "Dave"]), "Alice, Bob, Carol and Dave");
374});
375
376await test("nameList ignores empty names and non-arrays", () => {
377 assert.equal(nameList(["Alice", "", "Bob"]), "Alice and Bob");
378 assert.equal(nameList(null), "");
379});
380
381await test("membershipLine renders joins from the viewer's point of view", () => {
382 const names = { u1: "Paul", u2: "Alice", u3: "Bob" };
383 const label = (id) => names[id] || "Someone";
384 // A bystander sees everyone by name.
385 assert.equal(membershipLine({ uid: "u1", join: ["u2", "u3"] }, "u9", label), "Paul added Alice and Bob");
386 // A newcomer sees "you" for themselves.
387 assert.equal(membershipLine({ uid: "u1", join: ["u2", "u3"] }, "u3", label), "Paul added Alice and you");
388 assert.equal(membershipLine({ uid: "u1", join: ["u3"] }, "u3", label), "Paul added you");
389 // The person who did it sees "You".
390 assert.equal(membershipLine({ uid: "u1", join: ["u2", "u3"] }, "u1", label), "You added Alice and Bob");
391});
392
393await test("membershipLine renders a self-serve join as 'joined', not 'added themselves'", () => {
394 const names = { u1: "Paul" };
395 const label = (id) => names[id] || "Someone";
396 // Joining an open group via Discover: join = [the actor themselves].
397 assert.equal(membershipLine({ uid: "u1", join: ["u1"] }, "u9", label), "Paul joined");
398 assert.equal(membershipLine({ uid: "u1", join: ["u1"] }, "u1", label), "You joined");
399 // A uid-less legacy payload keeps the added-list rendering.
400 assert.equal(membershipLine({ name: "Paul", join: ["u2"] }, "u9", label), "Paul added Someone");
401});
402
403await test("membershipLine renders removals from the viewer's point of view", () => {
404 const names = { u1: "Paul", u2: "Alice", u3: "Bob" };
405 const label = (id) => names[id] || "Someone";
406 assert.equal(membershipLine({ uid: "u1", removed: ["u3"] }, "u9", label), "Paul removed Bob");
407 assert.equal(membershipLine({ uid: "u1", removed: ["u2", "u3"] }, "u1", label), "You removed Alice and Bob");
408 assert.equal(membershipLine({ uid: "u1", removed: ["u3"] }, "u3", label), "Paul removed you");
409});
410
411await test("membershipLine renders leaves and ignores non-membership payloads", () => {
412 const label = (id) => ({ u1: "Paul" })[id] || "Someone";
413 assert.equal(membershipLine({ uid: "u1", left: true }, "u9", label), "Paul left");
414 assert.equal(membershipLine({ uid: "u1", left: true }, "u1", label), "You left");
415 // No uid β†’ fall back to the baked name.
416 assert.equal(membershipLine({ name: "Paul", left: true }, "u9", label), "Paul left");
417 // Not a membership event.
418 assert.equal(membershipLine({ uid: "u1", text: "hello" }, "u9", label), null);
419 assert.equal(membershipLine({ uid: "u1", join: [] }, "u9", label), null);
420 assert.equal(membershipLine({ uid: "u1", removed: [] }, "u9", label), null);
421 assert.equal(membershipLine(null, "u9", label), null);
422});
423
424await test("emojiMatches ranks prefix hits and finds :tada:", () => {
425 const m = emojiMatches("tada");
426 assert.equal(m[0].emoji, "πŸŽ‰");
427 assert.equal(m[0].code, "tada");
428 assert.equal(emojiMatches("").length, 0); // no query β†’ no menu
429});
430
431await test("emojiMatches de-dupes aliases to the same glyph", () => {
432 const glyphs = emojiMatches("smi").map((m) => m.emoji);
433 assert.equal(new Set(glyphs).size, glyphs.length); // no repeated emoji
434});
435
436await test("emojify: shrug, facepalm, strong and lol shortcodes are known", () => {
437 assert.equal(emojify(":shrug:"), "🀷");
438 assert.equal(emojify(":facepalm:"), "🀦");
439 assert.equal(emojify(":strong:"), "πŸ’ͺ");
440 assert.equal(emojify(":lol:"), "πŸ˜‚");
441});
442
443await test("youtubeId parses the common URL shapes", () => {
444 assert.deepEqual(youtubeId("https://www.youtube.com/watch?v=dQw4w9WgXcQ"), { id: "dQw4w9WgXcQ", start: 0 });
445 assert.deepEqual(youtubeId("https://youtu.be/dQw4w9WgXcQ"), { id: "dQw4w9WgXcQ", start: 0 });
446 assert.deepEqual(youtubeId("https://m.youtube.com/watch?v=dQw4w9WgXcQ&pp=xyz"), { id: "dQw4w9WgXcQ", start: 0 });
447 assert.deepEqual(youtubeId("https://www.youtube.com/shorts/dQw4w9WgXcQ"), { id: "dQw4w9WgXcQ", start: 0 });
448 assert.deepEqual(youtubeId("https://www.youtube.com/live/dQw4w9WgXcQ"), { id: "dQw4w9WgXcQ", start: 0 });
449 assert.deepEqual(youtubeId("https://www.youtube.com/embed/dQw4w9WgXcQ"), { id: "dQw4w9WgXcQ", start: 0 });
450});
451
452await test("youtubeId parses start offsets", () => {
453 assert.equal(youtubeId("https://youtu.be/dQw4w9WgXcQ?t=90").start, 90);
454 assert.equal(youtubeId("https://youtu.be/dQw4w9WgXcQ?t=90s").start, 90);
455 assert.equal(youtubeId("https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=1h2m3s").start, 3723);
456 assert.equal(youtubeId("https://www.youtube.com/watch?v=dQw4w9WgXcQ&start=45").start, 45);
457});
458
459await test("youtubeId rejects non-video and lookalike URLs", () => {
460 assert.equal(youtubeId("https://www.youtube.com/@somechannel"), null);
461 assert.equal(youtubeId("https://www.youtube.com/playlist?list=PLx"), null);
462 assert.equal(youtubeId("https://notyoutube.com/watch?v=dQw4w9WgXcQ"), null);
463 assert.equal(youtubeId("https://evil.com/youtube.com/watch?v=dQw4w9WgXcQ"), null);
464 assert.equal(youtubeId("https://www.youtube.com/watch?v=too-short"), null);
465 assert.equal(youtubeId("not a url"), null);
466 assert.equal(youtubeId("ftp://youtu.be/dQw4w9WgXcQ"), null);
467});
468
469// ── changelog cleaning (the Admin app's update stream) ──
470
471await test("changelogParagraphs drops the πŸ€– authorship marker", () => {
472 assert.deepEqual(changelogParagraphs("πŸ€– Unread dots now sync across your devices."), [
473 "Unread dots now sync across your devices.",
474 ]);
475 // A person marker is stripped the same way.
476 assert.deepEqual(changelogParagraphs("πŸ‘¨ Hand-written note."), ["Hand-written note."]);
477});
478
479await test("changelogParagraphs collapses hard wraps but keeps paragraph breaks", () => {
480 const note = "πŸ€– Your space can now keep itself up\nto date. Admins get a new\nUpdates section.\n\nUpdates take seconds.";
481 assert.deepEqual(changelogParagraphs(note), [
482 "Your space can now keep itself up to date. Admins get a new Updates section.",
483 "Updates take seconds.",
484 ]);
485});
486
487await test("changelogParagraphs handles no marker, blank, and non-string", () => {
488 assert.deepEqual(changelogParagraphs("Plain note, no marker."), ["Plain note, no marker."]);
489 assert.deepEqual(changelogParagraphs(" "), []);
490 assert.deepEqual(changelogParagraphs(undefined), []);
491});
492
493await test("emphasis markers around a URL never ride into the href", () => {
494 // A bot writing "**github.com/apps/eleven-messenger**" gets a clean link,
495 // stars left as literal text (bolding ACROSS a link needs the real Markdown
496 // parser, tracked separately).
497 const toks = tokenizeText("go to **github.com/apps/eleven-messenger** and install");
498 const link = toks.find((t) => t.href);
499 assert.ok(link, "URL still linkifies next to emphasis markers");
500 assert.equal(link.text, "github.com/apps/eleven-messenger");
501 assert.ok(!link.href.includes("*"), "no emphasis markers in the href");
502 assert.equal(toks.map((t) => t.text).join(""), "go to **github.com/apps/eleven-messenger** and install");
503 const toks2 = tokenizeText("see https://docs.github.com/rest/issues#list**");
504 const link2 = toks2.find((t) => t.href);
505 assert.equal(link2.text, "https://docs.github.com/rest/issues#list");
506 assert.ok(!link2.href.endsWith("*"));
507});
508
509await test("formatCountdown: whole units, ceiled, never optimistic", () => {
510 const MIN = 60000;
511 assert.equal(formatCountdown(0), "under a minute");
512 assert.equal(formatCountdown(30 * 1000), "under a minute");
513 assert.equal(formatCountdown(MIN), "under a minute");
514 assert.equal(formatCountdown(MIN + 1), "2m"); // ceil: the label outlives the space, not vice versa
515 assert.equal(formatCountdown(45 * MIN), "45m");
516 assert.equal(formatCountdown(59 * MIN), "59m");
517 assert.equal(formatCountdown(59 * MIN + 1), "1h"); // 59m + a tick ceils to the hour
518 assert.equal(formatCountdown(60 * MIN), "1h");
519 assert.equal(formatCountdown(60 * MIN + 1), "1h 1m");
520 assert.equal(formatCountdown(23 * 60 * MIN + 40 * MIN), "23h 40m");
521 assert.equal(formatCountdown(24 * 60 * MIN), "24h");
522});
523
524await test("bare domains glued mid-token stay plain (the lookbehind rule)", () => {
525 // Mid-word, email, and mid-hyphenated-token candidates never link β€” and a
526 // hyphenated host still links whole, from its clean start.
527 assert.ok(!tokenizeText("x_aaa.com").some((t) => t.href));
528 // Glued after a stray hyphen counts as mid-token too (deliberate: hyphens
529 // are part of the candidate's own charset, so they bound it on both sides).
530 assert.ok(!tokenizeText("see -a.com").some((t) => t.href));
531 const hy = tokenizeText("try my-site.com now").find((t) => t.href);
532 assert.equal(hy && hy.text, "my-site.com");
533 assert.equal(roundtrip("x_aaa.com and my-site.com"), "x_aaa.com and my-site.com");
534});
535
536await test("tokenizing a huge unbroken paste is linear, not quadratic (the render-freeze regression)", () => {
537 // A pasted key/hash/log line is one long run of word chars. The bare-domain
538 // scan used to make a fresh full-run attempt at every position β€” O(nΒ²),
539 // seconds of locked main thread per render at message-size (~45k chars).
540 // The lookbehind fails mid-run starts in O(1); 256k chars must be instant.
541 // The 2s bound is ~50Γ— the observed linear time and ~1/40th of quadratic β€”
542 // generous for slow CI, unmissable if the regression returns.
543 const runs = ["a".repeat(256000), "a-".repeat(128000), "5".repeat(256000)];
544 const t0 = performance.now();
545 for (const s of runs) {
546 tokenizeText(s);
547 richTokens(s);
548 }
549 const ms = performance.now() - t0;
550 assert.ok(ms < 2000, `expected linear-time tokenize, took ${ms.toFixed(0)}ms`);
551});
552
553console.log(failures === 0 ? "\nALL PASSED" : `\n${failures} FAILED`);
554process.exit(failures === 0 ? 0 : 1);
555