tinyrelayfile
tinyrelay/scripts/chat-presence.test.mjs/

scripts/chat-presence.test.mjs raw
1import test from "node:test";
2import assert from "node:assert/strict";
3import fs from "node:fs";
4import vm from "node:vm";
5
6const source = fs.readFileSync("tinyclient/chat-presence.js", "utf8");
7const key = "a".repeat(64), other = "b".repeat(64);
8
9class 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
28function 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
46const event = (kind, pubkey, created_at, tags, content = "online") => ({kind, pubkey, created_at, tags, content});
47
48test("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
59test("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
75test("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
91test("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
113test("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
125test("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
142test("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
158test("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
169test("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
186test("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
196test("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
211test("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});

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

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

Refs