1import test from "node:test";2import assert from "node:assert/strict";3import fs from "node:fs";4import vm from "node:vm";5import {webcrypto,createHash} from "node:crypto";67const source=fs.readFileSync("tinyclient/components.js","utf8");8const code=source.slice(source.indexOf(" // Social follows"),source.indexOf(" // Social end."));9const author="a".repeat(64),parentAuthor="b".repeat(64),root="c".repeat(64),parent="d".repeat(64),hint="wss://relay.test";10function setup(attrs={},values={},options={}) {11 const attributes={actor:author,relay:hint,...attrs},store=options.store||new Map(),calls=[],signed=[];12 let now=1789000000,fail=options.fail||0;13 class FormElement {getAttribute(n){return attributes[n]??null;}report(message){this.message=message;}}14 class HTMLElement {}15 const fields=Object.fromEntries(Object.entries(values).map(([name,value])=>[name, typeof value === "object" ? value : {value}]));16 const form={elements:fields,reset(){for(const field of Object.values(fields))if("value"in field)field.value="";}};17 const tiny={root:"",localPath:path=>path,sha256hex:async data=>createHash("sha256").update(data).digest("hex"),18 signing:{signEvent:async unsigned=>{signed.push(unsigned);return {...unsigned,id:"e".repeat(64),pubkey:author};}},19 signedFetch:async(path,method,body)=>{calls.push({path,method,body});if(path === "/social/preview") return {ok:true,json:async()=>({html:"<p>Preview</p>"})};if(path.startsWith("/upload")){return {ok:true,json:async()=>({sha256:await tiny.sha256hex(body),size:body.length,url:"https://relay.test/file.jpg"})};}if(fail-->0)return {ok:false,json:async()=>({error:"interrupted"})};return {ok:true,json:async()=>({accepted:true})};},20 navigate:async href=>{tiny.href=href;}21 };22 const ctx={FormElement,HTMLElement,tiny,URL,Uint8Array,WeakMap,crypto:webcrypto,location:{origin:"https://relay.test",pathname:"/social",href:"https://relay.test/social"},localStorage:{getItem:k=>store.get(k)||null,setItem:(k,v)=>store.set(k,v),removeItem:k=>store.delete(k)},isHex64:v=>typeof v==="string"&&/^[0-9a-f]{64}$/.test(v),unixNow:()=>now,el:()=>({setAttribute(){}}),fetch:options.fetch};23 const classes=vm.runInNewContext(code+"\n({SocialCompose,SocialReaction,socialDraftKey})",ctx);24 const component=new classes.SocialCompose();component.form=form;25 return {component,form,calls,signed,tiny,store,attrs:attributes,classes,advance(){now+=90;},tags(){return JSON.parse(JSON.stringify(signed.at(-1).tags));}};26}2728test("notes keep plaintext and topics normalized",async()=>{29 const s=setup({mode:"note"},{content:"**literal**\nTwo lines",tags:"#Nostr, nostr, NEWS"});30 await s.component.submit(s.form);31 assert.equal(s.signed[0].kind,1);assert.equal(s.signed[0].content,"**literal**\nTwo lines");32 assert.deepEqual(s.tags(),[["t","nostr"],["t","news"]]);33});3435test("new articles with identical titles have distinct persistent addresses",async()=>{36 const s=setup({mode:"article"},{title:"Same title",content:"# Markdown",summary:"Subtitle",cover:"https://cdn.test/a.png"});37 const other=setup({mode:"article"},{title:"Same title",content:"Another story"});38 await s.component.submit(s.form);await other.component.submit(other.form);39 const tags=s.tags();assert.notEqual(tags.find(t=>t[0]==="d")[1],other.tags().find(t=>t[0]==="d")[1]);40 assert.equal(s.signed[0].kind,30023);assert.ok(tags.some(t=>t[0]==="title"&&t[1]==="Same title"));assert.ok(tags.some(t=>t[0]==="published_at"));41});4243test("NIP-10 top-level reply uses one root marker and inherited p tags",async()=>{44 const s=setup({mode:"comment",target:root,"target-kind":"1","target-pubkey":author,"target-tags":JSON.stringify([["p",parentAuthor]])},{content:"Reply"});45 await s.component.submit(s.form);46 assert.deepEqual(s.tags(),[["e",root,hint,"root",author],["p",author,hint],["p",parentAuthor,hint]]);47});4849test("NIP-10 nested replies distinguish root and direct parent",async()=>{50 const s=setup({mode:"comment",target:parent,"target-kind":"1","target-pubkey":parentAuthor,root,"root-pubkey":author},{content:"Nested"});51 await s.component.submit(s.form);assert.deepEqual(s.tags(),[["e",root,hint,"root",author],["e",parent,hint,"reply",parentAuthor],["p",author,hint],["p",parentAuthor,hint]]);52});5354test("NIP-22 article comment has address scope and address plus id parent",async()=>{55 const address=`30023:${author}:essay:part-one`;56 const s=setup({mode:"comment",target:root,"target-kind":"30023","target-pubkey":author,address},{content:"Article comment"});57 await s.component.submit(s.form);assert.equal(s.signed[0].kind,1111);58 assert.deepEqual(s.tags(),[["A",address,hint],["K","30023"],["P",author,hint],["a",address,hint],["e",root,hint,author],["k","30023"],["p",author,hint]]);59});6061test("NIP-22 nested comment keeps root scope but direct parent's author and kind",async()=>{62 const address=`30023:${author}:essay`;63 const s=setup({mode:"comment",target:parent,"target-kind":"1111","target-pubkey":parentAuthor,root,"root-pubkey":author,address},{content:"Nested article comment"});64 await s.component.submit(s.form);assert.equal(s.signed[0].kind,1111);65 assert.deepEqual(s.tags(),[["A",address,hint],["K","30023"],["P",author,hint],["e",parent,hint,parentAuthor],["k","1111"],["p",parentAuthor,hint]]);66});6768test("relay failure retains signed event across a later retry",async()=>{69 const s=setup({mode:"note"},{content:"Retry"},{fail:1});70 await assert.rejects(s.component.submit(s.form),/interrupted/);s.advance();await s.component.submit(s.form);71 assert.equal(s.signed.length,1);assert.equal(s.calls.length,2);assert.equal(s.calls[0].body,s.calls[1].body);72});7374test("editing a failed post requires a fresh signature",async()=>{75 const s=setup({mode:"note"},{content:"Before"},{fail:1});76 await assert.rejects(s.component.submit(s.form));s.form.elements.content.value="After";await s.component.submit(s.form);assert.equal(s.signed.length,2);77});7879test("duplicate submits publish only once",async()=>{80 const s=setup({mode:"note"},{content:"One"});await Promise.all([s.component.submit(s.form),s.component.submit(s.form)]);assert.equal(s.signed.length,1);assert.equal(s.calls.length,1);81});8283test("drafts remain scoped to account and article and clear when erased",()=>{84 const s=setup({mode:"article"},{title:"Story",content:"private draft"});s.component.saveDraft();85 const other=setup({mode:"article",actor:parentAuthor},{title:"",content:""},{store:s.store});other.component.restoreDraft();assert.equal(other.form.elements.content.value,"");86 const same=setup({mode:"article"},{title:"",content:""},{store:s.store});same.component.restoreDraft();assert.equal(same.form.elements.content.value,"private draft");87 s.form.elements.title.value="";s.form.elements.content.value="";s.component.saveDraft();assert.equal(s.store.size,0);88});8990test("media is uploaded once on retry and notes embed raw URLs with imeta",async()=>{91 const file={name:"photo.jpg",type:"image/jpeg",size:3,arrayBuffer:async()=>new Uint8Array([1,2,3]).buffer};92 const s=setup({mode:"note"},{content:"Photo",alt:"A mountain", "media-file":{files:[file]}},{fail:1});93 await assert.rejects(s.component.submit(s.form),/interrupted/);await s.component.submit(s.form);94 assert.equal(s.calls.filter(c=>c.path.startsWith("/upload")).length,1);95 assert.equal(s.signed[0].content,"Photo\n\nhttps://relay.test/file.jpg");96 const imeta=s.tags().find(t=>t[0]==="imeta");assert.ok(imeta.includes("m image/jpeg"));assert.ok(imeta.includes("alt A mountain"));assert.ok(imeta.includes("size 3"));97});9899test("unsafe media and cover URLs are rejected before publishing",async()=>{100 for(const values of [{content:"note",media:"javascript:alert(1)"},{content:"note",media:"https://secret@example.com/a"}]){const s=setup({mode:"note"},values);await assert.rejects(s.component.submit(s.form));assert.equal(s.signed.length,0);}101 const s=setup({mode:"article"},{title:"Title",content:"Body",cover:"data:image/svg+xml,bad"});await assert.rejects(s.component.submit(s.form));assert.equal(s.signed.length,0);102});103104105test("draft previews use authenticated requests without publishing", async()=>{106 const s=setup({mode:"article"},{content:"**Preview**"});107 const output={hidden:true,innerHTML:""};s.component.querySelector=()=>output;108 await s.component.preview();109 assert.equal(s.calls[0].path,"/social/preview");assert.equal(s.calls[0].method,"POST");110 assert.deepEqual(JSON.parse(s.calls[0].body),{content:"**Preview**",kind:30023});111 assert.equal(s.signed.length,0);assert.equal(output.hidden,false);assert.equal(output.innerHTML,"<p>Preview</p>");112});113114test("legacy replies omit an unknown optional root author rather than misattribute it", async()=>{115 const s=setup({mode:"comment",target:parent,"target-kind":"1","target-pubkey":parentAuthor,root,"root-pubkey":""},{content:"Legacy reply"});116 await s.component.submit(s.form);117 assert.deepEqual(s.tags(),[["e",root,hint,"root"],["e",parent,hint,"reply",parentAuthor],["p",parentAuthor,hint]]);118});119120test("podcast episodes publish native kind 54 audio and imeta tags", async()=>{121 const file={name:"episode.mp3",type:"audio/mpeg",size:3,arrayBuffer:async()=>new Uint8Array([1,2,3]).buffer};122 const s=setup({mode:"podcast"},{title:"Episode",summary:"Show notes",cover:"https://cdn.test/cover.jpg",content:"Notes", "media-file":{files:[file]}});123 await s.component.submit(s.form);124 assert.equal(s.signed[0].kind,54);assert.equal(s.signed[0].content,"Notes");125 assert.ok(s.tags().some(t=>t[0]==="audio"&&t[1]==="https://relay.test/file.jpg"&&t[2]==="audio/mpeg"));126 assert.ok(s.tags().some(t=>t[0]==="imeta"&&t.includes("m audio/mpeg")));127});128129test("podcast retry reuses the same signed event and keeps audio URLs out of notes", async()=>{130 const s=setup({mode:"podcast"},{title:"Episode",content:"Notes",media:"https://cdn.test/episode.mp3"},{fail:1});131 await assert.rejects(s.component.submit(s.form),/interrupted/);await s.component.submit(s.form);132 assert.equal(s.signed.length,1);assert.equal(s.calls.length,2);assert.equal(s.calls[0].body,s.calls[1].body);assert.equal(s.signed[0].content,"Notes");133});134135test("new podcast shows tolerate null metadata and preserve existing tags and websites", async()=>{136 const tags=[["p",parentAuthor],["website","https://old.example/one"],["website","https://old.example/two"],["custom","keep"]];137 const s=setup({mode:"podcast-show","show-tags":JSON.stringify(tags)},{title:"Series",website:"https://new.example"});138 await s.component.submit(s.form);assert.equal(s.signed[0].kind,10154);139 assert.deepEqual(s.tags(),[["p",parentAuthor],["custom","keep"],["title","Series"],["website","https://new.example/"],["website","https://old.example/two"]]);140 const fresh=setup({mode:"podcast-show","show-tags":"null"},{title:"Fresh"});await fresh.component.submit(fresh.form);assert.equal(fresh.signed[0].kind,10154);assert.ok(!fresh.tags().some(t=>t[0]==="d"));141});142143test("podcast requires audio and rejects non-audio attachments", async()=>{144 const missing=setup({mode:"podcast"},{title:"Episode",content:"Notes"});await assert.rejects(missing.component.submit(missing.form),/audio track/);assert.equal(missing.signed.length,0);145 const image=setup({mode:"podcast"},{title:"Episode",content:"Notes",media:"https://cdn.test/image.jpg","media-type":"image/jpeg"});await assert.rejects(image.component.submit(image.form),/audio track/);assert.equal(image.signed.length,0);146});147148test("direct and nested podcast replies use NIP-22 root and parent tags", async()=>{149 const direct=setup({mode:"comment",target:root,"target-kind":"54","target-pubkey":author},{content:"Reply"});await direct.component.submit(direct.form);150 assert.equal(direct.signed[0].kind,1111);assert.deepEqual(direct.tags(),[["E",root,hint,author],["K","54"],["P",author,hint],["e",root,hint,author],["k","54"],["p",author,hint]]);151 const nested=setup({mode:"comment",target:parent,"target-kind":"1111","target-pubkey":parentAuthor,root,"root-kind":"54","root-pubkey":author},{content:"Nested"});await nested.component.submit(nested.form);152 assert.deepEqual(nested.tags(),[["E",root,hint,author],["K","54"],["P",author,hint],["e",parent,hint,parentAuthor],["k","1111"],["p",parentAuthor,hint]]);153});