tinyrelayfile
tinyrelay/scripts/rooms.test.mjs/

scripts/rooms.test.mjs raw
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";
8
9const roomsSource = fs.readFileSync("tinyclient/rooms.js", "utf8");
10const 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.
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);
61
62export 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);
122
123test("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
145test("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
159test("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
169const 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};
174
175test("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
196test("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
229test("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, `![image](${descriptor.url})`);
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
254test("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
274test("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
287test("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
298test("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
320test("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
334test("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
392test("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
410test("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
437test("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
453test("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
472test("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
483test("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
497test("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});

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs