| 1 | // Tests for composer key handling. node test/ui.test.mjs |
| 2 | import assert from "node:assert/strict"; |
| 3 | import { |
| 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 | |
| 35 | let failures = 0; |
| 36 | async 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 | |
| 46 | await test("desktop: Enter sends", () => { |
| 47 | assert.equal(shouldSend({ key: "Enter", shiftKey: false }, false), true); |
| 48 | }); |
| 49 | |
| 50 | await test("desktop: Shift+Enter is a newline (does not send)", () => { |
| 51 | assert.equal(shouldSend({ key: "Enter", shiftKey: true }, false), false); |
| 52 | }); |
| 53 | |
| 54 | await test("mobile: Enter is a newline (does not send)", () => { |
| 55 | assert.equal(shouldSend({ key: "Enter", shiftKey: false }, true), false); |
| 56 | }); |
| 57 | |
| 58 | await test("mobile: Shift+Enter is a newline (does not send)", () => { |
| 59 | assert.equal(shouldSend({ key: "Enter", shiftKey: true }, true), false); |
| 60 | }); |
| 61 | |
| 62 | await 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 | |
| 67 | await 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 | |
| 76 | await 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 | |
| 83 | await 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 | |
| 90 | await 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 | |
| 97 | await 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 | |
| 107 | await 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 | |
| 115 | await 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 | |
| 122 | await 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 | |
| 134 | await 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 | |
| 142 | await 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 | |
| 149 | await 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 | |
| 156 | await 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 | |
| 164 | await 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 | |
| 170 | await 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 | |
| 179 | await 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 | |
| 185 | await 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 | |
| 194 | await test("pinnedFirst: no pins leaves the list unchanged", () => { |
| 195 | const list = [{ id: "a" }, { id: "b" }]; |
| 196 | assert.deepEqual(pinnedFirst(list, () => false), list); |
| 197 | }); |
| 198 | |
| 199 | await 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 | |
| 221 | await 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 | |
| 226 | await 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 | |
| 231 | await 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 | |
| 237 | await 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 | |
| 244 | await 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 | |
| 252 | await 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 | |
| 267 | await 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 | |
| 276 | await 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 | |
| 282 | await 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 | |
| 289 | await 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 | |
| 295 | await 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 | |
| 324 | await 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 | |
| 347 | await 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 | |
| 355 | await 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 | |
| 362 | await 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 | |
| 376 | await 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 | |
| 388 | await 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 | |
| 405 | await 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 | }); |
| 420 | await 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 | |
| 434 | await 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 | |
| 441 | await 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 | |
| 451 | await 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 | |
| 467 | console.log(failures === 0 ? "\nALL PASSED" : `\n${failures} FAILED`); |
| 468 | process.exit(failures === 0 ? 0 : 1); |
| 469 | |