Skip to content
179 linesCodeBlameRaw
1import { test } from "node:test";
2import assert from "node:assert/strict";
3
4import {
5 MAX_EMOJI_BYTES,
6 MAX_REACTIONS_PER_MESSAGE,
7 REACTORS_SHOWN,
8 emojiImage,
9 emojiName,
10 fromBase64,
11 isUnicodeEmoji,
12 mayRemove,
13 mayUpload,
14 reactionEmoji,
15 roomForReaction,
16 sniffImage,
17 tallyReactions,
18} from "./emoji.ts";
19
20test("a reaction is one emoji, however many code points it takes", () => {
21 for (const emoji of ["👍", "👍🏽", "❤️", "👩‍💻", "👨‍👩‍👧‍👦", "🏳️‍🌈", "🇳🇱", "1️⃣", "#️⃣", "✅", "🫠"]) {
22 assert.equal(isUnicodeEmoji(emoji), true, emoji);
23 }
24 for (const text of ["", "a", "ab", "👍👍", "👍 ", "🇳", "1", "<b>", "👍x", ":)"]) {
25 assert.equal(isUnicodeEmoji(text), false, JSON.stringify(text));
26 }
27});
28
29test("a reaction is a Unicode emoji or a workspace's own by name", () => {
30 assert.deepEqual(reactionEmoji(" 🎉 "), { ok: true, emoji: "🎉", custom: null });
31 assert.deepEqual(reactionEmoji(":shipit-g1t:"), { ok: true, emoji: ":shipit-g1t:", custom: "shipit-g1t" });
32 // A standard shortcode is sent as the emoji, never by name.
33 assert.equal(reactionEmoji(":thumbsup:").ok, false);
34 assert.equal(reactionEmoji(":+1:").ok, false);
35 assert.equal(reactionEmoji(":A:").ok, false);
36 // One spelling: a needless variation selector is dropped; a needed one kept.
37 const vs16 = String.fromCodePoint(0xfe0f);
38 assert.deepEqual(reactionEmoji("👍" + vs16), { ok: true, emoji: "👍", custom: null });
39 assert.deepEqual(reactionEmoji("❤" + vs16), { ok: true, emoji: "❤" + vs16, custom: null });
40 assert.equal(reactionEmoji("hello").ok, false);
41 assert.equal(reactionEmoji(42).ok, false);
42});
43
44test("custom emoji names: lowercase letters, digits, - _ +, 2 to 32, not a standard one", () => {
45 assert.deepEqual(emojiName(":Party-Parrot:"), { ok: true, name: "party-parrot" });
46 assert.deepEqual(emojiName("lgtm+1"), { ok: true, name: "lgtm+1" });
47 assert.deepEqual(emojiName("ok_g1t"), { ok: true, name: "ok_g1t" });
48 assert.equal(emojiName("a").ok, false);
49 assert.equal(emojiName("x".repeat(33)).ok, false);
50 assert.equal(emojiName("x".repeat(32)).ok, true);
51 assert.equal(emojiName("has space").ok, false);
52 assert.equal(emojiName("dot.name").ok, false);
53 for (const standard of ["smile", "thumbsup", "+1", "tada", "rocket", "heart"]) {
54 assert.equal(emojiName(standard).ok, false, standard);
55 }
56});
57
58/** A PNG's signature and header for a `width`×`height` image. */
59function png(width: number, height: number, size = 64): Uint8Array {
60 const bytes = new Uint8Array(size);
61 bytes.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13, 0x49, 0x48, 0x44, 0x52]);
62 new DataView(bytes.buffer).setUint32(16, width);
63 new DataView(bytes.buffer).setUint32(20, height);
64 return bytes;
65}
66
67function gif(width: number, height: number): Uint8Array {
68 const bytes = new Uint8Array(32);
69 bytes.set([...new TextEncoder().encode("GIF89a")]);
70 new DataView(bytes.buffer).setUint16(6, width, true);
71 new DataView(bytes.buffer).setUint16(8, height, true);
72 return bytes;
73}
74
75function webp(chunk: "VP8X" | "VP8L" | "VP8 ", width: number, height: number): Uint8Array {
76 const bytes = new Uint8Array(40);
77 const text = new TextEncoder();
78 bytes.set(text.encode("RIFF"), 0);
79 bytes.set(text.encode("WEBP"), 8);
80 bytes.set(text.encode(chunk), 12);
81 const view = new DataView(bytes.buffer);
82 if (chunk === "VP8X") {
83 const w = width - 1;
84 const h = height - 1;
85 bytes.set([w & 0xff, (w >> 8) & 0xff, (w >> 16) & 0xff, h & 0xff, (h >> 8) & 0xff, (h >> 16) & 0xff], 24);
86 } else if (chunk === "VP8L") {
87 const w = width - 1;
88 const h = height - 1;
89 bytes[20] = 0x2f;
90 const bits = w | (h << 14);
91 view.setUint32(21, bits, true);
92 } else {
93 bytes.set([0x9d, 0x01, 0x2a], 23);
94 view.setUint16(26, width, true);
95 view.setUint16(28, height, true);
96 }
97 return bytes;
98}
99
100test("an image is known by its bytes, with its size", () => {
101 assert.deepEqual(sniffImage(png(128, 64)), { content_type: "image/png", width: 128, height: 64 });
102 assert.deepEqual(sniffImage(gif(32, 30)), { content_type: "image/gif", width: 32, height: 30 });
103 assert.deepEqual(sniffImage(webp("VP8X", 300, 200)), { content_type: "image/webp", width: 300, height: 200 });
104 assert.deepEqual(sniffImage(webp("VP8L", 64, 48)), { content_type: "image/webp", width: 64, height: 48 });
105 assert.deepEqual(sniffImage(webp("VP8 ", 100, 90)), { content_type: "image/webp", width: 100, height: 90 });
106 // Not by name: an SVG, a JPEG or text is none of them.
107 assert.equal(sniffImage(new TextEncoder().encode("<svg xmlns='http://www.w3.org/2000/svg'></svg>")), null);
108 assert.equal(sniffImage(new Uint8Array([0xff, 0xd8, 0xff, 0xe0, ...new Uint8Array(40)])), null);
109 assert.equal(sniffImage(new Uint8Array()), null);
110});
111
112test("a custom emoji file is a PNG, GIF or WebP up to 256 KB and 512 pixels a side", () => {
113 assert.equal(emojiImage(png(512, 512)).ok, true);
114 assert.equal(emojiImage(png(513, 10)).ok, false);
115 assert.equal(emojiImage(webp("VP8X", 10, 600)).ok, false);
116 assert.equal(emojiImage(png(0, 10)).ok, false);
117 assert.equal(emojiImage(png(64, 64, MAX_EMOJI_BYTES)).ok, true);
118 assert.equal(emojiImage(png(64, 64, MAX_EMOJI_BYTES + 1)).ok, false);
119 assert.equal(emojiImage(new Uint8Array()).ok, false);
120 assert.equal(emojiImage(new TextEncoder().encode("GIF8 not really")).ok, false);
121});
122
123test("a file comes as base64, with or without a data: prefix", () => {
124 assert.deepEqual(fromBase64("AAEC"), new Uint8Array([0, 1, 2]));
125 assert.deepEqual(fromBase64("data:image/png;base64,AAEC"), new Uint8Array([0, 1, 2]));
126 assert.equal(fromBase64("not base64!!"), null);
127 assert.equal(fromBase64(null), null);
128});
129
130test("a message holds at most 50 kinds of reaction; more of one kind always fits", () => {
131 const full = new Set(Array.from({ length: MAX_REACTIONS_PER_MESSAGE }, (_, i) => `:e${i}:`));
132 assert.equal(roomForReaction(full, ":e3:"), true);
133 assert.equal(roomForReaction(full, "🎉"), false);
134 assert.equal(roomForReaction(new Set(), "🎉"), true);
135});
136
137test("who may add and remove a workspace's emoji", () => {
138 assert.equal(mayUpload("members", "member"), true);
139 assert.equal(mayUpload("admins", "member"), false);
140 assert.equal(mayUpload("admins", "owner"), true);
141 assert.equal(mayUpload("members", null), false);
142 assert.equal(mayRemove("user:a", "user:a", "member"), true);
143 assert.equal(mayRemove("user:a", "user:b", "member"), false);
144 assert.equal(mayRemove("user:a", "user:b", "owner"), true);
145 assert.equal(mayRemove("user:a", "user:a", null), false);
146});
147
148test("reactions are counted per message and emoji, in the order first used", () => {
149 const at = (n: number) => `2026-10-08T10:00:${String(n).padStart(2, "0")}.000Z`;
150 const rows = [
151 { message_id: "m1", emoji: "🎉", principal: "user:b", created_at: at(3) },
152 { message_id: "m1", emoji: "👍", principal: "user:a", created_at: at(1) },
153 { message_id: "m1", emoji: "👍", principal: "agent:x", created_at: at(2) },
154 { message_id: "m2", emoji: ":ship:", principal: "user:a", created_at: at(4) },
155 ];
156 const tallied = tallyReactions(rows, "user:a");
157 assert.deepEqual(tallied.get("m1"), [
158 { emoji: "👍", count: 2, me: true, by: ["user:a", "agent:x"] },
159 { emoji: "🎉", count: 1, me: false, by: ["user:b"] },
160 ]);
161 assert.deepEqual(tallied.get("m2"), [{ emoji: ":ship:", count: 1, me: true, by: ["user:a"] }]);
162 assert.equal(tallied.get("m3"), undefined);
163 // Without a viewer (a live event every one sees), nobody is `me`.
164 assert.equal(tallyReactions(rows, null).get("m1")![0]!.me, false);
165});
166
167test("a message carries the first ten who reacted, and counts them all", () => {
168 const rows = Array.from({ length: 25 }, (_, i) => ({
169 message_id: "m1",
170 emoji: "🚀",
171 principal: `user:u${String(i).padStart(2, "0")}`,
172 created_at: `2026-10-08T10:00:${String(i).padStart(2, "0")}.000Z`,
173 }));
174 const [rocket] = tallyReactions(rows, "user:u24").get("m1")!;
175 assert.equal(rocket!.count, 25);
176 assert.equal(rocket!.me, true);
177 assert.equal(rocket!.by.length, REACTORS_SHOWN);
178 assert.equal(rocket!.by[0], "user:u00");
179});