| 1 | import test from "node:test" ; |
| 2 | import assert from "node:assert/strict" ; |
| 3 | import fs from "node:fs" ; |
| 4 | import vm from "node:vm" ; |
| 5 | import { finalizeEvent, generateSecretKey, getPublicKey, verifyEvent } from "nostr-tools/pure" ; |
| 6 | import * as nip19 from "nostr-tools/nip19" ; |
| 7 | import { createHash } from "node:crypto" ; |
| 8 | |
| 9 | const roomsSource = fs.readFileSync("tinyclient/rooms.js" , "utf8" ); |
| 10 | const parity = JSON.parse(fs.readFileSync("tinyclient/room_parity.json" , "utf8" )); |
| 11 | |
| 12 | // A small document: enough of the DOM for the room elements to render |
| 13 | // messages, find their targets and keep the list bounded. |
| 14 | export 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 | } |
| 53 | const document = new FakeNode("#document" ); |
| 54 | document.body = new FakeNode("body" ); |
| 55 | document.body.parent = document; |
| 56 | document.documentElement = {scrollHeight: 1000}; |
| 57 | document.createElement = tag => new FakeNode(tag); |
| 58 | document.getElementById = id => document.body.descendants().find(node => node.id === id) || null; |
| 59 | document.querySelector = selector => document.body.querySelector(selector); |
| 60 | document.querySelectorAll = selector => document.body.querySelectorAll(selector); |
| 61 | |
| 62 | export 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 | } |
| 119 | const npub = nip19.npubEncode("d" .repeat(64)); |
| 120 | // Values built inside the sandbox belong to another realm; compare them as plain data. |
| 121 | const same = (actual, expected) => assert.deepEqual(JSON.parse(JSON.stringify(actual)), expected); |
| 122 | |
| 123 | test("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 | }); |
| 144 | |
| 145 | test("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 | }); |
| 158 | |
| 159 | test("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 | }); |
| 168 | |
| 169 | const selectedFile = (name = "chart.png" , type = "image/png" , text = "image bytes" ) => ({name, type, size: Buffer.byteLength(text), arrayBuffer: async () => Uint8Array.from(Buffer.from(text)).buffer}); |
| 170 | const 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 | }; |
| 174 | |
| 175 | test("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 | }); |
| 195 | |
| 196 | test("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); |
| 219 | |
| 220 | 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 | }); |
| 228 | |
| 229 | test("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 | }); |
| 253 | |
| 254 | test("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 | }); |
| 273 | |
| 274 | test("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 | }); |
| 286 | |
| 287 | test("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 | }); |
| 297 | |
| 298 | test("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 | }); |
| 319 | |
| 320 | test("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 | }); |
| 333 | |
| 334 | test("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 | }); |
| 391 | |
| 392 | test("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 | }); |
| 409 | |
| 410 | test("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 | }); |
| 436 | |
| 437 | test("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 | }); |
| 452 | |
| 453 | test("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 | }); |
| 471 | |
| 472 | test("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 | }); |
| 482 | |
| 483 | test("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 | }); |
| 496 | |
| 497 | test("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 | }); |