eleven / ocho Public

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

Plain git — no account needed to clone.

Download

Download this file

1// Tests for composer key handling. node test/ui.test.mjs
2import assert from "node:assert/strict";
3import {
4 shouldSend,
5 fileExt,
6 fileAllowed,
7 fileMime,
8 fileIcon,
9 normalizeTheme,
10 normalizeAppearance,
11 colorScheme,
12 composerToken,
13 pinnedFirst,
14 ownMessage,
15 sameSender,
16 memberCountLabel,
17 onlineFilterKeeps,
18 chatCategory,
19 presenceLabel,
20 displayNameFor,
21 renameNotice,
22 translateMentions,
23 DEFAULT_THEME,
24 DEFAULT_APPEARANCE,
25 sanitizeActions,
26 capAllows,
27 appOpenPlan,
28 normalizeTextSize,
29 TEXT_SIZES,
30 DEFAULT_TEXT_SIZE,
31 appReloadMessage,
32 appSchemeFrom,
33} from "../web/static/ui.js";
34
35let failures = 0;
36async function test(name, fn) {
37 try {
38 await fn();
39 console.log("PASS " + name);
40 } catch (e) {
41 console.log("FAIL " + name + " β€” " + (e && e.message));
42 failures++;
43 }
44}
45
46await test("desktop: Enter sends", () => {
47 assert.equal(shouldSend({ key: "Enter", shiftKey: false }, false), true);
48});
49
50await test("desktop: Shift+Enter is a newline (does not send)", () => {
51 assert.equal(shouldSend({ key: "Enter", shiftKey: true }, false), false);
52});
53
54await test("mobile: Enter is a newline (does not send)", () => {
55 assert.equal(shouldSend({ key: "Enter", shiftKey: false }, true), false);
56});
57
58await test("mobile: Shift+Enter is a newline (does not send)", () => {
59 assert.equal(shouldSend({ key: "Enter", shiftKey: true }, true), false);
60});
61
62await test("other keys never send", () => {
63 assert.equal(shouldSend({ key: "a", shiftKey: false }, false), false);
64 assert.equal(shouldSend({ key: "Tab", shiftKey: false }, false), false);
65});
66
67await test("fileExt: lowercased extension without the dot", () => {
68 assert.equal(fileExt("CLAUDE.local.md"), "md");
69 assert.equal(fileExt("Report.PDF"), "pdf");
70 assert.equal(fileExt("archive.tar.gz"), "gz");
71 assert.equal(fileExt("noext"), "");
72 assert.equal(fileExt(""), "");
73 assert.equal(fileExt(undefined), "");
74});
75
76await test("fileAllowed: every file type may be attached (2026-07-09)", () => {
77 for (const name of ["a.pdf", "notes.txt", "CLAUDE.local.md", "data.csv", "x.json",
78 "deck.pptx", "evil.exe", "run.sh", "app.js", "lib.so", "noext", ""]) {
79 assert.equal(fileAllowed(name), true, name);
80 }
81});
82
83await test("fileMime: browser type wins, else mapped from extension", () => {
84 assert.equal(fileMime("x.bin", "image/png"), "image/png"); // browser-provided type wins
85 assert.equal(fileMime("CLAUDE.local.md", ""), "text/markdown"); // .md often has empty type
86 assert.equal(fileMime("data.csv", undefined), "text/csv");
87 assert.equal(fileMime("mystery.xyz", ""), "application/octet-stream"); // unknown ext
88});
89
90await test("fileIcon: mapped emoji, πŸ“Ž fallback", () => {
91 assert.equal(fileIcon("a.pdf"), "πŸ“•");
92 assert.equal(fileIcon("b.csv"), "πŸ“Š");
93 assert.equal(fileIcon("c.docx"), "πŸ“˜");
94 assert.equal(fileIcon("d.unknown"), "πŸ“Ž");
95});
96
97await test("normalizeTheme: valid passes through, junk falls back", () => {
98 for (const ok of ["litroom", "lagoon", "sunny", "plum", "linen", "classic"]) {
99 assert.equal(normalizeTheme(ok), ok);
100 }
101 assert.equal(normalizeTheme("neon"), DEFAULT_THEME);
102 assert.equal(normalizeTheme(null), DEFAULT_THEME);
103 assert.equal(normalizeTheme(undefined), DEFAULT_THEME);
104 assert.equal(normalizeTheme(""), DEFAULT_THEME);
105});
106
107await test("normalizeAppearance: valid passes through, junk falls back", () => {
108 for (const ok of ["light", "dark", "system"]) {
109 assert.equal(normalizeAppearance(ok), ok);
110 }
111 assert.equal(normalizeAppearance("sepia"), DEFAULT_APPEARANCE);
112 assert.equal(normalizeAppearance(null), DEFAULT_APPEARANCE);
113});
114
115await test("colorScheme: system maps to 'light dark' so light-dark() follows the OS", () => {
116 assert.equal(colorScheme("light"), "light");
117 assert.equal(colorScheme("dark"), "dark");
118 assert.equal(colorScheme("system"), "light dark");
119 assert.equal(colorScheme("anything-else"), "light dark");
120});
121
122await test("normalizeTextSize: valid stops pass through (stored strings too), junk falls back", () => {
123 for (const ok of TEXT_SIZES) {
124 assert.equal(normalizeTextSize(ok), ok);
125 assert.equal(normalizeTextSize(String(ok)), ok); // localStorage returns strings
126 }
127 assert.equal(normalizeTextSize(15), DEFAULT_TEXT_SIZE); // not a slider stop
128 assert.equal(normalizeTextSize("huge"), DEFAULT_TEXT_SIZE);
129 assert.equal(normalizeTextSize(null), DEFAULT_TEXT_SIZE);
130 assert.equal(normalizeTextSize(undefined), DEFAULT_TEXT_SIZE);
131 assert.equal(normalizeTextSize(""), DEFAULT_TEXT_SIZE);
132});
133
134await test("text size default sits one from the top of the ladder", () => {
135 assert.equal(DEFAULT_TEXT_SIZE, 15);
136 // one stop above the default and no more β€” bigger sizes read as shouting
137 assert.equal(TEXT_SIZES[TEXT_SIZES.length - 2], DEFAULT_TEXT_SIZE);
138 // strictly increasing β€” the slider steps through them by index
139 for (let i = 1; i < TEXT_SIZES.length; i++) assert.ok(TEXT_SIZES[i] > TEXT_SIZES[i - 1]);
140});
141
142await test("composerToken: detects an emoji shortcode in progress", () => {
143 const t = composerToken("hey :tad", 8);
144 assert.equal(t?.kind, "emoji");
145 assert.equal(t.query, "tad");
146 assert.equal(t.start, 4); // index of the ":"
147});
148
149await test("composerToken: detects a mention (and @-only opens it)", () => {
150 assert.equal(composerToken("hi @so", 6)?.kind, "mention");
151 assert.equal(composerToken("hi @so", 6)?.query, "so");
152 assert.equal(composerToken("@", 1)?.kind, "mention"); // bare "@" browses everyone
153 assert.equal(composerToken("@", 1)?.query, "");
154});
155
156await test("composerToken: ignores non-boundary and closed tokens", () => {
157 assert.equal(composerToken("10:30", 5), null); // time, not an emoji
158 assert.equal(composerToken("email@host", 10), null); // email, not a mention
159 assert.equal(composerToken("see https://x", 13), null); // URL scheme colon
160 assert.equal(composerToken(":tada:", 6), null); // completed shortcode
161 assert.equal(composerToken("hi :", 4), null); // a bare ":" is punctuation
162});
163
164await test("composerToken: only the token before the caret counts", () => {
165 const t = composerToken(":tada: and :par", 15);
166 assert.equal(t?.kind, "emoji");
167 assert.equal(t.query, "par");
168});
169
170await test("composerToken: a leading / opens the command menu", () => {
171 const t = composerToken("/tod", 4);
172 assert.equal(t?.kind, "command");
173 assert.equal(t.query, "tod");
174 assert.equal(t.start, 0);
175 assert.equal(composerToken("/", 1)?.kind, "command"); // bare "/" browses all commands
176 assert.equal(composerToken("/", 1)?.query, "");
177});
178
179await test("composerToken: / only triggers on the command name, at the start", () => {
180 assert.equal(composerToken("/todo buy milk", 10), null); // caret in args β†’ no menu
181 assert.equal(composerToken("hey /todo", 9), null); // "/" mid-line isn't a command
182 assert.equal(composerToken("/todo", 5)?.query, "todo"); // caret at end of the name
183});
184
185await test("pinnedFirst: floats pinned chats, keeping order within each band", () => {
186 const list = [{ id: "a" }, { id: "b" }, { id: "c" }, { id: "d" }];
187 const pins = { b: true, d: true };
188 assert.deepEqual(
189 pinnedFirst(list, (t) => pins[t.id]).map((t) => t.id),
190 ["b", "d", "a", "c"],
191 );
192});
193
194await test("pinnedFirst: no pins leaves the list unchanged", () => {
195 const list = [{ id: "a" }, { id: "b" }];
196 assert.deepEqual(pinnedFirst(list, () => false), list);
197});
198
199await test("pinnedFirst: orders each band most-recent-first by lastOf", () => {
200 // Out-of-recency-order input (the pinned d is older than pinned b; unpinned c
201 // newer than a) β€” each band is re-sorted newest-first, pinned band on top.
202 const list = [
203 { id: "a", at: 10 },
204 { id: "b", at: 40 },
205 { id: "c", at: 30 },
206 { id: "d", at: 20 },
207 ];
208 const pins = { b: true, d: true };
209 assert.deepEqual(
210 pinnedFirst(list, (t) => pins[t.id], (t) => t.at).map((t) => t.id),
211 ["b", "d", "c", "a"],
212 );
213 // Ties keep input order (stable) β€” so a same-second burst doesn't jump around.
214 const tied = [{ id: "x", at: 5 }, { id: "y", at: 5 }, { id: "z", at: 9 }];
215 assert.deepEqual(
216 pinnedFirst(tied, () => false, (t) => t.at).map((t) => t.id),
217 ["z", "x", "y"],
218 );
219});
220
221await test("ownMessage: matches by uid when both sides have one", () => {
222 assert.equal(ownMessage({ uid: "u1", name: "Paul" }, "u1", "Paul"), true);
223 assert.equal(ownMessage({ uid: "u2", name: "Paul" }, "u1", "Paul"), false); // same name, other user
224});
225
226await test("ownMessage: legacy payloads (no uid) fall back to the screen name", () => {
227 assert.equal(ownMessage({ name: "Paul" }, "u1", "Paul"), true);
228 assert.equal(ownMessage({ name: "Maud" }, "u1", "Paul"), false);
229});
230
231await test("ownMessage: never matches on empty identity", () => {
232 assert.equal(ownMessage(null, "u1", "Paul"), false);
233 assert.equal(ownMessage({ name: "" }, "u1", ""), false);
234 assert.equal(ownMessage({}, "u1", "Paul"), false);
235});
236
237await test("sameSender: uid wins over the name snapshot", () => {
238 // Same person across a rename (snapshots differ) β€” still one run.
239 assert.equal(sameSender({ uid: "u1", name: "Dermo" }, { uid: "u1", name: "Dermot" }), true);
240 // Two people, even with identical snapshots (both nameless), never group.
241 assert.equal(sameSender({ uid: "u1", name: "" }, { uid: "u2", name: "" }), false);
242});
243
244await test("sameSender: uid-less legacy messages fall back to the name", () => {
245 assert.equal(sameSender({ name: "Al" }, { name: "Al" }), true);
246 assert.equal(sameSender({ name: "Al" }, { name: "Bo" }), false);
247 // A nameless legacy pair says nothing about who sent them β€” no grouping.
248 assert.equal(sameSender({ name: "" }, { name: "" }), false);
249 assert.equal(sameSender(null, { name: "Al" }), false);
250});
251
252await test("sanitizeActions: whitelists types and validates params", () => {
253 const chips = sanitizeActions([
254 { type: "set_theme", theme: "plum" },
255 { type: "set_theme", theme: "neon" }, // not a real theme
256 { type: "set_appearance", appearance: "dark", label: " Go dark " },
257 { type: "delete_everything" }, // unknown type
258 { type: "open_profile" },
259 { type: "open_tutorial" }, // the welcome DM's "Take the tour" chip
260 "junk",
261 ]);
262 assert.deepEqual(chips.map((c) => c.type), ["set_theme", "set_appearance", "open_profile", "open_tutorial"]);
263 assert.equal(chips[0].theme, "plum");
264 assert.equal(chips[1].label, "Go dark");
265});
266
267await test("sanitizeActions: caps the count and tolerates garbage", () => {
268 assert.deepEqual(sanitizeActions(null), []);
269 assert.deepEqual(sanitizeActions("x"), []);
270 const many = Array.from({ length: 9 }, () => ({ type: "open_theme_picker" }));
271 assert.equal(sanitizeActions(many).length, 4);
272 const longLabel = sanitizeActions([{ type: "open_profile", label: "x".repeat(99) }]);
273 assert.equal(longLabel[0].label.length, 40);
274});
275
276await test("memberCountLabel: people, bots, and AI agents counted separately", () => {
277 assert.equal(memberCountLabel(2, 2, 1), "2 members, 2 bots, 1 AI agent");
278 assert.equal(memberCountLabel(1, 1, 2), "1 member, 1 bot, 2 AI agents");
279 assert.equal(memberCountLabel(2, 3), "2 members, 3 bots"); // agents defaults to 0
280});
281
282await test("memberCountLabel: zero categories are dropped", () => {
283 assert.equal(memberCountLabel(5, 0, 0), "5 members");
284 assert.equal(memberCountLabel(1, 0, 0), "1 member");
285 assert.equal(memberCountLabel(0, 0, 0), "0 members");
286 assert.equal(memberCountLabel(2, 0, 1), "2 members, 1 AI agent"); // bots dropped, agents kept
287});
288
289await test("memberCountLabel: all helpers β†’ no '0 members'", () => {
290 assert.equal(memberCountLabel(0, 2, 0), "2 bots");
291 assert.equal(memberCountLabel(0, 0, 3), "3 AI agents");
292 assert.equal(memberCountLabel(0, 1, 1), "1 bot, 1 AI agent");
293});
294
295await test("onlineFilterKeeps: people decide; pure-bot chats fall back to the bot", () => {
296 // A DM with an online person stays; away or offline drops.
297 assert.equal(onlineFilterKeeps([{ agent: false, presence: "online" }]), true);
298 assert.equal(onlineFilterKeeps([{ agent: false, presence: "away" }]), false);
299 assert.equal(onlineFilterKeeps([{ agent: false, presence: "offline" }]), false);
300 // A chat with ONLY bots keeps iff the bot is online (sidecar health).
301 assert.equal(onlineFilterKeeps([{ agent: true, presence: "online" }]), true);
302 assert.equal(onlineFilterKeeps([{ agent: true, presence: "offline" }]), false);
303 // In a mixed room only the PEOPLE count β€” an always-on bot must not make
304 // every room it sits in read "someone's online".
305 assert.equal(
306 onlineFilterKeeps([
307 { agent: true, presence: "online" },
308 { agent: false, presence: "offline" },
309 ]),
310 false,
311 );
312 assert.equal(
313 onlineFilterKeeps([
314 { agent: true, presence: "offline" },
315 { agent: false, presence: "online" },
316 ]),
317 true,
318 );
319 // Notes/app threads (no other members) never match; missing entries are safe.
320 assert.equal(onlineFilterKeeps([]), false);
321 assert.equal(onlineFilterKeeps([null, { agent: false, presence: "" }]), false);
322});
323
324await test("chatCategory: apps, then all-bot chats, else people", () => {
325 assert.equal(chatCategory(true, []), "apps");
326 assert.equal(chatCategory(true, [{ agent: true, presence: "online" }]), "apps"); // app wins
327 assert.equal(chatCategory(false, [{ agent: true, presence: "offline" }]), "bots");
328 assert.equal(
329 chatCategory(false, [
330 { agent: true, presence: "online" },
331 { agent: true, presence: "online" },
332 ]),
333 "bots",
334 );
335 // Any person in the room makes it a people chat, bots present or not.
336 assert.equal(
337 chatCategory(false, [
338 { agent: true, presence: "online" },
339 { agent: false, presence: "offline" },
340 ]),
341 "people",
342 );
343 assert.equal(chatCategory(false, [{ agent: false, presence: "online" }]), "people");
344 assert.equal(chatCategory(false, []), "people"); // your own Notes
345});
346
347await test("presenceLabel: all three states read out; unknown reads blank", () => {
348 assert.equal(presenceLabel("online"), "Online");
349 assert.equal(presenceLabel("away"), "Away");
350 assert.equal(presenceLabel("offline"), "Offline");
351 assert.equal(presenceLabel(""), "");
352 assert.equal(presenceLabel(undefined), "");
353});
354
355await test("displayNameFor: my name for someone wins over their published one", () => {
356 assert.equal(displayNameFor({ name: "Dermo", seen: "Dermo" }, "Dermot"), "Dermo");
357 assert.equal(displayNameFor(null, "Dermot"), "Dermot");
358 assert.equal(displayNameFor(undefined, ""), "");
359 assert.equal(displayNameFor({ name: "", seen: "" }, "Dermot"), "Dermot"); // empty nickname = no nickname
360});
361
362await test("renameNotice: fires once per unacknowledged rename, never without a nickname", () => {
363 // The scenario from #577: invited as Dermo, renames to Dermot β†’ I get the choice.
364 assert.deepEqual(renameNotice({ name: "Dermo", seen: "Dermo" }, "Dermot"), { from: "Dermo", to: "Dermot" });
365 // Acknowledged (kept my name): seen catches up, no more prompting.
366 assert.equal(renameNotice({ name: "Dermo", seen: "Dermot" }, "Dermot"), null);
367 // …until they rename AGAIN.
368 assert.deepEqual(renameNotice({ name: "Dermo", seen: "Dermot" }, "Diarmuid"), { from: "Dermo", to: "Diarmuid" });
369 // They renamed to exactly what I call them β€” nothing to ask.
370 assert.equal(renameNotice({ name: "Dermo", seen: "x" }, "Dermo"), null);
371 // No nickname / no published name yet β†’ never a notice.
372 assert.equal(renameNotice(null, "Dermot"), null);
373 assert.equal(renameNotice({ name: "Dermo", seen: "Dermo" }, ""), null);
374});
375
376await test("translateMentions: rewrites published @names to the viewer's, same boundaries as the renderer", () => {
377 const renames = { Robert: "Bobby", "Anna Lee": "Mum" };
378 assert.equal(translateMentions("@Robert good to see you", renames), "@Bobby good to see you");
379 assert.equal(translateMentions("ping @Anna Lee now", renames), "ping @Mum now"); // multi-word label
380 assert.equal(translateMentions("@Roberta is someone else", renames), "@Roberta is someone else"); // no trailing word char
381 assert.equal(translateMentions("mail me@Robert.com", renames), "mail me@Robert.com"); // email stays
382 assert.equal(translateMentions("hi @Robert and @Robert", renames), "hi @Bobby and @Bobby"); // every occurrence
383 assert.equal(translateMentions("no mentions here", renames), "no mentions here");
384 assert.equal(translateMentions("@Robert", {}), "@Robert"); // no renames β†’ untouched
385 assert.equal(translateMentions("", renames), "");
386});
387
388await test("sanitizeActions: service_research validates services and goal", () => {
389 const ok = sanitizeActions([
390 { type: "service_research", services: ["github", "dnsimple", "github"], goal: " which domains point at Pages? ", label: "Look" },
391 ]);
392 assert.equal(ok.length, 1);
393 assert.deepEqual(ok[0].services, ["github", "dnsimple"]); // deduped
394 assert.equal(ok[0].goal, "which domains point at Pages?"); // trimmed
395 const bad = sanitizeActions([
396 { type: "service_research", services: [], goal: "x" }, // no services
397 { type: "service_research", services: ["github"], goal: " " }, // blank goal
398 { type: "service_research", services: ["GH!"], goal: "x" }, // bad id
399 { type: "service_research", services: ["a", "b", "c", "d", "e"], goal: "x" }, // too many
400 { type: "service_research", services: ["github"], goal: "x".repeat(301) }, // goal too long
401 ]);
402 assert.equal(bad.length, 0);
403});
404
405await test("sanitizeActions: run_command takes one clean app command, label is the command", () => {
406 const ok = sanitizeActions([{ type: "run_command", command: " /remind pick up the kids at 4pm ", label: "Open theme picker" }]);
407 assert.equal(ok.length, 1);
408 assert.equal(ok[0].command, "/remind pick up the kids at 4pm"); // trimmed
409 assert.equal(ok[0].label, undefined); // a free label could lie about the tap β€” always render the command
410 const bad = sanitizeActions([
411 { type: "run_command", command: "remind me at 4" }, // no slash
412 { type: "run_command", command: "/" }, // empty verb
413 { type: "run_command", command: "/todo @here buy milk" }, // group side effect from a tap
414 { type: "run_command", command: "/todo a\nb" }, // multi-line
415 { type: "run_command", command: "/" + "x".repeat(300) }, // too long
416 { type: "run_command", command: "/todo a\x07b" }, // control chars
417 ]);
418 assert.equal(bad.length, 0);
419});
420await test("capAllows: a connection's cap is the grant ceiling", () => {
421 // Reads pass under every cap (and under no cap at all).
422 assert.equal(capAllows("read", "read"), true);
423 assert.equal(capAllows("write", "read"), true);
424 assert.equal(capAllows(undefined, "read"), true);
425 assert.equal(capAllows("read", undefined), true); // access defaults to read
426 // Writes need a read+write connection…
427 assert.equal(capAllows("write", "write"), true);
428 assert.equal(capAllows("read", "write"), false);
429 // …except legacy cap-less connections, which keep their old behaviour.
430 assert.equal(capAllows(undefined, "write"), true);
431 assert.equal(capAllows(null, "write"), true);
432});
433
434await test("appReloadMessage names what actually changed, not a blanket 'new app'", () => {
435 assert.equal(appReloadMessage("created"), "A new app is available.");
436 assert.equal(appReloadMessage("updated"), "App updated.");
437 assert.equal(appReloadMessage("removed"), "Apps changed.");
438 assert.equal(appReloadMessage(undefined), "Apps changed."); // older server, no kind sent
439});
440
441await test("appSchemeFrom: the shipped scheme and dev alts pass, junk falls back", () => {
442 assert.equal(appSchemeFrom("elevenmessenger"), "elevenmessenger");
443 assert.equal(appSchemeFrom("elevenmessenger.alt-dek"), "elevenmessenger.alt-dek");
444 assert.equal(appSchemeFrom("elevenmessenger.alt-dev2"), "elevenmessenger.alt-dev2");
445 for (const junk of ["", "https", "evil", "elevenmessengerx", "elevenmessenger.alt-",
446 "elevenmessenger.alt-UPPER", "elevenmessenger.alt-" + "x".repeat(33), null, undefined, 7]) {
447 assert.equal(appSchemeFrom(junk), "elevenmessenger", `junk ${JSON.stringify(junk)} falls back`);
448 }
449});
450
451await test("appOpenPlan routes App Studio's Open by the app's places", () => {
452 // No group place β†’ open straight into where the app lives.
453 assert.deepEqual(appOpenPlan(["you"]), { ask: false, direct: "you" });
454 assert.deepEqual(appOpenPlan(["you", "space"]), { ask: false, direct: "you" });
455 assert.deepEqual(appOpenPlan(["space"]), { ask: false, direct: "space" });
456 // Group-only β†’ nothing to open until it's added somewhere: ask which group.
457 assert.deepEqual(appOpenPlan(["chat"]), { ask: "group", direct: null });
458 // Group + another place β†’ ask where to open it.
459 assert.deepEqual(appOpenPlan(["you", "chat"]), { ask: "how", direct: "you" });
460 assert.deepEqual(appOpenPlan(["chat", "space"]), { ask: "how", direct: "space" });
461 assert.deepEqual(appOpenPlan(["you", "chat", "space"]), { ask: "how", direct: "you" });
462 // Legacy/empty manifests read as personal-only, like registerMemberApp's default.
463 assert.deepEqual(appOpenPlan(undefined), { ask: false, direct: "you" });
464 assert.deepEqual(appOpenPlan([]), { ask: false, direct: "you" });
465});
466
467console.log(failures === 0 ? "\nALL PASSED" : `\n${failures} FAILED`);
468process.exit(failures === 0 ? 0 : 1);
469