1import test from "node:test";2import assert from "node:assert/strict";3import fs from "node:fs";4import vm from "node:vm";5import { finalizeEvent, generateSecretKey, getPublicKey, verifyEvent } from "nostr-tools/pure";6import * as nip19 from "nostr-tools/nip19";7import { createHash } from "node:crypto";89const roomsSource = fs.readFileSync("tinyclient/rooms.js", "utf8");10const parity = JSON.parse(fs.readFileSync("tinyclient/room_parity.json", "utf8"));1112// A small document: enough of the DOM for the room elements to render13// messages, find their targets and keep the list bounded.14export class FakeNode {15 constructor(tag) { this.localName = tag; this.childNodes = []; this.attributes = {}; this.dataset = {}; this.style = {}; this.parent = null; this.id = ""; }16 get children() { return this.childNodes.filter(node => node instanceof FakeNode); }17 get firstElementChild() { return this.children[0] || null; }18 get isConnected() { return Boolean(this.parent) && (this.parent === document || this.parent.isConnected); }19 append(...nodes) { for (const node of nodes) { if (node instanceof FakeNode) { node.remove(); node.parent = this; this.childNodes.push(node); } else if (String(node)) this.childNodes.push(String(node)); } }20 insertBefore(node, before) { if (!before) return this.append(node); node.remove(); const index = this.childNodes.indexOf(before); node.parent = this; this.childNodes.splice(index, 0, node); }21 remove() { if (this.parent) { this.parent.childNodes = this.parent.childNodes.filter(node => node !== this); this.parent = null; } }22 focus() { this.focused = true; }23 replaceWith(node) { if (!this.parent) return; const index = this.parent.childNodes.indexOf(this); node.remove(); node.parent = this.parent; this.parent.childNodes[index] = node; this.parent = null; }24 replaceChildren(...nodes) { this.children.forEach(node => { node.parent = null; }); this.childNodes = []; this.append(...nodes); }25 get textContent() { return this.childNodes.map(node => typeof node === "string" ? node : node.textContent).join(""); }26 set textContent(value) { this.childNodes = value ? [String(value)] : []; }27 setAttribute(name, value) { if (name === "id") this.id = value; else this.attributes[name] = String(value); }28 getAttribute(name) { return name === "id" ? this.id || null : this.attributes[name] ?? null; }29 removeAttribute(name) { delete this.attributes[name]; }30 hasAttribute(name) { return name.startsWith("data-") ? name.slice(5).replace(/-([a-z])/g, (_, c) => c.toUpperCase()) in this.dataset : name in this.attributes; }31 matches(selector) { return selector === this.localName; }32 addEventListener(name, handler) { this.listeners ||= {}; (this.listeners[name] ||= []).push(handler); }33 emit(name, event) { (this.listeners?.[name] || []).forEach(handler => handler(event)); }34 querySelector(selector) { return this.querySelectorAll(selector)[0] || null; }35 querySelectorAll(selector) {36 const steps = selector.replace(/^:scope\s*>\s*/, "> ").trim().split(/\s*>\s*|\s+/).filter(Boolean);37 const child = selector.includes(">");38 let nodes = [this];39 for (const step of steps) {40 const next = [];41 for (const node of nodes) for (const candidate of child ? node.children : node.descendants()) if (candidate.matchesSimple(step)) next.push(candidate);42 nodes = next;43 }44 return nodes;45 }46 descendants() { return this.children.flatMap(node => [node, ...node.descendants()]); }47 matchesSimple(step) {48 if (step.startsWith("#")) return this.id === step.slice(1);49 const [, tag, attr] = step.match(/^([a-z-]+)?(?:\[([a-z-]+)\])?$/) || [];50 return (!tag || tag === this.localName) && (!attr || this.hasAttribute(attr));51 }52}53const document = new FakeNode("#document");54document.body = new FakeNode("body");55document.body.parent = document;56document.documentElement = {scrollHeight: 1000};57document.createElement = tag => new FakeNode(tag);58document.getElementById = id => document.body.descendants().find(node => node.id === id) || null;59document.querySelector = selector => document.body.querySelector(selector);60document.querySelectorAll = selector => document.body.querySelectorAll(selector);6162export function setup({attributes = {}, result = {accepted: true}, change = false} = {}) {63 const sent = [], navigated = [], previewURLs = [], revokedURLs = [];64 const secret = generateSecretKey(), pubkey = getPublicKey(secret);65 document.body.childNodes = [];66 const content = new FakeNode("div"); content.id = "content"; content.scrollHeight = 500; content.clientHeight = 400; content.scrollTop = 100;67 const list = new FakeNode("div"); list.id = "messages";68 const members = new FakeNode("ul"); members.id = "members";69 const agentRow = new FakeNode("li"); agentRow.dataset.agent = ""; agentRow.setAttribute("data-operator", "a".repeat(64));70 const agentKey = new FakeNode("nostr-name"); agentKey.setAttribute("pubkey", "b".repeat(64));71 agentRow.append(agentKey, " ", Object.assign(new FakeNode("small"), {childNodes: ["member | agent"]}));72 members.append(agentRow);73 document.body.append(content, list, members);74 class HTMLElement extends FakeNode {75 constructor() { super("custom"); }76 getAttribute(name) { return attributes[name] ?? super.getAttribute(name); }77 }78 class FormElement extends HTMLElement {79 connectedCallback() { this.form = {requestSubmit() {}}; }80 report(message) { this.message = message; }81 }82 class TestURL extends URL {}83 TestURL.createObjectURL = file => { const url = "blob:room-preview-" + (previewURLs.length + 1); previewURLs.push({file, url}); return url; };84 TestURL.revokeObjectURL = url => revokedURLs.push(url);85 const sandbox = {86 document, HTMLElement, FormElement, URL: TestURL, Uint8Array, AbortController, encodeURIComponent, setTimeout, clearTimeout,87 location: {href: "https://relay.test/r/work/rooms/build", origin: "https://relay.test"},88 getComputedStyle: () => ({overflowY: "auto"}),89 window: {scrollY: 0, innerHeight: 800, scrollTo() {}, NostrSigner: {verifyEvent, decodeNpub: value => { const decoded = nip19.decode(value); if (decoded.type !== "npub") throw Error("not an npub"); return decoded.data; }}, nostr: {signEvent: async event => { const copy = JSON.parse(JSON.stringify(event)); if (change) copy.content = "changed"; return finalizeEvent(copy, secret); }}},90 tiny: {localPath: path => "/r/work" + path, sha256hex: async bytes => createHash("sha256").update(bytes).digest("hex"), navigate: async (href, push) => { navigated.push({href, push}); }, signedFetch: async (path, method, body, options) => { sent.push({path, method, options, event: JSON.parse(body)}); return Response.json(result); }},91 el: (tag, text) => { const node = new FakeNode(tag); if (text !== undefined) node.textContent = text; return node; },92 isHex64: value => typeof value === "string" && /^[0-9a-f]{64}$/.test(value)93 };94 sandbox.EventSource = class {95 constructor(url, options) { this.url = url; this.options = options; this.listeners = {}; this.closed = false; sandbox.sources.push(this); }96 addEventListener(name, handler) { (this.listeners[name] = this.listeners[name] || []).push(handler); }97 emit(name, detail) { (this.listeners[name] || []).forEach(handler => handler(detail)); }98 close() { this.closed = true; }99 };100 sandbox.tiny.ui = {FormElement, el: sandbox.el, isHex64: sandbox.isHex64};101 sandbox.tiny.signing = {publish: async (unsigned, {signal} = {}) => {102 const expected = JSON.stringify(unsigned);103 const event = await sandbox.window.nostr.signEvent(JSON.parse(expected));104 const actual = event && JSON.stringify({kind: event.kind, created_at: event.created_at, tags: event.tags, content: event.content});105 if (actual !== expected || sandbox.window.NostrSigner.verifyEvent(event) !== true) throw Error("The signer returned an invalid or changed event.");106 if (signal?.aborted) throw Error("Sending canceled.");107 const response = await sandbox.tiny.signedFetch("/events", "POST", JSON.stringify(event), {contentType: "application/json", signal});108 const body = await response.json();109 if (!response.ok || body.accepted !== true) throw Error(body.error || body.message || "The relay rejected the event.");110 return event;111 }};112 sandbox.window.tiny = sandbox.tiny;113 sandbox.customElements = {define() {}};114 sandbox.sources = [];115 vm.runInNewContext(roomsSource, sandbox);116 const classes = {...sandbox.tiny.roomElements, rooms: sandbox.tiny.rooms};117 return {...classes, sandbox, sent, navigated, previewURLs, revokedURLs, pubkey, list, content, form: values => ({elements: Object.fromEntries(Object.entries(values).map(([name, value]) => [name, {name, value, style: {}, scrollHeight: 0}])), reset() { this.resets = (this.resets || 0) + 1; }})};118}119const npub = nip19.npubEncode("d".repeat(64));120// Values built inside the sandbox belong to another realm; compare them as plain data.121const same = (actual, expected) => assert.deepEqual(JSON.parse(JSON.stringify(actual)), expected);122123test("room-compose signs kind 9 with the room tag and mentions from @npub and @hex", async () => {124 const s = setup({attributes: {room: "build", pubkey: "x"}});125 const compose = new s.RoomCompose();126 compose.connectedCallback();127 const unsigned = compose.event(`hi @${npub} and @${"e".repeat(64)}, again @${"e".repeat(64)} not me@${"f".repeat(64)}`);128 assert.equal(unsigned.kind, 9);129 same(unsigned.tags, [["h", "build"], ["p", "d".repeat(64)], ["p", "e".repeat(64)]]);130 same(s.rooms.roomMentions("@npub1notakey and @abc"), []);131 const form = s.form({content: " hello room "});132 await compose.submit(form);133 assert.equal(s.sent[0].path, "/events");134 assert.equal(s.sent[0].options.contentType, "application/json");135 assert.equal(s.sent[0].event.kind, 9);136 assert.equal(s.sent[0].event.content, "hello room");137 assert.equal(verifyEvent(s.sent[0].event), true);138 assert.equal(form.resets, 1);139 assert.equal(s.list.children.length, 1);140 assert.equal(s.list.children[0].id, "msg-" + s.sent[0].event.id);141 assert.equal(s.content.scrollTop, 500);142 assert.throws(() => new (setup({attributes: {room: "Bad Room"}}).RoomCompose)().event("x"), /room id/);143});144145test("room-compose replies in a thread with the root and its author, and refuses a changed event", async () => {146 const root = "1".repeat(64), author = "2".repeat(64);147 const s = setup({attributes: {room: "build", kind: "12", root, "root-pubkey": author, pubkey: "3".repeat(64)}});148 const compose = new s.RoomCompose();149 same(compose.event("ok").tags, [["h", "build"], ["e", root], ["p", author]]);150 const own = setup({attributes: {room: "build", kind: "12", root, "root-pubkey": author, pubkey: author}});151 same(new own.RoomCompose().event("ok").tags, [["h", "build"], ["e", root]]);152 const changed = setup({attributes: {room: "build"}, change: true});153 await assert.rejects(new changed.RoomCompose().submit(changed.form({content: "x"})), /changed event/);154 assert.equal(changed.sent.length, 0);155 const refused = setup({attributes: {room: "build"}, result: {accepted: false, message: "restricted: members only"}});156 await assert.rejects(new refused.RoomCompose().submit(refused.form({content: "x"})), /members only/);157});158159test("room-compose emits the standard q tag for a quoted event", () => {160 const id = "4".repeat(64), author = "5".repeat(64);161 const s = setup({attributes: {room: "build", quote: id, "quote-relay": "wss://relay.example", "quote-pubkey": author}});162 same(new s.RoomCompose().event("see this").tags, [["h", "build"], ["p", author], ["q", id, "wss://relay.example", author]]);163 const withoutRelay = setup({attributes: {room: "build", quote: id, "quote-pubkey": author, pubkey: "6".repeat(64)}});164 same(new withoutRelay.RoomCompose().event("see this").tags, [["h", "build"], ["p", author], ["q", id, "", author]]);165 const invalid = setup({attributes: {room: "build", quote: "not-an-event"}});166 assert.throws(() => new invalid.RoomCompose().event("x"), /quoted event/);167});168169const selectedFile = (name = "chart.png", type = "image/png", text = "image bytes") => ({name, type, size: Buffer.byteLength(text), arrayBuffer: async () => Uint8Array.from(Buffer.from(text)).buffer});170const descriptorFor = (file, bytes) => {171 const sha256 = createHash("sha256").update(bytes).digest("hex");172 return {sha256, size: file.size, type: file.type, url: "https://relay.test/r/work/media/" + sha256 + ".png"};173};174175test("room-compose previews images, keeps preview URLs across rerenders and revokes removed previews", () => {176 const s = setup({attributes: {room: "build"}}), compose = new s.RoomCompose();177 const content = new FakeNode("textarea"); content.name = "content";178 const files = new FakeNode("room-files"), image = selectedFile("photo.png", "image/png"), svg = selectedFile("icon.svg", "image/svg+xml"), pdf = selectedFile("report.pdf", "application/pdf");179 compose.append(content, files);180 compose.connectedCallback();181 compose.form = {elements: {content}};182 compose.addFiles([image, svg, pdf]);183 assert.equal(s.previewURLs.length, 1, "only safe raster images get local previews");184 assert.equal(files.querySelectorAll("img").length, 1);185 const previewURL = s.previewURLs[0].url;186 assert.equal(files.querySelector("img").src, previewURL);187 compose.renderFiles();188 assert.equal(s.previewURLs.length, 1, "rerendering must not allocate another object URL");189 files.querySelectorAll("button")[0].emit("click", {});190 assert.deepEqual(s.revokedURLs, [previewURL]);191 assert.equal(compose.pendingFiles.length, 2);192 compose.releasePreviews();193 assert.equal(s.revokedURLs.length, 1, "releasePreviews must be idempotent after removal");194});195196test("room-compose keeps a failed preview for retry, releases it on send and recreates it after reconnect", async () => {197 const s = setup({attributes: {room: "build"}}), compose = new s.RoomCompose(), files = new FakeNode("room-files"), content = new FakeNode("textarea");198 content.name = "content";199 compose.append(content, files);200 compose.connectedCallback();201 const form = s.form({content: "a chart"});202 compose.form = form;203 const file = selectedFile("photo.png");204 let publishes = 0;205 const publish = s.sandbox.tiny.signedFetch;206 s.sandbox.tiny.signedFetch = async (path, method, bytes, options) => {207 if (path !== "/events") return Response.json(descriptorFor(file, bytes));208 if (++publishes === 1) throw Error("connection lost");209 return publish(path, method, bytes, options);210 };211 compose.addFiles([file]);212 const previewURL = compose.pendingFiles[0].preview;213 await assert.rejects(compose.submit(form), /connection lost/);214 assert.equal(compose.pendingFiles.length, 1);215 assert.equal(compose.pendingFiles[0].preview, previewURL);216 await compose.submit(form);217 assert.deepEqual(s.revokedURLs, [previewURL], "successful send releases the object URL");218 assert.equal(compose.pendingFiles.length, 0);219220 compose.addFiles([file]);221 const reconnectedURL = compose.pendingFiles[0].preview;222 compose.disconnectedCallback();223 assert.deepEqual(s.revokedURLs, [previewURL, reconnectedURL], "disconnect releases pending previews");224 compose.connectedCallback();225 assert.equal(compose.pendingFiles[0].preview, s.previewURLs[2].url, "reconnect can create a fresh preview for the same file");226 assert.notEqual(compose.pendingFiles[0].preview, reconnectedURL);227});228229test("room-compose uploads only on send, then publishes Buzz metadata and an attachment-only message", async () => {230 const s = setup({attributes: {room: "build"}}), compose = new s.RoomCompose();231 const uploads = [], publish = s.sandbox.tiny.signedFetch;232 const file = selectedFile("my chart.png");233 s.sandbox.tiny.signedFetch = async (path, method, bytes, options) => {234 if (path === "/events") return publish(path, method, bytes, options);235 uploads.push({path, method, bytes, options});236 return Response.json(descriptorFor(file, bytes));237 };238 compose.addFiles([file]);239 assert.equal(uploads.length, 0);240 const form = s.form({content: ""});241 await compose.submit(form);242 assert.equal(uploads.length, 1);243 assert.equal(uploads[0].path, "/rooms/build/attachments?filename=my%20chart.png");244 assert.equal(uploads[0].method, "PUT");245 assert.equal(uploads[0].options.contentType, "image/png");246 const descriptor = descriptorFor(file, uploads[0].bytes);247 assert.equal(s.sent[0].event.content, ``);248 same(s.sent[0].event.tags, [["h", "build"], ["imeta", "url " + descriptor.url, "m image/png", "x " + descriptor.sha256, "size " + file.size, "filename my chart.png"]]);249 assert.equal(verifyEvent(s.sent[0].event), true);250 assert.equal(compose.pendingFiles.length, 0);251 assert.equal(form.resets, 1);252});253254test("room-compose retains uploaded files after a publish failure and does not upload them twice on retry", async () => {255 const s = setup({attributes: {room: "build"}}), compose = new s.RoomCompose(), file = selectedFile();256 let uploads = 0, publishes = 0;257 const publish = s.sandbox.tiny.signedFetch;258 s.sandbox.tiny.signedFetch = async (path, method, bytes, options) => {259 if (path !== "/events") { uploads++; return Response.json(descriptorFor(file, bytes)); }260 if (++publishes === 1) throw Error("connection lost");261 return publish(path, method, bytes, options);262 };263 compose.addFiles([file]);264 const form = s.form({content: "a chart"});265 await assert.rejects(compose.submit(form), /connection lost/);266 assert.equal(compose.pendingFiles.length, 1);267 assert.equal(form.resets, undefined);268 await compose.submit(form);269 assert.equal(uploads, 1);270 assert.equal(publishes, 2);271 assert.equal(s.sent[0].event.content.startsWith("a chart\n\n![image]"), true);272});273274test("room-compose refuses oversized selections and untrusted upload descriptors without publishing", async () => {275 const s = setup({attributes: {room: "build"}}), compose = new s.RoomCompose(), file = selectedFile();276 assert.throws(() => compose.addFiles([{...file, size: 32 * 1024 * 1024 + 1}]), /32 MiB/);277 assert.throws(() => compose.addFiles(Array.from({length: 9}, (_, i) => selectedFile(i + ".png"))), /8 files/);278 compose.addFiles([file]);279 for (const change of [d => ({...d, sha256: "0".repeat(64)}), d => ({...d, size: 1}), d => ({...d, url: "https://another.test/media/" + d.sha256 + ".png"})]) {280 s.sandbox.tiny.signedFetch = async (_path, _method, bytes) => Response.json(change(descriptorFor(file, bytes)));281 await assert.rejects(compose.submit(s.form({content: ""})), /descriptor|hash|size|URL/i);282 assert.equal(s.sent.length, 0);283 assert.equal(compose.pendingFiles.length, 1);284 }285});286287test("room-compose does not publish after leaving during an upload", async () => {288 const s = setup({attributes: {room: "build"}}), compose = new s.RoomCompose(), file = selectedFile();289 s.sandbox.tiny.signedFetch = async (_path, _method, bytes) => {290 compose.disconnectedCallback();291 return Response.json(descriptorFor(file, bytes));292 };293 compose.addFiles([file]);294 await assert.rejects(compose.submit(s.form({content: ""})), /cancel/i);295 assert.equal(s.sent.length, 0);296});297298test("room-create derives the id from the name and signs 9007 before opening the room", async () => {299 const s = setup();300 assert.equal(s.rooms.roomID("Build & Release 2026!"), "build-release-2026");301 assert.equal(s.rooms.roomID(" --- "), "");302 assert.equal(s.rooms.roomID("x".repeat(80)).length, 64);303 const create = new s.RoomCreate();304 const {id, event} = create.event({name: " Build & Release ", about: " nightly ", access: "members"});305 assert.equal(id, "build-release");306 assert.equal(event.kind, 9007);307 same(event.tags, [["h", "build-release"], ["name", "Build & Release"], ["about", "nightly"], ["visibility", "members"]]);308 same(create.event({name: "general"}).event.tags, [["h", "general"], ["name", "general"], ["visibility", "open"]]);309 assert.throws(() => create.event({name: "!!!"}), /letters or digits/);310 const fixed = create.event({name: "Hermes", id: " 9782AFBD-88c9-43b1-8b3a-5a68e99d3c52 "});311 assert.equal(fixed.id, "9782afbd-88c9-43b1-8b3a-5a68e99d3c52");312 same(fixed.event.tags, [["h", "9782afbd-88c9-43b1-8b3a-5a68e99d3c52"], ["name", "Hermes"], ["visibility", "open"]]);313 assert.throws(() => create.event({name: "Hermes", id: "not valid!"}), /lowercase letters/);314 await create.submit(s.form({name: "Build", about: "", access: "open"}));315 assert.equal(s.sent[0].event.kind, 9007);316 assert.equal(verifyEvent(s.sent[0].event), true);317 same(s.navigated, [{href: "/r/work/rooms/build", push: true}]);318});319320test("room-action signs membership, settings, join and leave events", () => {321 const s = setup({attributes: {room: "build", kind: "9000"}});322 const add = new s.RoomAction();323 same(add.event({pubkey: npub, role: "admin"}).tags, [["h", "build"], ["p", "d".repeat(64), "admin"]]);324 same(add.event({pubkey: "e".repeat(64), role: "nonsense"}).tags, [["h", "build"], ["p", "e".repeat(64), "member"]]);325 assert.throws(() => add.event({pubkey: "who"}), /npub or a 64/);326 const edit = new (setup({attributes: {room: "build", kind: "9002"}}).RoomAction)();327 same(edit.event({name: " Build ", about: "x", picture: "", access: "members"}).tags, [["h", "build"], ["name", "Build"], ["about", "x"], ["picture", ""], ["visibility", "members"]]);328 assert.throws(() => edit.event({name: " "}), /Enter a name/);329 same(new (setup({attributes: {room: "build", kind: "9022"}}).RoomAction)().event().tags, [["h", "build"]]);330 assert.equal(new (setup({attributes: {room: "build", kind: "9021"}}).RoomAction)().event().kind, 9021);331 assert.throws(() => new (setup({attributes: {room: "build", kind: "9008"}}).RoomAction)().event(), /Unsupported/);332});333334test("room-live appends streamed messages once, summarizes reactions and reconnects with backoff", async () => {335 const s = setup({attributes: {room: "build"}});336 const live = new s.RoomLive();337 live.connectedCallback();338 assert.equal(s.sandbox.sources.length, 1);339 const source = s.sandbox.sources[0];340 assert.equal(source.url, "/r/work/rooms/build/stream");341 assert.equal(source.options.withCredentials, true);342 const secret = generateSecretKey();343 const message = finalizeEvent({kind: 9, created_at: 1757203600, tags: [["h", "build"], ["p", "c".repeat(64)]], content: "see https://example.com/x, ok"}, secret);344 source.emit("message", {data: JSON.stringify(message)});345 source.emit("message", {data: JSON.stringify(message)});346 assert.equal(s.list.children.length, 1);347 const node = s.list.children[0];348 assert.equal(node.id, "msg-" + message.id);349 assert.equal(node.dataset.kind, "9");350 assert.equal(node.querySelector("header b nostr-name").getAttribute("pubkey"), message.pubkey);351 const link = node.querySelector("p a");352 assert.equal(link.href, "https://example.com/x");353 assert.equal(node.querySelector("div p").textContent, "see https://example.com/x, ok");354 assert.equal(node.querySelector("footer span nostr-name").getAttribute("pubkey"), "c".repeat(64));355 source.emit("message", {data: "not json"});356 source.emit("message", {data: JSON.stringify({...message, id: "9".repeat(64), kind: 20001})});357 assert.equal(s.list.children.length, 1);358 const reaction = finalizeEvent({kind: 7, created_at: 1757203700, tags: [["h", "build"], ["e", message.id]], content: "+"}, secret);359 source.emit("message", {data: JSON.stringify(reaction)});360 source.emit("message", {data: JSON.stringify({...reaction, id: "8".repeat(64)})});361 const summary = node.querySelector("footer span[data-reaction]");362 assert.equal(summary.dataset.reaction, "+1");363 assert.equal(summary.textContent, "+1 2");364 const edit = finalizeEvent({kind: 40003, created_at: 1757203750, tags: [["h", "build"], ["e", message.id]], content: "see https://example.com/y instead"}, secret);365 source.emit("message", {data: JSON.stringify(edit)});366 assert.equal(node.querySelector("div p").textContent, "see https://example.com/y instead");367 assert.equal(node.querySelector("p a").href, "https://example.com/y");368 assert.equal(node.dataset.edited, "");369 assert.equal(node.querySelectorAll("footer span[data-edited]").length, 1);370 const stranger = finalizeEvent({kind: 40003, created_at: 1757203760, tags: [["h", "build"], ["e", message.id]], content: "hijacked"}, generateSecretKey());371 source.emit("message", {data: JSON.stringify(stranger)});372 assert.equal(node.querySelector("div p").textContent, "see https://example.com/y instead");373 assert.equal(s.list.children.length, 1);374 // An agent's message carries the marker from the members list.375 const agent = finalizeEvent({kind: 11, created_at: 1757203800, tags: [["h", "build"]], content: "draft"}, secret);376 s.rooms.roomAppend({...agent, pubkey: "b".repeat(64)}, {room: "build"});377 const agentNode = s.list.children[1];378 assert.equal("agent" in agentNode.dataset, true);379 assert.equal(agentNode.querySelector("footer a").href, "/r/work/rooms/build/thread/" + agent.id);380 assert.equal(agentNode.querySelector("header").textContent.includes("'s agent"), true);381 // The list stays bounded.382 for (let i = 0; i < 520; i++) s.rooms.roomAppend({...message, id: i.toString(16).padStart(64, "0")}, {room: "build"});383 assert.equal(s.list.children.length, 500);384 source.emit("error", {});385 assert.equal(source.closed, true);386 assert.equal(live.textContent, "reconnecting");387 assert.equal(live.delay, 2000);388 live.close();389 assert.equal(live.timer, null);390});391392test("room-live on a thread page keeps only replies to its root", () => {393 const root = "a".repeat(64);394 const s = setup({attributes: {room: "build", root}});395 const live = new s.RoomLive();396 live.connectedCallback();397 const source = s.sandbox.sources[0];398 const secret = generateSecretKey();399 source.emit("message", {data: JSON.stringify(finalizeEvent({kind: 9, created_at: 1, tags: [["h", "build"]], content: "chat"}, secret))});400 source.emit("message", {data: JSON.stringify(finalizeEvent({kind: 12, created_at: 2, tags: [["h", "build"], ["e", "b".repeat(64)]], content: "elsewhere"}, secret))});401 const reply = finalizeEvent({kind: 12, created_at: 3, tags: [["h", "build"], ["e", root]], content: "here"}, secret);402 source.emit("message", {data: JSON.stringify(reply)});403 assert.equal(s.list.children.length, 1);404 assert.equal(s.list.children[0].id, "msg-" + reply.id);405 assert.equal(s.list.children[0].querySelector("footer"), null);406 live.disconnectedCallback();407 assert.equal(source.closed, true);408});409410test("room-live resolves an out-of-order nested reply to the canonical root", async () => {411 const root = "a".repeat(64), parent = "b".repeat(64);412 const s = setup({attributes: {room: "build", root}});413 const queries = [];414 s.sandbox.fetch = async (url) => {415 queries.push(url);416 return {ok: true, async json() { return {root: {id: root, kind: 11, tags: [["h", "build"]]}}; }};417 };418 const live = new s.RoomLive();419 live.connectedCallback();420 const source = s.sandbox.sources[0];421 const secret = generateSecretKey();422 const nested = finalizeEvent({kind: 12, created_at: 3, tags: [["h", "build"], ["e", parent]], content: "nested"}, secret);423 source.emit("message", {data: JSON.stringify(nested)});424 await new Promise(resolve => setTimeout(resolve, 0));425 assert.equal(s.list.children.length, 1);426 assert.equal(s.list.children[0].id, "msg-" + nested.id);427 assert.equal(queries.length, 1);428 assert.match(queries[0], /method=browsethread/);429 assert.match(decodeURIComponent(queries[0]), /"event":"bbbb/);430 const followup = finalizeEvent({kind: 12, created_at: 4, tags: [["h", "build"], ["e", nested.id]], content: "followup"}, secret);431 source.emit("message", {data: JSON.stringify(followup)});432 await new Promise(resolve => setTimeout(resolve, 0));433 assert.equal(s.list.children.length, 2);434 assert.equal(queries.length, 1);435});436437test("room-live drops a root lookup that finishes after the stream closes", async () => {438 const root = "a".repeat(64), parent = "b".repeat(64);439 const s = setup({attributes: {room: "build", root}});440 let finish;441 s.sandbox.fetch = async () => new Promise(resolve => { finish = resolve; });442 const live = new s.RoomLive();443 live.connectedCallback();444 const source = s.sandbox.sources[0], secret = generateSecretKey();445 const nested = finalizeEvent({kind: 12, created_at: 3, tags: [["h", "build"], ["e", parent]], content: "nested"}, secret);446 source.emit("message", {data: JSON.stringify(nested)});447 live.close();448 finish({ok: true, async json() { return {root: {id: root, kind: 11, tags: [["h", "build"]]}}; }});449 await new Promise(resolve => setTimeout(resolve, 0));450 assert.equal(s.list.children.length, 0);451});452453test("chat markdown renders the shared subset without ever parsing markup", () => {454 const s = setup();455 const body = s.rooms.chatMarkdown(new FakeNode("div"), "It works: **[the page](https://012.run/wiki/agents)**.\nsee https://example.com/x, plus <b>not markup</b>\n\n- `code https://not.a.link`\n- [x](javascript:alert(1))\n\n```\nhttps://in.code\n```");456 const [p, list, pre] = body.children;457 assert.equal(p.localName, "p");458 assert.equal(p.querySelector("strong a").href, "https://012.run/wiki/agents");459 assert.equal(p.querySelectorAll("br").length, 1);460 assert.equal(p.querySelectorAll("a").length, 2);461 assert.equal(p.textContent, "It works: the page.see https://example.com/x, plus <b>not markup</b>");462 assert.equal(list.localName, "ul");463 assert.equal(list.children[0].querySelector("code").textContent, "code https://not.a.link");464 assert.equal(list.children[0].querySelectorAll("a").length, 0);465 assert.equal(list.children[1].textContent, "[x](javascript:alert(1))");466 assert.equal(list.children[1].querySelectorAll("a").length, 0);467 assert.equal(pre.localName, "pre");468 assert.equal(pre.querySelector("code").textContent, "https://in.code");469 assert.equal(pre.querySelectorAll("a").length, 0);470});471472test("room attachment renderer matches visible imeta URLs and preserves prose and code", () => {473 const s = setup(), url = "https://cdn.example.test/report.pdf";474 const event = {content: "See [report](" + url + ") and `" + url + "`", tags: [["imeta", "url " + url, "m application/pdf", "filename report.pdf"]]};475 const items = s.rooms.roomAttachments(event);476 assert.equal(items.length, 1);477 assert.equal(s.rooms.attachmentContent(event.content, items), event.content);478 assert.equal(s.rooms.roomAttachments({content: "```\n" + url + "\n```", tags: event.tags}).length, 0);479 assert.equal(s.rooms.roomAttachments({content: url + "-backup", tags: event.tags}).length, 0);480 assert.equal(s.rooms.roomAttachments({content: url, tags: [["imeta", "url " + url, "m image/png"], ["imeta", "url " + url, "m image/png"]]}).length, 1);481});482483test("room attachment renderer replaces edited media without stale nodes", () => {484 const s = setup(), id = "a".repeat(64), author = "b".repeat(64), oldURL = "https://cdn.example.test/old.png", newURL = "https://cdn.example.test/new.mp4";485 const node = s.rooms.messageNode({id, pubkey: author, kind: 9, created_at: 1, content: oldURL, tags: [["imeta", "url " + oldURL, "m image/png"]]});486 s.list.append(node);487 s.rooms.roomEdit({id: "c".repeat(64), pubkey: author, kind: 40003, created_at: 3, content: newURL, tags: [["e", id], ["imeta", "url " + newURL, "m video/mp4"]]});488 assert.equal(node.querySelectorAll("img").length, 0);489 assert.equal(node.querySelectorAll("video").length, 1);490 s.rooms.roomEdit({pubkey: author, kind: 40003, created_at: 2, content: oldURL, tags: [["e", id], ["imeta", "url " + oldURL, "m image/png"]]});491 assert.equal(node.querySelectorAll("video").length, 1, "older edit must not replace newer media");492 s.rooms.roomEdit({pubkey: author, kind: 40003, created_at: 4, content: "Attachment removed", tags: [["e", id]]});493 assert.equal(node.querySelectorAll("video").length, 0);494 assert.equal(node.querySelectorAll("[data-room-attachments]").length, 0);495});496497test("room rendering follows the shared parity fixture", () => {498 for (const fixture of parity) {499 const s = setup();500 const items = s.rooms.roomAttachments({content: fixture.content, tags: fixture.tags});501 assert.deepEqual(JSON.parse(JSON.stringify(items.map(item => item.url))), fixture.attachments);502 const node = s.rooms.chatMarkdown(new FakeNode("div"), fixture.content);503 assert.equal(node.textContent, fixture.text);504 }505});