| 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 | |
| 6 | const source = fs.readFileSync("tinyclient/chat-presence.js" , "utf8" ); |
| 7 | const key = "a" .repeat(64), other = "b" .repeat(64); |
| 8 | |
| 9 | class 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 | } |
| 27 | |
| 28 | function 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 | } |
| 45 | |
| 46 | const event = (kind, pubkey, created_at, tags, content = "online" ) => ({kind, pubkey, created_at, tags, content}); |
| 47 | |
| 48 | test("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 | }); |
| 58 | |
| 59 | test("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 | }); |
| 74 | |
| 75 | test("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 | }); |
| 90 | |
| 91 | test("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 | }); |
| 111 | |
| 112 | |
| 113 | test("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 | }); |
| 124 | |
| 125 | test("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 | }); |
| 141 | |
| 142 | test("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 | }); |
| 157 | |
| 158 | test("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 | }); |
| 168 | |
| 169 | test("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 | }); |
| 185 | |
| 186 | test("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 | }); |
| 195 | |
| 196 | test("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 | }); |
| 210 | |
| 211 | test("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 | }); |