clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

feat/tinyagent
tinyrelay / scripts / chat-presence.test.mjs
chat-presence.test.mjs 13.4K
1import test from "node:test";2import assert from "node:assert/strict";3import fs from "node:fs";4import vm from "node:vm";56const source = fs.readFileSync("tinyclient/chat-presence.js", "utf8");7const key = "a".repeat(64), other = "b".repeat(64);89class Node {10 constructor(tag = "div") { this.localName = tag; this.childNodes = []; this.attributes = {}; this.parent = null; }11 get isConnected() { return Boolean(this.parent); }12 append(...items) { for (const item of items) { if (item instanceof Node) { item.parent = this; } this.childNodes.push(item); } }13 replaceChildren(...items) { this.childNodes = []; this.append(...items); }14 get textContent() { return this.childNodes.map(item => item instanceof Node ? item.textContent : String(item)).join(""); }15 set textContent(value) { this.childNodes = value ? [String(value)] : []; }16 setAttribute(name, value) { this.attributes[name] = String(value); }17 getAttribute(name) { return this.attributes[name] ?? null; }18 querySelector(selector) { return this.childNodes.find(item => item.localName === selector) || null; }19 get children() { return this.childNodes.filter(item => item instanceof Node); }20 remove() { if(this.parent) this.parent.childNodes = this.parent.childNodes.filter(item=>item!==this); this.parent=null; }21 insertBefore(item,before) { item.remove(); const index=before ? this.childNodes.indexOf(before) : this.childNodes.length; this.childNodes.splice(index,0,item); item.parent=this; }22 addEventListener(name, fn) { (this.listeners ||= {})[name] = fn; }23 removeEventListener(name) { delete this.listeners?.[name]; }24 matches(selector) { return selector === this.localName; }25 appendChild(item) { this.append(item); return item; }26}2728function setup(options={}) {29 const document = new Node("document");30 document.hidden = false;31 document.createElement = tag => new Node(tag);32 document.addEventListener = (name, fn) => (document.listeners ||= {})[name] = fn;33 document.removeEventListener = name => delete document.listeners?.[name];34 const definitions = {}, saved = [], dispatched = [];35 document.dispatchEvent = event => dispatched.push(event);36 const tiny = {signedFetch: async (path,method,body,settings) => {saved.push({path,method,body,settings}); if(options.saveError) throw Error(options.saveError); return {json:async()=>JSON.parse(body)};},localPath:path=>path,signer: () => signer, signing: {publish: async event => { if (options.publishGate) await options.publishGate.promise; if (options.publishError) { const error = options.publishError; options.publishError = null; throw Error(error); } published.push(event); }}};37 const published = [];38 const signer = {getPublicKey: async () => key, signEvent: async () => ({})};39 class HTMLElement extends Node {}40 const sandbox = {document, HTMLElement, customElements: {define: (name, ctor) => definitions[name] = ctor}, tiny,41 AbortController,CustomEvent:class {constructor(type,options){this.type=type;this.detail=options.detail;}},fetch:options.fetch || (async()=>({ok:true,json:async()=>({share_presence:options.share === true})})),setInterval: () => 1, clearInterval: () => {}, setTimeout, clearTimeout, Date, JSON, String, Number, Boolean, Set, Map, Object};42 vm.runInNewContext(source, sandbox);43 return {document, definitions, tiny, published, signer, saved, dispatched};44}4546const event = (kind, pubkey, created_at, tags, content = "online") => ({kind, pubkey, created_at, tags, content});4748test("presence parser enforces room scope, TTL and typing precedence", () => {49 const {tiny} = setup();50 const {parse, PRESENCE, TYPING} = tiny.roomPresence;51 assert.equal(parse(event(PRESENCE, other, 100, [["h", "other"]]), "room", 100), null);52 assert.equal(parse(event(TYPING, other, 100, [], "typing"), "room", 100), null);53 assert.equal(parse(event(PRESENCE, other, 100, [["h", "room"]]), "room", 281), null);54 assert.equal(parse(event(TYPING, other, 100, [["h", "room"]], "typing"), "room", 106), null);55 assert.equal(parse(event(PRESENCE, other, 100, [], '{"status":"away"}'), "room", 100).status, "away");56 assert.equal(parse(event(TYPING, other, 100, [["h", "room"]], '{"typing":false}'), "room", 100).status, "stopped");57});5859test("presence ignores stale events and keeps typing ahead of presence", () => {60 const {definitions, document} = setup();61 const Presence = definitions["chat-presence"];62 const host = new Presence(); host.setAttribute("room", "room"); host.setAttribute("actor", key);63 const output = new Node("output"); host.append(output); host.parent = document;64 host.connectedCallback();65 const now = Math.floor(Date.now() / 1000);66 host.receive(event(20001, other, now, [["h", "room"]], "away"));67 host.receive(event(20001, other, now - 1, [["h", "room"]], "online"));68 assert.match(output.textContent, /away/);69 host.receive(event(20002, other, now, [["h", "room"]], "typing"));70 assert.match(output.textContent, /typing/);71 assert.doesNotMatch(output.textContent, /away/);72 host.disconnectedCallback();73});7475test("presence publishing is opt in and always includes the room tag", async () => {76 const {definitions, document, published} = setup();77 const Presence = definitions["chat-presence"];78 const host = new Presence(); host.setAttribute("room", "room"); host.setAttribute("actor", key);79 const output = new Node("output");80 host.append(output); host.parent = document; host.connectedCallback();81 await host.publish(20001, "online");82 assert.equal(published.length, 0);83 await new Promise(resolve=>setTimeout(resolve,0));84 host.applyPreference(true);85 await host.publish(20001, "online");86 assert.deepEqual(JSON.parse(JSON.stringify(published[0].tags)), [["h", "room"]]);87 assert.equal(published[0].kind, 20001);88 host.disconnectedCallback();89});9091test("heartbeat repaint preserves resolved names and each user's status node", () => {92 const {definitions, document} = setup();93 const host = new definitions["chat-presence"](); host.setAttribute("room", "room"); host.setAttribute("actor", key);94 const output = new Node("output"); host.append(output); host.parent = document; host.connectedCallback();95 const now = Math.floor(Date.now()/1000), third = "c".repeat(64);96 host.receive(event(20001, other, now, [["h","room"]]));97 const row = output.childNodes[0], name = row.querySelector?.("nostr-name") || row;98 name.textContent = "Alice";99 for(let i=0;i<5;i++) host.paint();100 assert.equal(output.childNodes[0], row, "unchanged heartbeat replaced the name element");101 assert.match(output.textContent, /Alice/);102 host.receive(event(20001, third, now, [["h","room"]], "working"));103 host.receive(event(20002, third, now, [["h","room"]], "typing"));104 assert.equal(output.childNodes[0],row,"another user's update replaced Alice");105 assert.match(output.textContent,/Alice/);106 host.receive(event(20001,other,now+1,[["h","room"]],"offline"));107 host.receive(event(20001,other,now,[["h","room"]],"online"));108 assert.doesNotMatch(output.textContent,/Alice/,"late heartbeat resurrected an offline user");109 host.disconnectedCallback();110});111112113test("saved account preference enables chat without a chat control, and respects signer identity", async () => {114 const {definitions,document,published,tiny} = setup({share:true});115 const make = () => { const host=new definitions["chat-presence"]();host.setAttribute("room","room");host.setAttribute("actor",key);host.append(new Node("output"));host.parent=document;host.connectedCallback();return host; };116 const host=make(); await new Promise(resolve=>setTimeout(resolve,0));117 assert.equal(host.querySelector("input"),null);118 assert.equal(published.length,1); assert.equal(published[0].kind,20001);119 host.disconnectedCallback(); await new Promise(resolve=>setTimeout(resolve,0));120 published.length=0; tiny.signer=()=>({signEvent:async()=>({}),getPublicKey:async()=>other});121 const otherAccount=make(); await new Promise(resolve=>setTimeout(resolve,0));122 assert.equal(published.length,0); otherAccount.disconnectedCallback();123});124125test("signer recovery clears a transient presence failure without a reload", async () => {126 const options = {share:true, publishError:"Signer connection changed. Retry when connected."};127 const {definitions, document, published} = setup(options);128 const host = new definitions["chat-presence"](); host.setAttribute("room","room"); host.setAttribute("actor",key);129 host.append(new Node("output")); host.parent = document; host.connectedCallback();130 await new Promise(resolve => setTimeout(resolve, 0));131 assert.match(host.status.textContent, /Presence could not be shared/);132 assert.equal(host.failed, true);133 document.listeners["tiny:signer-status"]({detail:{status:"ready"}});134 await new Promise(resolve => setTimeout(resolve, 0));135 assert.equal(host.failed, false);136 assert.equal(host.status.textContent, "");137 await host.publish(20001, "online");138 assert.equal(published.length, 1);139 host.disconnectedCallback();140});141142test("a presence failure from before signer recovery cannot restore the warning", async () => {143 let resolve, reject;144 const options = {share:false, publishGate:{promise:new Promise((done, fail) => { resolve = done; reject = fail; })}};145 const {definitions, document} = setup(options);146 const host = new definitions["chat-presence"](); host.setAttribute("room","room"); host.setAttribute("actor",key);147 host.append(new Node("output")); host.parent = document; host.connectedCallback(); host.enabled = true;148 const pending = host.publish(20001, "online");149 document.listeners["tiny:signer-status"]({detail:{status:"ready"}});150 reject(Error("Signer connection changed. Retry when connected."));151 await pending;152 assert.notEqual(host.failed, true);153 assert.equal(host.status.textContent, "");154 resolve();155 host.disconnectedCallback();156});157158test("a failed hidden cleanup announcement does not create a signer warning", async () => {159 const {definitions, document} = setup({publishError:"Signer connection changed. Retry when connected."});160 const host = new definitions["chat-presence"](); host.setAttribute("room","room"); host.setAttribute("actor",key);161 host.append(new Node("output")); host.parent = document; host.connectedCallback();162 host.last[20001] = 1; document.hidden = true;163 await host.publish(20001, "offline", true);164 assert.notEqual(host.failed, true);165 assert.equal(host.status.textContent, "");166 host.disconnectedCallback();167});168169test("a ready signer event queues preference reload after an in-flight request", async () => {170 let resolveFirst, calls = 0;171 const first = new Promise(resolve => { resolveFirst = resolve; });172 const {definitions, document} = setup({fetch: async () => ++calls === 1 ? first : {ok:true,json:async()=>({share_presence:true})}});173 const host = new definitions["chat-presence"](); host.setAttribute("room","room"); host.setAttribute("actor",key);174 host.append(new Node("output")); host.parent = document; host.connectedCallback();175 document.listeners["tiny:signer-status"]({detail:{status:"ready"}});176 document.listeners["tiny:signer"]({detail:{}});177 assert.equal(host.preferenceReload, true);178 resolveFirst({ok:true,json:async()=>({share_presence:true})});179 await new Promise(resolve => setTimeout(resolve, 0));180 await new Promise(resolve => setTimeout(resolve, 0));181 assert.equal(calls, 2);182 assert.equal(host.enabled, true);183 host.disconnectedCallback();184});185186test("a failed visible opt-out announcement does not create a signer warning", async () => {187 const {definitions, document} = setup({publishError:"Signer connection changed. Retry when connected."});188 const host = new definitions["chat-presence"](); host.setAttribute("room","room"); host.setAttribute("actor",key);189 host.append(new Node("output")); host.parent = document; host.connectedCallback();190 host.last[20001] = 1; await host.publish(20001, "offline", true);191 assert.notEqual(host.failed, true);192 assert.equal(host.status.textContent, "");193 host.disconnectedCallback();194});195196test("account preference saves with a signed JSON request and restores checkbox after failure", async () => {197 const options={}; const {definitions,document,saved,dispatched}=setup(options);198 const host=new definitions["chat-preferences"]();host.setAttribute("actor",key);host.parent=document;199 const input=new Node("input"), output=new Node("output");host.append(input,output);host.connectedCallback();200 await new Promise(resolve=>setTimeout(resolve,0));201 assert.equal(input.checked,false);assert.equal(input.disabled,false);202 input.checked=true;await host.save();203 assert.equal(saved.length,1);assert.equal(saved[0].path,"/chat/preferences");assert.equal(saved[0].method,"POST");204 assert.deepEqual(JSON.parse(saved[0].body),{share_presence:true});assert.equal(saved[0].settings.contentType,"application/json");205 assert.equal(dispatched[0].detail.actor,key);assert.equal(dispatched[0].detail.share_presence,true);206 options.saveError="Signing refused";input.checked=false;await host.save();207 assert.equal(input.checked,true);assert.match(output.textContent,/Signing refused/);208 host.disconnectedCallback();209});210211test("a preference response arriving after logout cannot enable presence", async () => {212 let resolve;const {definitions,document,published}=setup({fetch:()=>new Promise(done=>{resolve=done;})});213 const host=new definitions["chat-presence"]();host.setAttribute("room","room");host.setAttribute("actor",key);host.append(new Node("output"));host.parent=document;host.connectedCallback();214 host.onIdentity();resolve({ok:true,json:async()=>({share_presence:true})});215 await new Promise(done=>setTimeout(done,0));216 assert.equal(host.enabled,false);assert.equal(published.length,0);host.disconnectedCallback();217});