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-activity.js", "utf8");7const actor = "a".repeat(64), author = "b".repeat(64), request = "c".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 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}2425function 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}4243test("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});5455test("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});6465test("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});8182test("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});9394function nativeForm(selection="single", interaction="question", freeform=true) {95 const {definitions, document, template, published} = setup();96 const host = new (definitions["chat-activity"])(); host.localName="chat-activity"; host.parent=document;97 host.read=async()=>"fresh"; host.schedule=()=>{};98 const allowed=new Node("chat-decision"), decision=new (definitions["chat-decision"])();99 decision.localName="chat-decision"; decision.parent=host;100 for(const el of [allowed,decision]) {101 for(const [name,value] of [["event",request],["actor",actor],["pubkey",author],["room","room"],["kind","9"],["native",""],["selection",selection],["interaction",interaction]]) el.setAttribute(name,value);102 if(interaction==="question")el.setAttribute("question","");103 if(freeform)el.setAttribute("freeform","");104 if(selection==="multiple")el.setAttribute("multiple","");105 }106 allowed.querySelectorAll=()=>selection==="text"?[]:[{value:"c0"},{value:"c1"}];107 template.content={querySelectorAll:()=>[allowed]};108 return {decision,allowed,published};109}110111for(const [selection,form,expected] of [112 ["single",{option:{value:"c0",checked:true}},"c0"],113 ["multiple",{option:[{value:"c0",checked:true},{value:"c1",checked:true}]},'["c0","c1"]'],114 ["single",{"custom-answer":{value:"custom reply"}},'{"text":"custom reply"}'],115 ["text",{"custom-answer":{value:"plain reply"}},"plain reply"],116 ["single",{option:{value:"c1",checked:true},"custom-answer":{value:" The question is in the wrong thread. "}},'{"choices":["c1"],"text":"The question is in the wrong thread."}'],117 ["multiple",{option:[{value:"c0",checked:true},{value:"c1",checked:true}],"custom-answer":{value:"Both apply."}},'{"choices":["c0","c1"],"text":"Both apply."}'],118]) test(`native ${selection} question submits the selected or custom answer`,async()=>{119 const {decision,published}=nativeForm(selection);120 await decision.submit({elements:form});121 assert.equal(published.length,1); assert.equal(published[0].content,expected); assert.equal(published[0].kind,1111);122});123124test("native approval accepts only an offered option",async()=>{125 const {decision,published}=nativeForm("single","approval",false);126 await assert.rejects(decision.submit({elements:{"custom-answer":{value:"yes"}}}),/options only/);127 await assert.rejects(decision.submit({elements:{option:{value:"always",checked:true}}}),/options changed/);128 await decision.submit({elements:{option:{value:"c0",checked:true}}});129 assert.equal(published[0].content,"c0");130});131132test("native decisions validate freeform and selection against the fresh card",async()=>{133 const {decision,allowed,published}=nativeForm();134 delete allowed.attributes.freeform;135 await assert.rejects(decision.submit({elements:{"custom-answer":{value:"custom"}}}),/options only/);136 await assert.rejects(decision.submit({elements:{option:[{value:"c0",checked:true},{value:"c1",checked:true}]}}),/one option/);137 assert.equal(published.length,0);138});139140test("an explanation cannot bypass the single-choice limit or offered choices",async()=>{141 const {decision,published}=nativeForm();142 const comment={value:"Here is why."};143 await assert.rejects(decision.submit({elements:{option:[{value:"c0",checked:true},{value:"c1",checked:true}],"custom-answer":comment}}),/one option/);144 await assert.rejects(decision.submit({elements:{option:{value:"unknown",checked:true},"custom-answer":comment}}),/options changed/);145 assert.equal(published.length,0);146});147148test("combined answers still require freeform permission on the current question",async()=>{149 for(const interaction of ["question","approval","confirmation"]) {150 const {decision,published}=nativeForm("single",interaction,false);151 await assert.rejects(decision.submit({elements:{option:{value:"c1",checked:true},"custom-answer":{value:"An explanation."}}}),/options only/);152 assert.equal(published.length,0);153 }154});