| 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-activity.js" , "utf8" ); |
| 7 | const actor = "a" .repeat(64), author = "b" .repeat(64), request = "c" .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 | setAttribute(name, value) { this.attributes[name] = String(value); } |
| 15 | getAttribute(name) { return this.attributes[name] ?? null; } |
| 16 | hasAttribute(name) { return Object.hasOwn(this.attributes, name); } |
| 17 | querySelectorAll() { return []; } |
| 18 | contains(node) { return node === this || this.childNodes.some(child => child instanceof Node && child.contains(node)); } |
| 19 | closest(selector) { return selector === this.localName ? this : this.parent?.closest(selector) || null; } |
| 20 | dispatchEvent(event) { this.listeners?.[event.type]?.(event); return true; } |
| 21 | addEventListener(name, fn) { (this.listeners ||= {})[name] = fn; } |
| 22 | removeEventListener(name) { delete this.listeners?.[name]; } |
| 23 | } |
| 24 | |
| 25 | function setup() { |
| 26 | const document = new Node("document" ); document.hidden = false; document.activeElement = null; |
| 27 | const definitions = {}, events = {}; |
| 28 | document.addEventListener = (name, fn) => (events[name] ||= []).push(fn); |
| 29 | document.removeEventListener = () => {}; |
| 30 | const signer = {getPublicKey: async () => actor}; |
| 31 | const published = []; |
| 32 | const tiny = {signer: () => signer, localPath: path => path, signing: {publish: async event => { published.push(event); return event; }}, |
| 33 | ui: {FormElement: class extends Node { report(message) { this.message = message; } connectedCallback() { this.form = {}; } }}}; |
| 34 | const template = new Node("template" ); |
| 35 | document.createElement = tag => tag === "template" ? template : new Node(tag); |
| 36 | const sandbox = {document, HTMLElement: Node, customElements: {define: (name, ctor) => definitions[name] = ctor}, tiny, |
| 37 | location: {href: "https://relay.test/rooms/room" , origin: "https://relay.test" }, URL, AbortController, setInterval: () => 1, |
| 38 | clearInterval: () => {}, setTimeout, clearTimeout, queueMicrotask, CustomEvent: class { constructor(type,opts){this.type=type;Object.assign(this,opts);} }, morph: (target,html)=>{target.morphed=html;}, fetch: async () => ({ok: true, text: async () => "" }), Date, JSON, Error}; |
| 39 | vm.runInNewContext(source, sandbox); |
| 40 | return {document, definitions, tiny, signer, published, template}; |
| 41 | } |
| 42 | |
| 43 | test("Fixi refresh preserves a draft focused while the request was in flight" , async () => { |
| 44 | const {definitions, document} = setup(); |
| 45 | const host = new (definitions["chat-activity" ])(); host.parent = document; |
| 46 | const original = new Node("textarea" ); host.append(original); host.connectedCallback(); |
| 47 | const cfg={trigger:{target:host},headers:{},abort(){},response:{ok:true},target:new Node(),text:"new cards" }; |
| 48 | host.onConfig({target:host,detail:{cfg}}); |
| 49 | assert.equal(cfg.cache,"no-store" );assert.equal(cfg.credentials,"same-origin" );assert.equal(cfg.transition,false); |
| 50 | document.activeElement=original;cfg.swap();assert.equal(cfg.target.morphed,undefined); |
| 51 | document.activeElement=null;cfg.swap();assert.equal(cfg.target.morphed,"new cards" ); |
| 52 | host.disconnectedCallback(); |
| 53 | }); |
| 54 | |
| 55 | test("Fixi swap preserves details and ignores failed responses and logout" , () => { |
| 56 | const {definitions,document}=setup(),host=new (definitions["chat-activity" ])();host.parent=document; |
| 57 | const open={dataset:{task:"first" }},first={dataset:{task:"first" }},second={dataset:{task:"second" }}; |
| 58 | host.querySelectorAll=()=>[open]; |
| 59 | const target={querySelectorAll:selector=>selector.startsWith("details" )?[first,second]:[]}; |
| 60 | const cfg={target,text:"cards" ,response:{ok:false}};host.swap(cfg);assert.equal(target.morphed,undefined); |
| 61 | cfg.response.ok=true;host.swap(cfg);assert.equal(first.open,true);assert.equal(second.open,false); |
| 62 | host.stopped=true;cfg.text="logout" ;host.swap(cfg);assert.equal(target.morphed,"cards" ); |
| 63 | }); |
| 64 | |
| 65 | test("decision revalidates the current card before signing and emits a NIP-22 answer" , async () => { |
| 66 | const {definitions, document, template, published} = setup(); |
| 67 | const Decision = definitions["chat-decision" ], host = new (definitions["chat-activity" ])(); host.localName = "chat-activity" ; host.parent = document; |
| 68 | host.read = async () => "fresh" ; host.schedule = () => { host.scheduled = true; }; |
| 69 | const allowed = new Node("chat-decision" ); allowed.setAttribute("event" , request); allowed.setAttribute("actor" , actor); allowed.setAttribute("pubkey" , author); |
| 70 | template.content = {querySelectorAll: selector => selector === "chat-decision" ? [allowed] : []}; |
| 71 | const decision = new Decision(); decision.localName = "chat-decision" ; decision.parent = host; |
| 72 | for (const [name, value] of [["event" , request], ["actor" , actor], ["pubkey" , author], ["room" , "room" ], ["kind" , "5000" ], ["question" , "" ]]) decision.setAttribute(name, value); |
| 73 | decision.submitter = {value: "?" }; |
| 74 | const form = {elements: {content: {value: " answer " }}}; |
| 75 | await decision.submit(form); |
| 76 | assert.equal(published.length, 1); |
| 77 | assert.equal(published[0].kind, 1111); |
| 78 | assert.deepEqual(JSON.parse(JSON.stringify(published[0].tags)), [["h" , "room" ], ["E" , request, "" , author], ["K" , "5000" ], ["P" , author], ["e" , request, "" , author], ["k" , "5000" ], ["p" , author]]); |
| 79 | assert.equal(published[0].content, "answer" ); |
| 80 | }); |
| 81 | |
| 82 | test("decision refuses a card that disappeared while it was being answered" , async () => { |
| 83 | const {definitions, document, template} = setup(); |
| 84 | const Decision = definitions["chat-decision" ], host = new (definitions["chat-activity" ])(); host.localName = "chat-activity" ; host.parent = document; |
| 85 | host.read = async () => "gone" ; host.schedule = () => { host.scheduled = true; }; |
| 86 | template.content = {querySelectorAll: () => []}; |
| 87 | const decision = new Decision(); decision.localName = "chat-decision" ; decision.parent = host; |
| 88 | for (const [name, value] of [["event" , request], ["actor" , actor], ["pubkey" , author], ["room" , "room" ], ["kind" , "5000" ]]) decision.setAttribute(name, value); |
| 89 | decision.submitter = {value: "+" }; |
| 90 | await assert.rejects(decision.submit({elements: {}}), /no longer waiting/); |
| 91 | assert.equal(host.scheduled, true); |
| 92 | }); |