Creating mono-repo from psf-memo-client

This commit is contained in:
Chris Troutner
2026-08-26 09:10:39 -07:00
commit c168c652de
191 changed files with 49095 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
'use strict'
// A fake profile store that records names set for addresses.
function fakeProfiles () {
const names = new Map()
return {
names,
setName: (addr, name) => names.set(addr, name),
getName: (addr) => names.get(addr) || null
}
}
module.exports = { fakeProfiles }
+34
View File
@@ -0,0 +1,34 @@
'use strict'
// A fake wallet that records every broadcast attempt and can be made to fail.
// It satisfies the small adapter surface the Memo action modules need:
// walletInfo, getUtxos(), sendOpReturn(). Set `wallet.failWith` to make
// sendOpReturn throw.
function fakeWallet ({
cashAddress = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d',
utxos = [{ txid: 'utxo1', value: 100000 }],
txid = 'fake-txid'
} = {}) {
const broadcasts = []
const sends = []
const wallet = {
walletInfo: { cashAddress },
utxos,
getUtxos: async () => utxos,
sendOpReturn: async function (msg, prefix, bchOutput = []) {
broadcasts.push({ msg, prefix, bchOutput })
if (this.failWith) throw new Error(this.failWith)
return txid
},
send: async function (receivers) {
sends.push(receivers)
if (this.failWith) throw new Error(this.failWith)
return txid
}
}
wallet.broadcasts = broadcasts
wallet.sends = sends
return wallet
}
module.exports = { fakeWallet }
+117
View File
@@ -0,0 +1,117 @@
/*
Shared property tests for the Memo post and Set Name behavior slices.
Both slices validate a value against a length limit, expose a character/byte
counter that conserves its relationship to input length, round-trip the draft
text through setInput, and surface broadcast failures without navigating.
These tests assert those invariants across a broad input range; the slice
specifics are supplied through `cfg` so the two behavior slices share one
implementation instead of duplicating it.
*/
'use strict'
const test = require('node:test')
const { forAll, makeStringGen } = require('./harness')
const { fakeWallet } = require('../helpers/fake-wallet')
// Register the property tests shared by a Memo behavior slice. `cfg` supplies
// the slice-specific pieces:
// Module - the action class under test (MemoPost or MemoSetName)
// MAX - the length limit (characters or bytes)
// label - a short label used in test names
// rng - the seeded random generator
// measure - (input) => length in the slice's unit (chars or bytes)
// buildPage - () => a fresh page for counter and round-trip tests
// buildBroadcastPage - ({ wallet, navigations }) => a page wired to broadcast
function registerBehaviorProperties (cfg) {
const { Module, MAX, label, rng, measure, buildPage, buildBroadcastPage } = cfg
const stringOf = makeStringGen(rng)
test(`${label} validation: any non-blank string at or below the limit is valid`, async () => {
await forAll(
(i) => {
const len = 1 + Math.floor(rng() * MAX) // 1..MAX
return stringOf(len)
},
(input) => {
// A random ASCII string may occasionally be all whitespace; whitespace-only
// input is a validation error, so only assert for non-blank strings.
if (input.trim().length === 0) return true
const result = new Module({}).validate(input)
return result.ok === true
},
{ label: 'valid length' }
)
})
test(`${label} validation: any string above the limit is a length error`, async () => {
await forAll(
(i) => stringOf(MAX + 1 + Math.floor(rng() * 100)), // > MAX
(input) => {
const result = new Module({}).validate(input)
return result.ok === false && result.type === 'length'
},
{ label: 'over-long rejected as length' }
)
})
test(`${label} validation: blank and non-string input are validation errors`, async () => {
await forAll(
(i) => (i % 2 === 0 ? ' ' : null),
(input) => {
const result = new Module({}).validate(input)
return result.ok === false && result.type === 'validation'
},
{ label: 'blank/non-string rejected as validation' }
)
})
test(`${label} counter conserves length: remaining === MAX - measure(input)`, async () => {
await forAll(
(i) => stringOf(Math.floor(rng() * (MAX + 8))),
(input) => {
const page = buildPage()
page.setInput(input)
return page.remainingCount() === MAX - measure(input)
},
{ label: 'counter conservation' }
)
})
test('setInput round-trips the draft text exactly', async () => {
await forAll(
(i) => stringOf(Math.floor(rng() * 50)),
(input) => {
const page = buildPage()
page.setInput(input)
return page.input === input
},
{ label: 'setInput round-trip' }
)
})
test('a broadcast failure surfaces the error and never navigates', async () => {
await forAll(
(i) => ({ text: stringOf(1 + Math.floor(rng() * 40)), failWith: `boom-${i % 97}` }),
({ text, failWith }) => {
const wallet = fakeWallet()
wallet.failWith = failWith
const navigations = []
const page = buildBroadcastPage({ wallet, navigations })
page.setInput(text)
return page.submit().then((result) => {
if (result.ok) return false
if (page.submitError !== 'broadcast') return false
if (!page.broadcastError || !page.broadcastError.includes('boom')) return false
return navigations.length === 0
})
},
{ label: 'broadcast failure does not navigate' }
)
})
}
module.exports = { registerBehaviorProperties }
+48
View File
@@ -0,0 +1,48 @@
/*
Small property-testing harness for psf-memo-client.
Node's built-in test runner has no property-based generator, so this module
provides a tiny deterministic, seeded pseudo-random generator plus a helper
to run a property across many samples and report a counterexample. All
generation is seeded, so runs are reproducible.
*/
'use strict'
const assert = require('node:assert/strict')
// A small deterministic PRNG (mulberry32). Same seed => same stream.
function seededRandom (seed = 12345) {
let a = seed >>> 0
return function next () {
a |= 0
a = (a + 0x6d2b79f5) | 0
let t = Math.imul(a ^ (a >>> 15), 1 | a)
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
}
// Run a property across N samples. `gen` returns a fresh input; `check`
// returns true when the property holds. Asserts a counterexample on failure.
async function forAll (gen, check, { samples = 500, label = 'property' } = {}) {
for (let i = 0; i < samples; i++) {
const input = gen(i)
const ok = await check(input)
assert.ok(ok, `${label} failed at sample ${i} for input: ${JSON.stringify(input)}`)
}
}
// Generate a random ASCII string of a given length using a seeded RNG.
function makeStringGen (rng) {
return (length) => {
const chars = []
for (let i = 0; i < length; i++) {
// Mix of printable ASCII (32..126).
chars.push(String.fromCharCode(32 + Math.floor(rng() * 95)))
}
return chars.join('')
}
}
module.exports = { seededRandom, forAll, makeStringGen }
+116
View File
@@ -0,0 +1,116 @@
/*
Property tests for the Memo like / tip behavior slices.
The like/tip slice centers on a post txid encoded as a 64-character hex
string. These tests assert useful invariants across a broad input range that
unit tests cover only at a few fixed points:
- hexToBytes round-trips any valid hex txid back to its canonical string.
- hexToBytes rejects any string that is not a valid 64-char hex txid.
- MemoLike.validate accepts any valid 64-char hex txid and rejects others.
- getSpendableSats conserves the sum of every spendable utxo value.
*/
'use strict'
const test = require('node:test')
const { forAll, seededRandom } = require('./harness')
const { fakeWallet } = require('../helpers/fake-wallet')
const MemoLike = require('../../src/services/memo-like')
const { hexToBytes } = require('../../src/services/hex')
const rng = seededRandom(20260720)
// Build a random lowercase-hex string of the given byte length.
function hexString (bytes) {
const out = []
for (let i = 0; i < bytes; i++) {
out.push(Math.floor(rng() * 256).toString(16).padStart(2, '0'))
}
return out.join('')
}
test('hexToBytes round-trips a valid hex txid back to its canonical string', async () => {
await forAll(
() => hexString(32),
(hex) => Buffer.from(hexToBytes(hex, 32)).toString('hex') === hex,
{ label: 'hexToBytes round-trip' }
)
})
test('hexToBytes rejects any string that is not a 64-character hex txid', async () => {
await forAll(
(i) => {
const len = 1 + Math.floor(rng() * 100)
if (len !== 64) return 'z'.repeat(len)
// Exactly 64 chars but containing a non-hex character.
return `z${'a'.repeat(63)}`
},
(input) => {
try {
hexToBytes(input, 32)
return false
} catch (err) {
return err instanceof Error
}
},
{ label: 'hexToBytes rejects invalid' }
)
})
test('MemoLike.validate accepts any valid 64-character hex post txid', async () => {
await forAll(
() => hexString(32),
(txid) => {
const result = new MemoLike({}).validate(txid)
return result.ok === true
},
{ label: 'valid txid accepted' }
)
})
test('MemoLike.validate rejects a non-hex post txid with like_validation', async () => {
await forAll(
() => {
// 64 chars drawn from g..z, guaranteed to be non-hex.
const chars = []
for (let i = 0; i < 64; i++) {
chars.push(String.fromCharCode(103 + Math.floor(rng() * 20)))
}
return chars.join('')
},
(txid) => {
try {
new MemoLike({}).validate(txid)
return false
} catch (err) {
return err.code === 'like_validation'
}
},
{ label: 'invalid txid rejected as like_validation' }
)
})
test('getSpendableSats conserves the sum of every spendable utxo value', async () => {
await forAll(
(i) => {
const fields = ['value', 'satoshis', 'amount']
const count = 1 + Math.floor(rng() * 8)
const utxos = []
for (let k = 0; k < count; k++) {
utxos.push({ [fields[k % 3]]: Math.floor(rng() * 1000000) })
}
return utxos
},
(utxos) => {
const wallet = fakeWallet({ utxos })
const expected = utxos.reduce(
(sum, u) => sum + (u.value ?? u.satoshis ?? u.amount ?? 0),
0
)
return new MemoLike({ wallet }).getSpendableSats() === expected
},
{ label: 'spendable sum conservation' }
)
})
+67
View File
@@ -0,0 +1,67 @@
/*
Property tests for the Memo post / New Post behavior slices.
These assert useful invariants that unit tests cover only at a few fixed
points. The validation, counter-conservation, setInput round-trip, and
broadcast-failure invariants are shared with the Set Name slice and live in
behavior-helpers.js; this file supplies the Memo-post specifics and the
menu-link idempotence property unique to the New Post page.
*/
'use strict'
const test = require('node:test')
const { seededRandom, forAll } = require('./harness')
const { registerBehaviorProperties } = require('./behavior-helpers')
const MemoPost = require('../../src/services/memo-post')
const NewPostPage = require('../../src/services/new-post')
const MAX = MemoPost.MAX_MEMO_CHARS // 217
const rng = seededRandom(20260826)
function buildPage () {
return new NewPostPage({
memoPost: new MemoPost({}),
navigate: () => {},
menuLinks: []
})
}
function fakeFeed () {
const posts = []
return { posts, addPost: (p) => posts.push(p) }
}
function buildBroadcastPage ({ wallet, navigations }) {
return new NewPostPage({
memoPost: new MemoPost({ wallet, feed: fakeFeed() }),
navigate: (p) => navigations.push(p)
})
}
registerBehaviorProperties({
Module: MemoPost,
MAX,
label: 'memo',
rng,
measure: (input) => input.length,
buildPage,
buildBroadcastPage
})
test('menu link registration is idempotent', async () => {
await forAll(
(i) => `/posts/${i}`,
(path) => {
const page = buildPage()
page.addMenuLink(path)
page.addMenuLink(path)
page.addMenuLink(path)
return page.menuLinks.filter((p) => p === path).length === 1
},
{ label: 'menu link idempotence' }
)
})
+45
View File
@@ -0,0 +1,45 @@
/*
Property tests for the Set Name behavior slice.
These assert useful invariants that unit tests cover only at a few fixed
points. The validation, counter-conservation, setInput round-trip, and
broadcast-failure invariants are shared with the Memo post slice and live in
behavior-helpers.js; this file supplies the Set Name specifics.
*/
'use strict'
const { seededRandom } = require('./harness')
const { registerBehaviorProperties } = require('./behavior-helpers')
const { fakeProfiles } = require('../helpers/fake-profiles')
const MemoSetName = require('../../src/services/memo-set-name')
const SetNamePage = require('../../src/services/set-name-page')
const MAX = MemoSetName.MAX_NAME_BYTES // 77
const rng = seededRandom(20260827)
function buildPage () {
return new SetNamePage({
memoSetName: new MemoSetName({}),
navigate: () => {}
})
}
function buildBroadcastPage ({ wallet, navigations }) {
return new SetNamePage({
memoSetName: new MemoSetName({ wallet, profiles: fakeProfiles() }),
navigate: (p) => navigations.push(p)
})
}
registerBehaviorProperties({
Module: MemoSetName,
MAX,
label: 'set-name',
rng,
measure: (input) => Buffer.byteLength(input, 'utf8'),
buildPage,
buildBroadcastPage
})
+285
View File
@@ -0,0 +1,285 @@
(ns swarmforge.handoff-test
(:require [babashka.fs :as fs]
[clojure.java.shell :as sh]
[clojure.string :as str]
[clojure.test :refer [deftest is run-tests testing use-fixtures]]))
(def repo-root (fs/cwd))
(def scripts-dir (fs/path repo-root "swarmforge" "scripts"))
(def temp-dirs (atom []))
(use-fixtures :once
(fn [tests]
(try
(tests)
(finally
(doseq [dir @temp-dirs]
(fs/delete-tree dir))))))
(defn script [name]
(str (fs/path scripts-dir name)))
(defn tmp-dir []
(let [dir (fs/create-temp-dir {:prefix "swarmforge-handoff-test."})]
(swap! temp-dirs conj dir)
dir))
(defn run
[{:keys [dir env ok?]} & args]
(let [result (apply sh/sh (concat args [:dir (str dir)
:env (merge {"PATH" (System/getenv "PATH")
"GIT_CONFIG_NOSYSTEM" "1"}
env)]))]
(when (and (not (false? ok?)) (not= 0 (:exit result)))
(throw (ex-info (str "Command failed: " (str/join " " args))
(assoc result :args args))))
result))
(defn write-file [path text]
(fs/create-dirs (fs/parent path))
(spit (str path) text))
(defn read-file [path]
(slurp (str path)))
(defn init-repo! [root]
(run {:dir root} "git" "init" "-q")
(run {:dir root} "git" "config" "user.email" "test@example.com")
(run {:dir root} "git" "config" "user.name" "Test User")
(write-file (fs/path root "README.md") "initial\n")
(run {:dir root} "git" "add" "README.md")
(run {:dir root} "git" "commit" "-q" "-m" "Initial commit")
(str/trim (:out (run {:dir root} "git" "rev-parse" "--short=10" "HEAD"))))
(defn setup-project!
([root] (setup-project! root {"sender" "task" "receiver" "task"}))
([root roles]
(doseq [dir [".swarmforge/handoffs/outbox/tmp"
".swarmforge/handoffs/sent"
".swarmforge/handoffs/failed"
".swarmforge/handoffs/inbox/new"
".swarmforge/handoffs/inbox/in_process"
".swarmforge/handoffs/inbox/completed"]]
(fs/create-dirs (fs/path root dir)))
(write-file
(fs/path root ".swarmforge/roles.tsv")
(apply str
(for [[role mode] roles]
(format "%s\tmaster\t%s\tsession\t%s\tcodex\t%s\n"
role root (str/capitalize role) mode))))))
(defn handoff
[{:keys [id from to recipient priority type task commit body
enqueued-at dequeued-at completed-at]}]
(str "id: " id "\n"
"from: " from "\n"
"to: " to "\n"
(when recipient (str "recipient: " recipient "\n"))
"priority: " priority "\n"
"type: " type "\n"
(when task (str "task: " task "\n"))
(when commit (str "commit: " commit "\n"))
(when enqueued-at (str "enqueued_at: " enqueued-at "\n"))
(when dequeued-at (str "dequeued_at: " dequeued-at "\n"))
(when completed-at (str "completed_at: " completed-at "\n"))
"\n"
(or body (str "payload for " id)) "\n"))
(defn handoff-path [root state filename]
(fs/path root ".swarmforge" "handoffs" "inbox" state filename))
(defn put-handoff! [root state filename attrs]
(let [path (handoff-path root state filename)]
(write-file path (handoff attrs))
path))
(defn header [path field]
(some->> (str/split-lines (read-file path))
(take-while seq)
(some (fn [line]
(let [prefix (str field ": ")]
(when (str/starts-with? line prefix)
(subs line (count prefix))))))))
(defn make-queued-handoff!
([root filename attrs]
(put-handoff! root "new" filename
(merge {:from "sender"
:to "receiver"
:recipient "receiver"
:priority "50"
:type "git_handoff"
:task "task-one"
:commit "0123456789"
:body "merge_and_process sender 0123456789"}
attrs))))
(deftest swarm-handoff-validates-and-queues-git-handoffs
(let [root (tmp-dir)
commit (init-repo! root)]
(setup-project! root)
(testing "git_handoff requires a task name"
(let [draft (fs/path root "tmp" "missing-task.handoff")]
(write-file draft (format "type: git_handoff\nto: receiver\npriority: 50\ncommit: %s\n" commit))
(let [result (run {:dir root :env {"SWARMFORGE_ROLE" "sender"} :ok? false}
(script "swarm_handoff.sh") (str draft))]
(is (= 2 (:exit result)))
(is (str/includes? (:err result) "Missing required header 'task'"))
(is (fs/exists? draft)))))
(testing "valid git_handoff writes task, canonical commit, and generated payload"
(let [draft (fs/path root "tmp" "valid.handoff")]
(write-file draft (format "type: git_handoff\nto: receiver\npriority: 50\ntask: task-1-cave-setup\ncommit: %s\n" commit))
(let [result (run {:dir root :env {"SWARMFORGE_ROLE" "sender"}}
(script "swarm_handoff.sh") (str draft))
queued (-> (:out result) str/trim (str/replace #"^HANDOFF QUEUED: " ""))
content (read-file queued)]
(is (str/includes? content "task: task-1-cave-setup\n"))
(is (str/includes? content (str "commit: " commit "\n")))
(is (str/includes? content (str "merge_and_process sender " commit)))
(is (fs/exists? queued))
(is (not (fs/exists? draft))))))))
(deftest ready-for-next-task-accepts-and-resumes-single-tasks
(let [root (tmp-dir)]
(init-repo! root)
(setup-project! root {"receiver" "task"})
(testing "accepts one queued task and prints task name"
(make-queued-handoff! root "50_20260615T000001Z_000001_from_sender_to_receiver.handoff"
{:id "20260615T000001Z_000001_from_sender"
:task "task-alpha"})
(let [result (run {:dir root :env {"SWARMFORGE_ROLE" "receiver"}}
(script "ready_for_next.sh"))
out (:out result)
in-process (fs/path root ".swarmforge/handoffs/inbox/in_process/50_20260615T000001Z_000001_from_sender_to_receiver.handoff")]
(is (str/includes? out "TASK:"))
(is (str/includes? out "TASK_NAME: task-alpha"))
(is (fs/exists? in-process))
(is (some? (header in-process "dequeued_at")))))
(testing "returns existing in-process task before queued tasks"
(make-queued-handoff! root "40_20260615T000002Z_000002_from_sender_to_receiver.handoff"
{:id "20260615T000002Z_000002_from_sender"
:priority "40"
:task "task-beta"})
(let [result (run {:dir root :env {"SWARMFORGE_ROLE" "receiver"}}
(script "ready_for_next.sh"))]
(is (str/includes? (:out result) "task-alpha"))
(is (fs/exists? (fs/path root ".swarmforge/handoffs/inbox/new/40_20260615T000002Z_000002_from_sender_to_receiver.handoff")))))))
(deftest ready-for-next-batch-groups-equal-priority-handoffs
(let [root (tmp-dir)]
(init-repo! root)
(setup-project! root {"receiver" "batch"})
(make-queued-handoff! root "10_20260615T000001Z_000001_from_sender_to_receiver.handoff"
{:id "20260615T000001Z_000001_from_sender" :priority "10" :task "task-a"})
(make-queued-handoff! root "10_20260615T000002Z_000002_from_sender_to_receiver.handoff"
{:id "20260615T000002Z_000002_from_sender" :priority "10" :task "task-b"})
(make-queued-handoff! root "20_20260615T000003Z_000003_from_sender_to_receiver.handoff"
{:id "20260615T000003Z_000003_from_sender" :priority "20" :task "task-c"})
(let [result (run {:dir root :env {"SWARMFORGE_ROLE" "receiver"}}
(script "ready_for_next.sh"))
out (:out result)
batch-dir (->> (str/split-lines out)
(filter #(str/starts-with? % "BATCH: "))
first
(#(subs % 7)))]
(is (str/includes? out "COUNT: 2"))
(is (str/includes? out "TASK_NAME: task-a"))
(is (str/includes? out "TASK_NAME: task-b"))
(is (not (str/includes? out "TASK_NAME: task-c")))
(is (= 2 (count (fs/glob batch-dir "*.handoff"))))
(is (fs/exists? (fs/path root ".swarmforge/handoffs/inbox/new/20_20260615T000003Z_000003_from_sender_to_receiver.handoff"))))))
(deftest done-with-current-task-completes-and-accepts-next-task
(let [root (tmp-dir)]
(init-repo! root)
(setup-project! root {"receiver" "task"})
(put-handoff! root "in_process" "50_20260615T000001Z_000001_from_sender_to_receiver.handoff"
{:id "20260615T000001Z_000001_from_sender"
:from "sender" :to "receiver" :recipient "receiver"
:priority "50" :type "git_handoff" :task "task-current"
:commit "0123456789"})
(make-queued-handoff! root "50_20260615T000002Z_000002_from_sender_to_receiver.handoff"
{:id "20260615T000002Z_000002_from_sender"
:task "task-next"})
(let [result (run {:dir root :env {"SWARMFORGE_ROLE" "receiver"}}
(script "done_with_current.sh"))
completed (fs/path root ".swarmforge/handoffs/inbox/completed/50_20260615T000001Z_000001_from_sender_to_receiver.handoff")
next-file (fs/path root ".swarmforge/handoffs/inbox/in_process/50_20260615T000002Z_000002_from_sender_to_receiver.handoff")]
(is (str/includes? (:out result) "COMPLETED:"))
(is (str/includes? (:out result) "TASK_NAME: task-next"))
(is (some? (header completed "completed_at")))
(is (some? (header next-file "dequeued_at"))))))
(deftest done-with-current-batch-completes-and-accepts-next-batch
(let [root (tmp-dir)
batch (fs/path root ".swarmforge/handoffs/inbox/in_process/batch_20260615T000001Z_000001")]
(init-repo! root)
(setup-project! root {"receiver" "batch"})
(fs/create-dirs batch)
(write-file (fs/path batch "10_20260615T000001Z_000001_from_sender_to_receiver.handoff")
(handoff {:id "20260615T000001Z_000001_from_sender"
:from "sender" :to "receiver" :recipient "receiver"
:priority "10" :type "git_handoff" :task "task-a"
:commit "0123456789"}))
(write-file (fs/path batch "10_20260615T000002Z_000002_from_sender_to_receiver.handoff")
(handoff {:id "20260615T000002Z_000002_from_sender"
:from "sender" :to "receiver" :recipient "receiver"
:priority "10" :type "git_handoff" :task "task-b"
:commit "0123456789"}))
(make-queued-handoff! root "20_20260615T000003Z_000003_from_sender_to_receiver.handoff"
{:id "20260615T000003Z_000003_from_sender"
:priority "20"
:task "task-c"})
(let [result (run {:dir root :env {"SWARMFORGE_ROLE" "receiver"}}
(script "done_with_current.sh"))
completed-batch (fs/path root ".swarmforge/handoffs/inbox/completed/batch_20260615T000001Z_000001")]
(is (str/includes? (:out result) "COMPLETED_BATCH:"))
(is (str/includes? (:out result) "TASK_NAME: task-c"))
(is (= 2 (count (fs/glob completed-batch "*.handoff"))))
(is (every? #(some? (header % "completed_at"))
(fs/glob completed-batch "*.handoff"))))))
(deftest stop-handoff-daemon-stops-running-process-and-removes-pid-file
(let [root (tmp-dir)]
(init-repo! root)
(fs/create-dirs (fs/path root ".swarmforge/daemon"))
(write-file (fs/path root ".swarmforge/roles.tsv")
(str "coder\tmaster\t" root "\tsession\tCoder\tcodex\ttask\n"))
(write-file (fs/path root ".swarmforge/tmux-socket") "/tmp/fake.sock\n")
(run {:dir root :ok? false}
"sh" "-c"
(str "bb " (script "handoffd.bb") " " root " >/dev/null 2>&1 &"))
(Thread/sleep 1500)
(let [pid-file (fs/path root ".swarmforge/daemon/handoffd.pid")]
(is (fs/exists? pid-file))
(let [pid (str/trim (read-file pid-file))
stop (run {:dir root} (script "stop_handoff_daemon.bb") (str root))]
(is (= 0 (:exit stop)))
(Thread/sleep 300)
(is (not (fs/exists? pid-file)))
(is (not= 0 (:exit (run {:dir root :ok? false} "kill" "-0" pid))))))))
(deftest helpers-refuse-wrong-current-work-shape
(let [root (tmp-dir)
batch (fs/path root ".swarmforge/handoffs/inbox/in_process/batch_20260615T000001Z_000001")]
(init-repo! root)
(setup-project! root {"receiver" "batch"})
(fs/create-dirs batch)
(write-file (fs/path batch "10_20260615T000001Z_000001_from_sender_to_receiver.handoff")
(handoff {:id "20260615T000001Z_000001_from_sender"
:from "sender" :to "receiver" :recipient "receiver"
:priority "10" :type "git_handoff" :task "task-a"
:commit "0123456789"}))
(testing "task helpers refuse an in-process batch"
(let [ready (run {:dir root :env {"SWARMFORGE_ROLE" "receiver"} :ok? false}
(script "ready_for_next_task.sh"))
done (run {:dir root :env {"SWARMFORGE_ROLE" "receiver"} :ok? false}
(script "done_with_current_task.sh"))]
(is (= 2 (:exit ready)))
(is (str/includes? (:err ready) "TASK_IN_PROCESS_IS_BATCH"))
(is (= 2 (:exit done)))
(is (str/includes? (:err done) "CURRENT_WORK_IS_BATCH"))))))
(defn -main [& _]
(let [{:keys [fail error]} (run-tests 'swarmforge.handoff-test)]
(System/exit (+ fail error))))
+350
View File
@@ -0,0 +1,350 @@
(ns swarmforge.script-test
(:require [babashka.fs :as fs]
[clojure.java.shell :as sh]
[clojure.string :as str]
[clojure.test :refer [deftest is testing]]))
(def repo-root (fs/cwd))
(def scripts-dir (fs/path repo-root "swarmforge" "scripts"))
(defn write-file [path text]
(fs/create-dirs (fs/parent path))
(spit (str path) text))
(defn run
[{:keys [dir env ok?]} & args]
(let [result (apply sh/sh (concat args [:dir (str dir)
:env (merge {"PATH" (System/getenv "PATH")
"GIT_CONFIG_NOSYSTEM" "1"}
env)]))]
(when (and (not (false? ok?)) (not= 0 (:exit result)))
(throw (ex-info (str "Command failed: " (str/join " " args))
(assoc result :args args))))
result))
(defn init-repo! [root]
(run {:dir root} "git" "init" "-q")
(run {:dir root} "git" "config" "user.email" "test@example.com")
(run {:dir root} "git" "config" "user.name" "Test User")
(write-file (fs/path root "README.md") "initial\n")
(run {:dir root} "git" "add" "README.md")
(run {:dir root} "git" "commit" "-q" "-m" "Initial commit"))
(defn tmp-dir []
(fs/create-temp-dir {:prefix "swarmforge-script-test."}))
(defn script [name]
(str (fs/path scripts-dir name)))
(deftest handoff-lib-parses-and-prints-handoff-files
(let [root (tmp-dir)
handoff-file (fs/path root "task.handoff")]
(try
(write-file handoff-file
(str "id: 1\n"
"from: coder\n"
"to: cleaner\n"
"priority: 10\n"
"type: git_handoff\n"
"task: task-alpha\n"
"\n"
"merge_and_process coder abcdef1234\n"))
(let [header (run {:dir root} (script "handoff_lib.bb") "header-field" "task.handoff" "task")
body (run {:dir root} (script "handoff_lib.bb") "body" "task.handoff")
task (run {:dir root} (script "handoff_lib.bb") "print-task" "task.handoff")]
(is (str/includes? (:out header) "task-alpha"))
(is (str/includes? (:out body) "merge_and_process coder abcdef1234"))
(is (str/includes? (:out task) "TASK: task.handoff"))
(is (str/includes? (:out task) "FROM: coder"))
(is (str/includes? (:out task) "TASK_NAME: task-alpha")))
(finally
(fs/delete-tree root)))))
(deftest handoff-lib-updates-headers-and-reads-role-state
(let [root (tmp-dir)]
(try
(init-repo! root)
(write-file (fs/path root ".swarmforge/roles.tsv")
(str "coder\tmaster\t" root "\tsession\tCoder\tcodex\ttask\n"
"cleaner\tcleaner\t" root "/.worktrees/cleaner\tsession\tCleaner\tcodex\tbatch\n"))
(write-file (fs/path root ".swarmforge/handoffs/inbox/new/item.handoff")
(str "id: 1\n"
"from: coder\n"
"to: cleaner\n"
"priority: 20\n"
"type: note\n"
"\n"
"payload\n"))
(run {:dir root} (script "handoff_lib.bb") "role-known" "cleaner")
(run {:dir root} (script "handoff_lib.bb") "set-header" ".swarmforge/handoffs/inbox/new/item.handoff" "dequeued_at" "2026-06-16T00:00:00Z")
(let [mode (run {:dir root} (script "handoff_lib.bb") "role-receive-mode" "cleaner")
worktree (run {:dir root} (script "handoff_lib.bb") "role-worktree-name" "cleaner")
dequeued (run {:dir root} (script "handoff_lib.bb") "header-field" ".swarmforge/handoffs/inbox/new/item.handoff" "dequeued_at")
seq-1 (run {:dir root} (script "handoff_lib.bb") "next-sequence")
seq-2 (run {:dir root} (script "handoff_lib.bb") "next-sequence")]
(is (str/includes? (:out mode) "batch"))
(is (str/includes? (:out worktree) "cleaner"))
(is (str/includes? (:out dequeued) "2026-06-16T00:00:00Z"))
(is (str/includes? (:out seq-1) "000001"))
(is (str/includes? (:out seq-2) "000002")))
(finally
(fs/delete-tree root)))))
(deftest swarmforge-launcher-parses-config-and-writes-state-files
(let [root (tmp-dir)]
(try
(write-file (fs/path root "swarmforge/constitution.prompt")
"Read articles.\n")
(write-file (fs/path root "swarmforge/swarmforge.conf")
(str "# comment\n"
"window coder codex master\n"
"window cleaner codex cleaner batch\n"))
(write-file (fs/path root "swarmforge/roles/coder.prompt") "coder\n")
(write-file (fs/path root "swarmforge/roles/cleaner.prompt") "cleaner\n")
(let [result (run {:dir root} (script "swarmforge.bb") "--test-parse" (str root))]
(is (str/includes? (:out result) "coder Coder"))
(is (str/includes? (:out result) "cleaner Cleaner"))
(is (str/includes? (:out result) "cleaner batch"))
(is (str/includes? (:out result) "swarmforge-coder"))
(is (str/includes? (:out result) "swarmforge-cleaner"))
(is (fs/exists? (fs/path root ".swarmforge/tmux-socket"))))
(finally
(fs/delete-tree root)))))
(deftest swarmforge-uses-portable-tmux-socket-dir
(let [root (tmp-dir)]
(try
(write-file (fs/path root "swarmforge/constitution.prompt")
"Read articles.\n")
(write-file (fs/path root "swarmforge/swarmforge.conf")
"window coder codex master\n")
(write-file (fs/path root "swarmforge/roles/coder.prompt") "coder\n")
(run {:dir root} (script "swarmforge.bb") "--test-parse" (str root))
(let [socket-path (str/trim (slurp (str (fs/path root ".swarmforge/tmux-socket"))))]
(is (str/starts-with? socket-path "/tmp/swarmforge-"))
(is (not (str/starts-with? socket-path "/private/tmp/"))))
(finally
(fs/delete-tree root)))))
(deftest swarmforge-launcher-rejects-invalid-config
(let [root (tmp-dir)]
(try
(write-file (fs/path root "swarmforge/constitution.prompt")
"Read articles.\n")
(write-file (fs/path root "swarmforge/swarmforge.conf")
(str "window coder codex master\n"
"window coder codex other\n"))
(write-file (fs/path root "swarmforge/roles/coder.prompt") "coder\n")
(let [result (run {:dir root :ok? false} (script "swarmforge.bb") "--test-parse" (str root))]
(is (= 1 (:exit result)))
(is (str/includes? (:err result) "Duplicate role 'coder'")))
(finally
(fs/delete-tree root)))))
(deftest swarmforge-terminal-bridge-preserves-adapter-globals
(let [root (tmp-dir)]
(try
(write-file (fs/path root "swarmforge/scripts/swarm-terminal-adapter.sh")
(str "load_terminal_backend() {\n"
" source \"$SCRIPT_DIR/terminal-adapters/$1.sh\"\n"
"}\n"))
(write-file (fs/path root "swarmforge/scripts/terminal-adapters/probe.sh")
(str "terminal_open_session() {\n"
" printf '%s\\n' \"$WORKING_DIR|$TMUX_SOCKET|$1|$2|$3\"\n"
"}\n"))
(let [result (run {:dir root}
(script "swarmforge.bb")
"--test-terminal-bridge"
(str root)
"probe")]
(is (str/includes? (:out result) (str root "|")))
(is (str/includes? (:out result) "|swarmforge-specifier|SwarmForge Specifier|"))
(is (not (str/includes? (:out result) "cd ''")))
(is (not (str/includes? (:out result) "-S ''"))))
(finally
(fs/delete-tree root)))))
(deftest swarmforge-agent-start-delay-is-configurable
(let [default-result (run {:dir repo-root}
(script "swarmforge.bb")
"--test-agent-start-delay")
configured-result (run {:dir repo-root
:env {"SWARMFORGE_AGENT_START_DELAY_MS" "2750"}}
(script "swarmforge.bb")
"--test-agent-start-delay")
invalid-result (run {:dir repo-root
:env {"SWARMFORGE_AGENT_START_DELAY_MS" "fast"}}
(script "swarmforge.bb")
"--test-agent-start-delay")]
(is (= "1500" (str/trim (:out default-result))))
(is (= "2750" (str/trim (:out configured-result))))
(is (= "1500" (str/trim (:out invalid-result))))))
(deftest swarmforge-sleep-prevention-can-be-disabled
(let [result (run {:dir repo-root
:env {"SWARMFORGE_PREVENT_SLEEP" "0"}}
(script "swarmforge.bb")
"--test-sleep-inhibitor-prefix")]
(is (= "" (str/trim (:out result))))))
(deftest swarmforge-launcher-parses-extra-cli-args
(let [root (tmp-dir)]
(try
(write-file (fs/path root "swarmforge/constitution.prompt")
"Read articles.\n")
(write-file (fs/path root "swarmforge/swarmforge.conf")
(str "window coder copilot master --yolo\n"
"window cleaner copilot cleaner batch --allow-all-tools\n"))
(write-file (fs/path root "swarmforge/roles/coder.prompt") "coder\n")
(write-file (fs/path root "swarmforge/roles/cleaner.prompt") "cleaner\n")
(let [result (run {:dir root} (script "swarmforge.bb") "--test-parse" (str root))]
(is (str/includes? (:out result) "coder Coder"))
(is (str/includes? (:out result) "task --yolo"))
(is (str/includes? (:out result) "batch --allow-all-tools")))
(finally
(fs/delete-tree root)))))
(deftest copilot-launch-command-passes-extra-cli-args
(let [root (tmp-dir)]
(try
(let [result (run {:dir root}
(script "swarmforge.bb")
"--test-launch-command"
(str root)
"copilot"
"--yolo")
command (:out result)]
(is (str/includes? command "copilot -C "))
(is (re-find #"--name 'SwarmForge Coder' --yolo -i" command)))
(finally
(fs/delete-tree root)))))
(deftest grok-launch-command-passes-initial-prompt
(let [root (tmp-dir)]
(try
(let [result (run {:dir root}
(script "swarmforge.bb")
"--test-launch-command"
(str root)
"grok")
command (:out result)]
(is (str/includes? command "grok --cwd "))
(is (str/includes? command "--permission-mode acceptEdits"))
(is (str/includes? command "--rules \"$(cat "))
(is (str/includes? command "--verbatim \"$(cat "))
(is (str/includes? command ".swarmforge/prompts/coder.md"))
(is (fs/exists? (fs/path root ".swarmforge/prompts/coder.md"))))
(finally
(fs/delete-tree root)))))
(deftest grok-launch-command-uses-bypass-permissions-with-always-approve
(let [root (tmp-dir)]
(try
(let [result (run {:dir root}
(script "swarmforge.bb")
"--test-launch-command"
(str root)
"grok"
"--always-approve")
command (:out result)]
(is (str/includes? command "--permission-mode bypassPermissions"))
(is (str/includes? command "--always-approve"))
(is (not (str/includes? command "--permission-mode acceptEdits"))))
(finally
(fs/delete-tree root)))))
(deftest window-watchdog-rewrites-window-state-and-id-list
(let [root (tmp-dir)
state-file (fs/path root "windows.tsv")
ids-file (fs/path root "window-ids")]
(try
(write-file state-file
(str "1\told-a\tswarmforge-coder\tSwarmForge Coder\n"
"2\told-b\tswarmforge-cleaner\tSwarmForge Cleaner\n"))
(write-file ids-file "old-a\nold-b\n")
(run {:dir root} (script "swarm-window-watchdog.bb") "--rewrite-window-id" "windows.tsv" "window-ids" "2" "new-b")
(let [state (slurp (str state-file))
ids (slurp (str ids-file))]
(is (str/includes? state "1\told-a\tswarmforge-coder\tSwarmForge Coder"))
(is (str/includes? state "2\tnew-b\tswarmforge-cleaner\tSwarmForge Cleaner"))
(is (= "old-a\nnew-b\n" ids)))
(finally
(fs/delete-tree root)))))
(deftest swarmforge-detects-nonzero-pane-base-index
(let [root (tmp-dir)
sock (str root "/test.sock")
conf (fs/path root "tmux.conf")]
(try
(write-file conf "set -g base-index 1\nset -g pane-base-index 1\n")
(run {:dir root} "tmux" "-S" sock "-f" (str conf) "new-session" "-d" "-s" "probe" "sleep" "120")
(let [result (run {:dir root}
(script "swarmforge.bb")
"--test-tmux-base-indexes"
sock)]
(is (= "1 1" (str/trim (:out result)))))
(finally
(run {:dir root :ok? false} "tmux" "-S" sock "kill-server")
(fs/delete-tree root)))))
(deftest swarm-cleanup-tolerates-missing-runtime-state
(let [root (tmp-dir)
ids-file (fs/path root ".swarmforge/window-ids")]
(try
(write-file ids-file "window-a\nwindow-b\n")
(let [result (run {:dir root
:env {"SWARMFORGE_TERMINAL_BACKEND" "none"}}
(str (fs/path scripts-dir "swarm-cleanup.sh"))
"/tmp/nonexistent.sock"
(str ids-file))]
(is (= 0 (:exit result)))
(is (= "" (:err result))))
(finally
(fs/delete-tree root)))))
(defn close-swarm []
(str (fs/path repo-root "close-swarm")))
(deftest close-swarm-reports-when-no-swarm-state
(let [root (tmp-dir)]
(try
(let [result (run {:dir root :ok? false
:env {"SWARMFORGE_TERMINAL_BACKEND" "none"}}
(close-swarm)
(str root))]
(is (not= 0 (:exit result)))
(is (str/includes? (str (:err result) (:out result)) "No SwarmForge swarm")))
(finally
(fs/delete-tree root)))))
(deftest close-swarm-kills-tmux-sessions-and-stops-daemon
(let [root (tmp-dir)
sock (str (fs/path root "swarm.sock"))
pid-file (fs/path root ".swarmforge/daemon/handoffd.pid")
daemon (.start (java.lang.ProcessBuilder. ["sleep" "120"]))
pid (str (.pid daemon))]
(try
(write-file (fs/path root ".swarmforge/tmux-socket") (str sock "\n"))
(write-file (fs/path root ".swarmforge/sessions.tsv")
(str "1\tcoder\tswarmforge-coder\tCoder\tcodex\n"
"2\tcleaner\tswarmforge-cleaner\tCleaner\tcodex\n"))
(write-file (fs/path root ".swarmforge/window-ids") "win-a\nwin-b\n")
(write-file pid-file (str pid "\n"))
(run {:dir root} "tmux" "-S" sock "new-session" "-d" "-s" "swarmforge-coder" "sleep" "120")
(run {:dir root} "tmux" "-S" sock "new-session" "-d" "-s" "swarmforge-cleaner" "sleep" "120")
(let [result (run {:dir root
:env {"SWARMFORGE_TERMINAL_BACKEND" "none"}}
(close-swarm)
(str root))]
(is (= 0 (:exit result)))
(is (not= 0 (:exit (run {:dir root :ok? false}
"tmux" "-S" sock "has-session" "-t" "swarmforge-coder"))))
(is (not= 0 (:exit (run {:dir root :ok? false}
"tmux" "-S" sock "has-session" "-t" "swarmforge-cleaner"))))
(is (not (fs/exists? pid-file)))
(is (false? (.isAlive daemon))))
(finally
(when (.isAlive daemon)
(.destroyForcibly daemon))
(run {:dir root :ok? false} "tmux" "-S" sock "kill-server")
(fs/delete-tree root)))))
+75
View File
@@ -0,0 +1,75 @@
/*
Unit tests for the Account Page behavior slice (src/services/account-page.js).
Expresses the observable behavior described by specs/set-name.feature:
- the account page shows the authenticated user's display name.
- the account page exposes a Set Name button.
- clicking the Set Name button navigates to /memo/set-name.
*/
'use strict'
const test = require('node:test')
const assert = require('node:assert/strict')
const AccountPage = require('../../src/services/account-page')
const ADDRESS = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d'
function fakeWallet (cashAddress = ADDRESS) {
return { walletInfo: { cashAddress } }
}
function fakeProfiles (initial = {}) {
const names = { ...initial }
return { names, setName: (addr, name) => { names[addr] = name }, getName: (addr) => names[addr] || null }
}
function build (deps = {}) {
const wallet = deps.wallet !== undefined ? deps.wallet : fakeWallet()
const profiles = deps.profiles !== undefined ? deps.profiles : fakeProfiles()
const navigations = []
const page = new AccountPage({
wallet,
profiles,
navigate: (path) => navigations.push(path)
})
return { page, navigations, profiles }
}
test('SET_NAME_PATH and ACCOUNT_PATH constants', () => {
assert.equal(AccountPage.SET_NAME_PATH, '/memo/set-name')
assert.equal(AccountPage.ACCOUNT_PATH, '/account')
})
test('the account page shows a Set Name button', () => {
const { page } = build()
assert.equal(page.hasSetNameButton(), true)
})
test('clicking the Set Name button navigates to /memo/set-name', () => {
const { page, navigations } = build()
page.clickSetName()
assert.deepEqual(navigations, ['/memo/set-name'])
})
test('the account page shows the stored name for the authenticated address', () => {
const profiles = fakeProfiles({ [ADDRESS]: 'trout' })
const { page } = build({ profiles })
assert.equal(page.getName(), 'trout')
})
test('the account page returns null when no name is stored', () => {
const { page } = build()
assert.equal(page.getName(), null)
})
test('the account page returns null when no wallet is present', () => {
const { page } = build({ wallet: null })
assert.equal(page.getName(), null)
})
test('the account page returns null when no profile store is present', () => {
const { page } = build({ profiles: null })
assert.equal(page.getName(), null)
})
+50
View File
@@ -0,0 +1,50 @@
/*
Unit tests for the hex conversion helper (src/services/hex.js).
Memo actions like replies and likes embed a post txid in the OP_RETURN
payload as raw bytes, so the helper must decode a canonical 64-character hex
string into exactly 32 bytes and reject anything else with a clear error.
*/
'use strict'
const test = require('node:test')
const assert = require('node:assert/strict')
const { hexToBytes } = require('../../src/services/hex')
test('hexToBytes decodes a 64-char hex string into 32 bytes', () => {
const hex = 'a'.repeat(64)
const bytes = hexToBytes(hex, 32)
assert.ok(bytes instanceof Uint8Array)
assert.equal(bytes.length, 32)
assert.equal(Buffer.from(bytes).toString('hex'), hex)
})
test('hexToBytes rejects a string of the wrong length', () => {
assert.throws(
() => hexToBytes('a'.repeat(10), 32),
/64-character hex string/
)
})
test('hexToBytes rejects a 64-char string containing a non-hex character', () => {
assert.throws(
() => hexToBytes(`z${'a'.repeat(63)}`, 32),
/valid hex string/
)
})
test('hexToBytes rejects non-string input', () => {
assert.throws(
() => hexToBytes(null, 32),
/64-character hex string/
)
})
test('hexToBytes uses a custom label in error messages', () => {
assert.throws(
() => hexToBytes('nope', 32, 'Post txid'),
/Post txid/
)
})
+239
View File
@@ -0,0 +1,239 @@
/*
Unit tests for the Like / Tip page behavior slice
(src/services/like-tip-page.js).
These tests express the page-level behavior described by
specs/like-tip-memo.feature:
- opening the modal for a post checks the wallet balance.
- submitting a like without a tip broadcasts the Memo like action.
- submitting a like with a tip broadcasts the action and the tip.
- invalid, dust, maximum, and balance errors are surfaced.
*/
'use strict'
const test = require('node:test')
const assert = require('node:assert/strict')
const LikeTipPage = require('../../src/services/like-tip-page')
const MemoLike = require('../../src/services/memo-like')
const { fakeWallet } = require('../helpers/fake-wallet')
const POST_TXID = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
const AUTHOR_ADDRESS = 'bitcoincash:qz7v6ztvzu2f2xd2ww8pnx9vwk0g4ncvfvavktg0jc'
const MY_ADDRESS = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d'
function build () {
const wallet = fakeWallet({ cashAddress: MY_ADDRESS, utxos: [{ txid: 'u1', value: 100000 }] })
const feed = { posts: [], likes: [], addLike: (l) => feed.likes.push(l) }
const memoLike = new MemoLike({ wallet, feed })
const page = new LikeTipPage({ memoLike })
return { wallet, feed, memoLike, page }
}
// Build a page with the given wallet balance, submit a tip string, and assert
// that the submit is rejected with the expected error code and message.
async function assertTipRejected (utxos, tip, expectedCode, messageRe) {
const wallet = fakeWallet({ cashAddress: MY_ADDRESS, utxos })
const memoLike = new MemoLike({ wallet })
const page = new LikeTipPage({ memoLike })
page.open(POST_TXID, AUTHOR_ADDRESS)
page.setTip(tip)
const result = await page.submit()
assert.equal(result.ok, false)
assert.equal(result.error, expectedCode)
assert.match(page.broadcastError, messageRe)
assert.equal(wallet.broadcasts.length, 0)
}
test('opening the modal sets the target post and author', () => {
const { page } = build()
const result = page.open(POST_TXID, AUTHOR_ADDRESS)
assert.equal(result.ok, true)
assert.equal(page.modalOpen, true)
assert.equal(page.postTxid, POST_TXID)
assert.equal(page.authorAddress, AUTHOR_ADDRESS)
})
test('opening the modal with insufficient balance surfaces an add-BCH error', () => {
const wallet = fakeWallet({ cashAddress: MY_ADDRESS, utxos: [] })
const memoLike = new MemoLike({ wallet })
const page = new LikeTipPage({ memoLike })
const result = page.open(POST_TXID, AUTHOR_ADDRESS)
assert.equal(result.ok, false)
assert.equal(result.error, 'like_empty_balance')
assert.equal(page.submitError, 'like_empty_balance')
assert.match(page.broadcastError, /add BCH/i)
assert.equal(page.modalOpen, true)
})
test('opening the modal without a memo-like handler surfaces an error', () => {
const page = new LikeTipPage({})
const result = page.open(POST_TXID, AUTHOR_ADDRESS)
assert.equal(result.ok, false)
assert.equal(result.error, 'like_validation')
assert.match(page.broadcastError, /memo like/i)
})
test('closing the modal resets input and errors', () => {
const { page } = build()
page.open(POST_TXID, AUTHOR_ADDRESS)
page.setTip('5000')
page.submitError = 'like_dust'
page.broadcastError = 'some error'
page.close()
assert.equal(page.modalOpen, false)
assert.equal(page.input, '')
assert.equal(page.submitError, null)
assert.equal(page.broadcastError, null)
})
test('submitting a pure like broadcasts the Memo like prefix', async () => {
const { wallet, page } = build()
page.open(POST_TXID, AUTHOR_ADDRESS)
const result = await page.submit()
assert.equal(result.ok, true)
assert.equal(page.tipping, false)
assert.equal(wallet.broadcasts.length, 1)
assert.equal(wallet.broadcasts[0].prefix, '6d04')
assert.deepEqual(wallet.broadcasts[0].bchOutput, [])
})
test('submitting a like with a tip broadcasts the prefix and the tip output', async () => {
const { wallet, page } = build()
page.open(POST_TXID, AUTHOR_ADDRESS)
page.setTip('3000')
const result = await page.submit()
assert.equal(result.ok, true)
assert.equal(wallet.broadcasts.length, 1)
assert.equal(wallet.broadcasts[0].prefix, '6d04')
assert.deepEqual(wallet.broadcasts[0].bchOutput, [{ address: AUTHOR_ADDRESS, amountSat: 3000 }])
})
test('submitting a like on a post authored by the wallet works without a tip', async () => {
const { wallet, page } = build()
page.open(POST_TXID, MY_ADDRESS)
const result = await page.submit()
assert.equal(result.ok, true)
assert.equal(wallet.broadcasts.length, 1)
assert.equal(wallet.broadcasts[0].prefix, '6d04')
})
test('submitting with a non-numeric tip string is rejected', async () => {
for (const tip of ['abc', '1.5']) {
const { wallet, page } = build()
page.open(POST_TXID, AUTHOR_ADDRESS)
page.setTip(tip)
const result = await page.submit()
assert.equal(result.ok, false)
assert.equal(result.error, 'like_validation')
assert.equal(page.submitError, 'like_validation')
assert.match(page.broadcastError, /valid number/i)
assert.equal(wallet.broadcasts.length, 0)
}
})
test('submitting with a dust tip is rejected', async () => {
const { wallet, page } = build()
page.open(POST_TXID, AUTHOR_ADDRESS)
page.setTip('599')
const result = await page.submit()
assert.equal(result.ok, false)
assert.equal(result.error, 'like_dust')
assert.equal(wallet.broadcasts.length, 0)
})
test('submitting with a tip above the maximum is rejected', async () => {
await assertTipRejected(
[{ txid: 'u1', value: 150000000 }],
'100000001',
'like_maximum',
/maximum/i
)
})
test('submitting with a tip above the spendable balance is rejected', async () => {
await assertTipRejected(
[{ txid: 'u1', value: 30000 }],
'35000',
'like_balance',
/spendable/i
)
})
test('tipping flag is true while a submit is in flight and false once it settles', async () => {
const { wallet, page } = build()
page.open(POST_TXID, AUTHOR_ADDRESS)
let resolveSend
wallet.sendOpReturn = async () => new Promise((resolve) => { resolveSend = resolve })
assert.equal(page.tipping, false)
const pending = page.submit()
assert.equal(page.tipping, true)
await new Promise((resolve) => setImmediate(resolve))
assert.equal(typeof resolveSend, 'function')
resolveSend('in-flight-txid')
await pending
assert.equal(page.tipping, false)
})
test('submitting without a memo-like handler reports an error', async () => {
const page = new LikeTipPage({})
page.open(POST_TXID, AUTHOR_ADDRESS)
const result = await page.submit()
assert.equal(result.ok, false)
assert.equal(page.submitError, 'broadcast')
assert.match(page.broadcastError, /memo like/i)
})
test('a failed broadcast surfaces the real error', async () => {
const wallet = fakeWallet({ cashAddress: MY_ADDRESS })
wallet.failWith = 'Insufficient balance'
const memoLike = new MemoLike({ wallet })
const page = new LikeTipPage({ memoLike })
page.open(POST_TXID, AUTHOR_ADDRESS)
const result = await page.submit()
assert.equal(result.ok, false)
assert.equal(page.submitError, 'broadcast')
assert.match(page.broadcastError, /Insufficient balance/)
})
test('a submit failure with no error message surfaces the error name', async () => {
const wallet = fakeWallet({ cashAddress: MY_ADDRESS })
const memoLike = new MemoLike({ wallet })
memoLike.like = async () => { throw new Error('') }
const page = new LikeTipPage({ memoLike })
page.open(POST_TXID, AUTHOR_ADDRESS)
const result = await page.submit()
assert.equal(result.ok, false)
assert.equal(page.submitError, 'broadcast')
assert.equal(page.broadcastError, 'Error')
})
+107
View File
@@ -0,0 +1,107 @@
'use strict'
const test = require('node:test')
const assert = require('node:assert/strict')
const { fakeWallet } = require('../helpers/fake-wallet')
// Register the MemoAction tests shared by the Memo post and Set Name slices.
// Both slices broadcast a value through a wallet and reflect the result on an
// injected store, so the maximum-length and over-length behaviors are
// identical; only the slice-specific pieces differ. `cfg` supplies:
// Action - the action class (MemoPost or MemoSetName)
// method - the broadcast method name ('post' or 'setName')
// MAX - the length limit
// lengthCode - the length error code
// validationCode - the validation error code
// label - a short label for test names
// storeKey - the action's store dependency key ('feed' or 'profiles')
// storeFactory - () => a fresh store
// assertStoreEmpty - (store, wallet) => asserts the store was not updated
// assertBroadcastMsg - (broadcast, value) => asserts the broadcast message
// byteBased - true when the slice counts bytes (registers multi-byte tests)
// extraArgs - extra arguments passed to the broadcast method after the value
function registerMemoActionTests (cfg) {
const { Action, method, MAX, lengthCode, validationCode, label, storeKey, storeFactory, assertStoreEmpty, assertBroadcastMsg, byteBased = false, extraArgs = [] } = cfg
const checkBroadcastMsg = assertBroadcastMsg || ((broadcast, value) => assert.equal(broadcast.msg, value))
test(`${label} at the maximum length (${MAX}) is accepted`, async () => {
const wallet = fakeWallet()
const action = new Action({ wallet })
const value = 'x'.repeat(MAX)
const txid = await action[method](value, ...extraArgs)
assert.equal(txid, 'fake-txid')
checkBroadcastMsg(wallet.broadcasts[0], value)
})
test(`${label} over the limit (${MAX + 1}) throws a length error and broadcasts nothing`, async () => {
const wallet = fakeWallet()
const store = storeFactory()
const action = new Action({ wallet, [storeKey]: store })
await assert.rejects(
action[method]('y'.repeat(MAX + 1), ...extraArgs),
(err) => err.code === lengthCode
)
assert.equal(wallet.broadcasts.length, 0)
assertStoreEmpty(store, wallet)
})
test(`${label} that is whitespace-only or non-string throws a validation error and broadcasts nothing`, async () => {
for (const invalid of [' ', 42]) {
const wallet = fakeWallet()
const action = new Action({ wallet })
await assert.rejects(
action[method](invalid, ...extraArgs),
(err) => err.code === validationCode
)
assert.equal(wallet.broadcasts.length, 0)
}
})
test(`${label} that is empty throws a validation error and broadcasts nothing`, async () => {
const wallet = fakeWallet()
const store = storeFactory()
const action = new Action({ wallet, [storeKey]: store })
await assert.rejects(
action[method]('', ...extraArgs),
(err) => err.code === validationCode
)
assert.equal(wallet.broadcasts.length, 0)
assertStoreEmpty(store, wallet)
})
if (byteBased) {
test(`${label} with multi-byte characters at the byte limit is accepted`, async () => {
const wallet = fakeWallet()
const action = new Action({ wallet })
// 'é' encodes to 2 UTF-8 bytes, so floor(MAX/2) characters reach the limit.
const count = Math.floor(MAX / 2)
const value = 'é'.repeat(count)
assert.equal(Buffer.byteLength(value, 'utf8'), count * 2)
const txid = await action[method](value, ...extraArgs)
assert.equal(txid, 'fake-txid')
})
test(`${label} with multi-byte characters that exceed the byte limit throws a length error`, async () => {
const wallet = fakeWallet()
const action = new Action({ wallet })
const count = Math.floor(MAX / 2) + 1
const value = 'é'.repeat(count)
assert.ok(Buffer.byteLength(value, 'utf8') > MAX)
await assert.rejects(
action[method](value, ...extraArgs),
(err) => err.code === lengthCode
)
assert.equal(wallet.broadcasts.length, 0)
})
}
}
module.exports = { registerMemoActionTests }
+293
View File
@@ -0,0 +1,293 @@
/*
Unit tests for the Memo like/tip behavior slice (src/services/memo-like.js).
These tests express the observable behavior described by
specs/like-tip-memo.feature:
- a valid like broadcasts an OP_RETURN transaction carrying the Memo like
prefix (0x6d04) and the post txid bytes; an optional tip is included as a
BCH output to the author address.
- an invalid/non-integer tip is rejected with a validation error.
- a tip below the dust limit is rejected.
- a tip above the hard maximum is rejected.
- a tip above the spendable balance is rejected.
- a wallet without spendable balance cannot like.
*/
'use strict'
const test = require('node:test')
const assert = require('node:assert/strict')
const MemoLike = require('../../src/services/memo-like')
const { fakeWallet } = require('../helpers/fake-wallet')
const POST_TXID = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
const AUTHOR_ADDRESS = 'bitcoincash:qz7v6ztvzu2f2xd2ww8pnx9vwk0g4ncvfvavktg0jc'
const MY_ADDRESS = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d'
// A fake feed that records likes and exposes posts keyed by txid.
function fakeFeed (posts = []) {
const likes = []
return {
posts,
likes,
addLike: (l) => likes.push(l)
}
}
// Decode a like payload back into the canonical 64-character hex txid.
function decodeLikeTxid (raw) {
return Buffer.from(raw).toString('hex')
}
// Assert that a like with the given post txid and tip rejects with a specific
// error code and performs no broadcast.
async function assertLikeRejected (wallet, tip, expectedCode, postTxid = POST_TXID) {
const memoLike = new MemoLike({ wallet })
await assert.rejects(
memoLike.like(postTxid, tip, AUTHOR_ADDRESS),
(err) => err.code === expectedCode
)
assert.equal(wallet.broadcasts.length, 0)
}
test('MEMO_LIKE_PREFIX is the Memo like action 0x6d04', () => {
assert.equal(MemoLike.MEMO_LIKE_PREFIX, '6d04')
})
test('DUST_LIMIT_SATS is 3000', () => {
assert.equal(MemoLike.DUST_LIMIT_SATS, 3000)
})
test('DUST_TIP_SATS is 600', () => {
assert.equal(MemoLike.DUST_TIP_SATS, 600)
})
test('MAX_TIP_SATS is 100000000', () => {
assert.equal(MemoLike.MAX_TIP_SATS, 100000000)
})
test('liking a post without a tip broadcasts an OP_RETURN with the Memo like prefix and txid', async () => {
const wallet = fakeWallet({ cashAddress: MY_ADDRESS })
const feed = fakeFeed([{ txid: POST_TXID, addr: AUTHOR_ADDRESS, likeCount: 0 }])
const memoLike = new MemoLike({ wallet, feed })
const txid = await memoLike.like(POST_TXID, 0, AUTHOR_ADDRESS)
assert.equal(txid, 'fake-txid')
assert.equal(wallet.broadcasts.length, 1)
const b = wallet.broadcasts[0]
assert.equal(b.prefix, '6d04')
assert.ok(b.msg instanceof Uint8Array)
assert.equal(decodeLikeTxid(b.msg), POST_TXID)
assert.deepEqual(b.bchOutput, [])
// The feed reflects the like.
assert.equal(feed.likes.length, 1)
assert.equal(feed.likes[0].postTxid, POST_TXID)
assert.equal(feed.likes[0].address, MY_ADDRESS)
assert.equal(feed.likes[0].tipSats, 0)
assert.equal(feed.posts[0].likeCount, 1)
})
test('liking a post with a tip broadcasts an OP_RETURN and a tip output', async () => {
const wallet = fakeWallet({ cashAddress: MY_ADDRESS, utxos: [{ txid: 'u1', value: 100000 }] })
const feed = fakeFeed([{ txid: POST_TXID, addr: AUTHOR_ADDRESS, likeCount: 5 }])
const memoLike = new MemoLike({ wallet, feed })
const txid = await memoLike.like(POST_TXID, 3000, AUTHOR_ADDRESS)
assert.equal(txid, 'fake-txid')
assert.equal(wallet.broadcasts.length, 1)
const b = wallet.broadcasts[0]
assert.equal(b.prefix, '6d04')
assert.equal(decodeLikeTxid(b.msg), POST_TXID)
assert.deepEqual(b.bchOutput, [{ address: AUTHOR_ADDRESS, amountSat: 3000 }])
// The feed reflects the like.
assert.equal(feed.likes.length, 1)
assert.equal(feed.likes[0].tipSats, 3000)
assert.equal(feed.posts[0].likeCount, 6)
})
test('liking without a wallet reports a missing-wallet error', async () => {
const memoLike = new MemoLike({})
await assert.rejects(
memoLike.like(POST_TXID),
(err) => /wallet/i.test(err.message)
)
})
test('liking with an invalid post txid reports a clear validation error', async () => {
await assertLikeRejected(fakeWallet(), 0, 'like_validation', 'not-a-txid')
})
test('liking with a wrong-length but valid-hex post txid is rejected', async () => {
await assertLikeRejected(fakeWallet(), 0, 'like_validation', 'a'.repeat(10))
})
test('a non-integer tip like "1.5" is rejected with a validation error', async () => {
await assertLikeRejected(fakeWallet(), 1.5, 'like_validation')
})
test('a non-numeric tip like "abc" is rejected with a validation error', async () => {
await assertLikeRejected(fakeWallet(), NaN, 'like_validation')
})
test('a negative tip is rejected with a validation error', async () => {
await assertLikeRejected(fakeWallet(), -1, 'like_validation')
})
test('a tip below the tip dust limit is rejected with a dust error', async () => {
const wallet = fakeWallet({ utxos: [{ txid: 'u1', value: 100000 }] })
await assertLikeRejected(wallet, 1, 'like_dust')
await assertLikeRejected(wallet, 599, 'like_dust')
})
test('a tip at the tip dust limit is accepted', async () => {
const wallet = fakeWallet({ utxos: [{ txid: 'u1', value: 100000 }] })
const memoLike = new MemoLike({ wallet })
const txid = await memoLike.like(POST_TXID, 600, AUTHOR_ADDRESS)
assert.equal(txid, 'fake-txid')
assert.equal(wallet.broadcasts.length, 1)
assert.deepEqual(wallet.broadcasts[0].bchOutput, [
{ address: AUTHOR_ADDRESS, amountSat: 600 }
])
})
test('a tip above the hard maximum is rejected with a maximum error', async () => {
await assertLikeRejected(
fakeWallet({ utxos: [{ txid: 'u1', value: 150000000 }] }),
100000001,
'like_maximum'
)
})
test('a tip at exactly the hard maximum is accepted', async () => {
const wallet = fakeWallet({
cashAddress: MY_ADDRESS,
utxos: [{ txid: 'u1', value: MemoLike.MAX_TIP_SATS + 1000 }]
})
const memoLike = new MemoLike({ wallet })
const txid = await memoLike.like(POST_TXID, MemoLike.MAX_TIP_SATS, AUTHOR_ADDRESS)
assert.equal(txid, 'fake-txid')
assert.equal(wallet.broadcasts.length, 1)
assert.deepEqual(wallet.broadcasts[0].bchOutput, [
{ address: AUTHOR_ADDRESS, amountSat: MemoLike.MAX_TIP_SATS }
])
})
test('a wallet with exactly the dust-limit balance can make a pure like', async () => {
const wallet = fakeWallet({
cashAddress: MY_ADDRESS,
utxos: [{ txid: 'u1', value: MemoLike.DUST_LIMIT_SATS }]
})
const memoLike = new MemoLike({ wallet })
const txid = await memoLike.like(POST_TXID, 0, AUTHOR_ADDRESS)
assert.equal(txid, 'fake-txid')
assert.equal(wallet.broadcasts.length, 1)
})
test('a tip above the spendable balance is rejected with a balance error', async () => {
await assertLikeRejected(
fakeWallet({ utxos: [{ txid: 'u1', value: 30000 }] }),
35000,
'like_balance'
)
})
test('a tip at exactly the spendable balance is accepted', async () => {
const wallet = fakeWallet({
cashAddress: MY_ADDRESS,
utxos: [{ txid: 'u1', value: 25000 }]
})
const memoLike = new MemoLike({ wallet })
const txid = await memoLike.like(POST_TXID, 25000, AUTHOR_ADDRESS)
assert.equal(txid, 'fake-txid')
assert.equal(wallet.broadcasts.length, 1)
})
test('a wallet with zero spendable balance cannot like', async () => {
await assertLikeRejected(fakeWallet({ utxos: [] }), 0, 'like_empty_balance')
})
test('a wallet with balance below the dust limit cannot like', async () => {
await assertLikeRejected(
fakeWallet({ utxos: [{ txid: 'u1', value: 2999 }] }),
0,
'like_empty_balance'
)
})
test('a pure like can be made on a post authored by the wallet address', async () => {
const wallet = fakeWallet({ cashAddress: MY_ADDRESS })
const feed = fakeFeed([{ txid: POST_TXID, addr: MY_ADDRESS, likeCount: 0 }])
const memoLike = new MemoLike({ wallet, feed })
const txid = await memoLike.like(POST_TXID, 0, MY_ADDRESS)
assert.equal(txid, 'fake-txid')
assert.equal(wallet.broadcasts.length, 1)
assert.equal(wallet.broadcasts[0].prefix, '6d04')
assert.equal(feed.posts[0].likeCount, 1)
})
test('a tip requires an author address', async () => {
const wallet = fakeWallet({ utxos: [{ txid: 'u1', value: 100000 }] })
const memoLike = new MemoLike({ wallet })
await assert.rejects(
memoLike.like(POST_TXID, 3000),
(err) => err.code === 'like_validation'
)
assert.equal(wallet.broadcasts.length, 0)
})
test('getSpendableSats tolerates common utxo value field names', () => {
const walletValue = fakeWallet({ utxos: [{ txid: 'u1', value: 1000 }] })
const walletSatoshis = fakeWallet({ utxos: [{ txid: 'u2', satoshis: 2000 }] })
const walletAmount = fakeWallet({ utxos: [{ txid: 'u3', amount: 3000 }] })
assert.equal(new MemoLike({ wallet: walletValue }).getSpendableSats(), 1000)
assert.equal(new MemoLike({ wallet: walletSatoshis }).getSpendableSats(), 2000)
assert.equal(new MemoLike({ wallet: walletAmount }).getSpendableSats(), 3000)
})
test('getSpendableSats returns 0 without a wallet or spendable values', () => {
assert.equal(new MemoLike({}).getSpendableSats(), 0)
assert.equal(new MemoLike({ wallet: { utxos: undefined } }).getSpendableSats(), 0)
assert.equal(new MemoLike({ wallet: { utxos: [{ txid: 'u1' }] } }).getSpendableSats(), 0)
})
test('getSpendableSats reads spendable BCH outputs from the wallet UtxoStore object', () => {
// minimal-slp-wallet exposes wallet.utxos as a UtxoStore object whose
// spendable BCH outputs live under utxoStore.bchUtxos.
const utxoStore = {
utxoStore: {
bchUtxos: [
{ txid: 'u1', satoshis: 1500 },
{ txid: 'u2', value: 2000 },
{ txid: 'u3', satoshis: 2500 }
],
slpUtxos: { type1: { tokens: [] }, nft: [] }
}
}
const wallet = fakeWallet({ utxos: utxoStore })
assert.equal(new MemoLike({ wallet }).getSpendableSats(), 6000)
})
test('getSpendableSats returns 0 for an empty UtxoStore object', () => {
const wallet = fakeWallet({ utxos: { utxoStore: { bchUtxos: [] } } })
assert.equal(new MemoLike({ wallet }).getSpendableSats(), 0)
})
+68
View File
@@ -0,0 +1,68 @@
/*
Unit tests for the Memo post behavior slice (src/services/memo-post.js).
These tests express the observable behavior described by
specs/post-memo.feature:
- a valid memo broadcasts an OP_RETURN transaction carrying the Memo post
prefix (0x6d02) and the message text, and the feed reflects the new post.
- an empty memo is rejected with a validation error and nothing is broadcast.
- an over-long memo is rejected with a length error and nothing is broadcast.
*/
'use strict'
const test = require('node:test')
const assert = require('node:assert/strict')
const MemoPost = require('../../src/services/memo-post')
const { fakeWallet } = require('../helpers/fake-wallet')
const { registerMemoActionTests } = require('./memo-action-helpers')
// A fake feed that records posts added to the recent posts feed.
function fakeFeed () {
const posts = []
return { posts, addPost: (p) => posts.push(p) }
}
registerMemoActionTests({
Action: MemoPost,
method: 'post',
MAX: 217,
lengthCode: 'memo_length',
validationCode: 'memo_validation',
label: 'posting a memo',
storeKey: 'feed',
storeFactory: fakeFeed,
assertStoreEmpty: (feed) => assert.equal(feed.posts.length, 0)
})
test('MEMO_POST_PREFIX is the Memo post action 0x6d02', () => {
assert.equal(MemoPost.MEMO_POST_PREFIX, '6d02')
})
test('posting a valid memo broadcasts an OP_RETURN with the Memo post prefix and message', async () => {
const wallet = fakeWallet()
const feed = fakeFeed()
const memoPost = new MemoPost({ wallet, feed })
const txid = await memoPost.post('hello memo')
assert.equal(txid, 'fake-txid')
assert.equal(wallet.broadcasts.length, 1)
const b = wallet.broadcasts[0]
assert.equal(b.prefix, '6d02')
assert.equal(b.msg, 'hello memo')
// The feed reflects the new post from this address with this text.
assert.equal(feed.posts.length, 1)
assert.equal(feed.posts[0].text, 'hello memo')
assert.equal(feed.posts[0].address, wallet.walletInfo.cashAddress)
})
test('posting without a wallet reports a missing-wallet error', async () => {
const memoPost = new MemoPost({})
await assert.rejects(
memoPost.post('hello memo'),
(err) => /wallet/i.test(err.message)
)
})
+115
View File
@@ -0,0 +1,115 @@
/*
Unit tests for the Memo reply behavior slice (src/services/memo-reply.js).
These tests express the observable behavior described by
specs/reply-memo.feature:
- a valid reply broadcasts an OP_RETURN transaction carrying the Memo reply
prefix (0x6d03), the parent txid bytes, and the message text; the thread
reflects the new reply.
- an empty reply is rejected with a validation error and nothing is broadcast.
- an over-long reply is rejected with a length error and nothing is broadcast.
*/
'use strict'
const test = require('node:test')
const assert = require('node:assert/strict')
const MemoReply = require('../../src/services/memo-reply')
const { fakeWallet } = require('../helpers/fake-wallet')
const { registerMemoActionTests } = require('./memo-action-helpers')
const PARENT_TXID = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
// A fake thread that records replies added to the thread view.
function fakeThread (rootTxid = PARENT_TXID) {
const replies = []
return {
rootTxid,
replies,
addReply: (r) => replies.push(r)
}
}
// Decode the reply text from a raw Uint8Array payload (skipping the 32-byte parent txid).
function decodeReplyText (raw) {
const decoder = new TextDecoder()
return decoder.decode(raw.slice(32))
}
// Decode the parent txid from a raw Uint8Array payload.
function decodeParentTxid (raw) {
return Buffer.from(raw.slice(0, 32)).toString('hex')
}
registerMemoActionTests({
Action: MemoReply,
method: 'reply',
MAX: 184,
lengthCode: 'reply_length',
validationCode: 'reply_validation',
label: 'a reply',
storeKey: 'thread',
storeFactory: fakeThread,
assertStoreEmpty: (thread) => assert.equal(thread.replies.length, 0),
assertBroadcastMsg: (broadcast, value) => assert.equal(decodeReplyText(broadcast.msg), value),
byteBased: true,
extraArgs: [PARENT_TXID]
})
test('MEMO_REPLY_PREFIX is the Memo reply action 0x6d03', () => {
assert.equal(MemoReply.MEMO_REPLY_PREFIX, '6d03')
})
test('replying with a valid message broadcasts an OP_RETURN with the Memo reply prefix and payload', async () => {
const wallet = fakeWallet()
const thread = fakeThread()
const memoReply = new MemoReply({ wallet, thread })
const txid = await memoReply.reply('hello memo', PARENT_TXID)
assert.equal(txid, 'fake-txid')
assert.equal(wallet.broadcasts.length, 1)
const b = wallet.broadcasts[0]
assert.equal(b.prefix, '6d03')
assert.ok(b.msg instanceof Uint8Array)
assert.equal(decodeParentTxid(b.msg), PARENT_TXID)
assert.equal(decodeReplyText(b.msg), 'hello memo')
// The thread reflects the new reply from this address with this text.
assert.equal(thread.replies.length, 1)
assert.equal(thread.replies[0].text, 'hello memo')
assert.equal(thread.replies[0].address, wallet.walletInfo.cashAddress)
assert.equal(thread.replies[0].parentTxid, PARENT_TXID)
})
test('replying without a wallet reports a missing-wallet error', async () => {
const memoReply = new MemoReply({})
await assert.rejects(
memoReply.reply('hello memo', PARENT_TXID),
(err) => /wallet/i.test(err.message)
)
})
test('replying with an invalid parent txid reports a clear error', async () => {
const wallet = fakeWallet()
const memoReply = new MemoReply({ wallet })
await assert.rejects(
memoReply.reply('hello memo', 'not-a-txid'),
(err) => /txid/i.test(err.message)
)
assert.equal(wallet.broadcasts.length, 0)
})
test('replying with a wrong-length but valid-hex parent txid is rejected', async () => {
const wallet = fakeWallet()
const memoReply = new MemoReply({ wallet })
// 10 hex characters are valid hex but not the required 64-character txid.
await assert.rejects(
memoReply.reply('hello memo', 'a'.repeat(10)),
(err) => /64-character hex/i.test(err.message)
)
assert.equal(wallet.broadcasts.length, 0)
})
+88
View File
@@ -0,0 +1,88 @@
/*
Unit tests for the Memo set-name behavior slice (src/services/memo-set-name.js).
These tests express the observable behavior described by specs/set-name.feature:
- a valid name broadcasts an OP_RETURN transaction carrying the Memo set-name
prefix (0x6d01) and the name text, and the profile store reflects the new
name.
- an empty name is rejected with a validation error and nothing is broadcast.
- an over-long name is rejected with a length error and nothing is broadcast.
*/
'use strict'
const test = require('node:test')
const assert = require('node:assert/strict')
const MemoSetName = require('../../src/services/memo-set-name')
const { fakeWallet } = require('../helpers/fake-wallet')
const { fakeProfiles } = require('../helpers/fake-profiles')
const { registerMemoActionTests } = require('./memo-action-helpers')
registerMemoActionTests({
Action: MemoSetName,
method: 'setName',
MAX: 77,
lengthCode: 'name_length',
validationCode: 'name_validation',
label: 'setting a name',
storeKey: 'profiles',
storeFactory: fakeProfiles,
assertStoreEmpty: (profiles, wallet) => assert.equal(profiles.getName(wallet.walletInfo.cashAddress), null),
byteBased: true
})
test('MEMO_SET_NAME_PREFIX is the Memo set-name action 0x6d01', () => {
assert.equal(MemoSetName.MEMO_SET_NAME_PREFIX, '6d01')
})
test('setting a valid name broadcasts an OP_RETURN with the Memo set-name prefix and name', async () => {
const wallet = fakeWallet()
const profiles = fakeProfiles()
const memoSetName = new MemoSetName({ wallet, profiles })
const txid = await memoSetName.setName('trout')
assert.equal(txid, 'fake-txid')
assert.equal(wallet.broadcasts.length, 1)
const b = wallet.broadcasts[0]
assert.equal(b.prefix, '6d01')
assert.equal(b.msg, 'trout')
// The profile store reflects the new name for this address.
assert.equal(profiles.getName(wallet.walletInfo.cashAddress), 'trout')
})
test('setting an empty name throws a validation error and broadcasts nothing', async () => {
const wallet = fakeWallet()
const profiles = fakeProfiles()
const memoSetName = new MemoSetName({ wallet, profiles })
await assert.rejects(
memoSetName.setName(''),
(err) => err.code === 'name_validation'
)
assert.equal(wallet.broadcasts.length, 0)
assert.equal(profiles.getName(wallet.walletInfo.cashAddress), null)
})
test('setting a name without a wallet reports a missing-wallet error', async () => {
const memoSetName = new MemoSetName({})
await assert.rejects(
memoSetName.setName('trout'),
(err) => /wallet/i.test(err.message)
)
})
test('a failed broadcast does not update the profile store', async () => {
const wallet = fakeWallet()
wallet.sendOpReturn = async () => { throw new Error('broadcast failure') }
const profiles = fakeProfiles()
const memoSetName = new MemoSetName({ wallet, profiles })
await assert.rejects(
memoSetName.setName('trout'),
(err) => /broadcast failure/i.test(err.message)
)
assert.equal(wallet.broadcasts.length, 0)
assert.equal(profiles.getName(wallet.walletInfo.cashAddress), null)
})
+129
View File
@@ -0,0 +1,129 @@
/*
Unit tests for the New Post Page behavior slice (src/services/new-post.js).
Expresses the observable behavior described by specs/memo-new.feature:
- posting a valid memo broadcasts an OP_RETURN with the Memo post prefix and
navigates the user to the recent feed.
- an empty memo is rejected with a validation error; nothing is broadcast.
- an over-long memo is rejected with a length error; nothing is broadcast.
- the character counter counts down from the memo limit.
- the navigation menu links to /posts/new.
*/
'use strict'
const test = require('node:test')
const assert = require('node:assert/strict')
const MemoPost = require('../../src/services/memo-post')
const NewPostPage = require('../../src/services/new-post')
const { fakeWallet } = require('../helpers/fake-wallet')
const { registerPageControllerTests, registerPageSubmitTests } = require('./page-controller-helpers')
const { buildPage } = require('./page-build-helpers')
const MAX = MemoPost.MAX_MEMO_CHARS // 217
function fakeFeed () {
const posts = []
return { posts, addPost: (p) => posts.push(p) }
}
function build () {
return buildPage({
Page: NewPostPage,
Action: MemoPost,
actionKey: 'memoPost',
storeKey: 'feed',
storeFactory: fakeFeed
})
}
function buildBarePage (navigations) {
return new NewPostPage({ navigate: (p) => navigations.push(p) })
}
test('NEW_POST_PATH and RECENT_FEED_PATH constants', () => {
assert.equal(NewPostPage.NEW_POST_PATH, '/posts/new')
assert.equal(NewPostPage.RECENT_FEED_PATH, '/posts/recent')
})
test('the new post page is linked from the navigation menu', () => {
const { page } = build()
assert.equal(page.hasMenuLink('/posts/new'), true)
})
test('the character counter counts down from the memo limit for an empty memo', () => {
const { page } = build()
page.setInput('')
assert.equal(page.remainingCount(), MAX)
})
test('the character counter counts down from the memo limit for a short memo', () => {
const { page } = build()
page.setInput('hello')
assert.equal(page.remainingCount(), MAX - 5)
})
test('the character counter reaches zero at the memo limit', () => {
const { page } = build()
page.setInput('x'.repeat(MAX))
assert.equal(page.remainingCount(), 0)
})
registerPageSubmitTests({
buildPage: build,
verb: 'posting',
label: 'memo',
busyFlag: 'posting',
prefix: '6d02',
validationCode: 'memo_validation',
lengthCode: 'memo_length',
MAX,
successPath: '/posts/recent',
assertBroadcastMsg: (broadcast) => assert.equal(broadcast.msg, 'hello memo'),
assertStore: (store) => assert.equal(store.posts[0].text, 'hello memo'),
assertStoreEmpty: (store) => assert.equal(store.posts.length, 0)
})
test('the new post page starts idle (not posting)', () => {
const { page } = build()
assert.equal(page.posting, false)
})
registerPageControllerTests({
buildPage: build,
buildBarePage,
busyFlag: 'posting',
prefix: '6d02'
})
test('a failed broadcast surfaces a different real error message', async () => {
const wallet = fakeWallet()
wallet.failWith = 'Insufficient balance'
const page = new NewPostPage({
memoPost: new MemoPost({ wallet }),
navigate: () => {}
})
page.setInput('hello memo')
const result = await page.submit()
assert.equal(result.ok, false)
assert.match(page.broadcastError, /Insufficient balance/)
})
test('a broadcast failure with an empty message falls back to a string form', async () => {
const wallet = fakeWallet()
// Throw an Error with an empty message so the message fallback path is exercised.
wallet.sendOpReturn = async () => { throw new Error('') }
const page = new NewPostPage({ memoPost: new MemoPost({ wallet }), navigate: () => {} })
page.setInput('hello memo')
const result = await page.submit()
assert.equal(result.ok, false)
assert.equal(page.submitError, 'broadcast')
// The real (string) error is surfaced even though the message was empty.
assert.equal(typeof page.broadcastError, 'string')
assert.ok(page.broadcastError.length > 0)
})
+66
View File
@@ -0,0 +1,66 @@
/*
Unit tests for the optimistic reply object builder
(src/services/optimistic-reply.js).
The reply form renders an optimistic reply immediately after a successful
broadcast, before the thread is refreshed from the network. This module
shapes that reply object so the shape is covered by unit tests and the
React form stays a thin adapter.
*/
'use strict'
const test = require('node:test')
const assert = require('node:assert/strict')
const { buildOptimisticReply } = require('../../src/services/optimistic-reply')
const TXID = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
const ADDR = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d'
test('builds a reply with a zero reply count and no child replies', () => {
const reply = buildOptimisticReply({
txid: TXID,
addr: ADDR,
text: 'hello',
seen: 1234,
blockHeight: 800000,
displayName: null
})
assert.equal(reply.txid, TXID)
assert.equal(reply.addr, ADDR)
assert.equal(reply.text, 'hello')
assert.equal(reply.seen, 1234)
assert.equal(reply.blockHeight, 800000)
assert.equal(reply.replyCount, 0)
assert.deepEqual(reply.replies, [])
assert.equal(reply.profile, undefined)
})
test('attaches a profile when a display name is present', () => {
const reply = buildOptimisticReply({
txid: TXID,
addr: ADDR,
text: 'hello',
seen: 1234,
blockHeight: undefined,
displayName: 'Trout'
})
assert.deepEqual(reply.profile, { name: 'Trout' })
assert.equal(reply.blockHeight, undefined)
})
test('preserves an absent block height', () => {
const reply = buildOptimisticReply({
txid: TXID,
addr: ADDR,
text: 'hello',
seen: 1234,
blockHeight: null,
displayName: null
})
assert.equal(reply.blockHeight, null)
})
+28
View File
@@ -0,0 +1,28 @@
'use strict'
const { fakeWallet } = require('../helpers/fake-wallet')
// Build a page controller wired to a working action and a navigation recorder.
// Both the New Post and Set Name pages extend PageController and take a single
// action dependency plus a navigate callback, so the wiring is identical; only
// the page-specific pieces differ. `cfg` supplies:
// Page - the page controller class (NewPostPage or SetNamePage)
// Action - the action class (MemoPost or MemoSetName)
// actionKey - the page's action dependency key ('memoPost' or 'memoSetName')
// storeKey - the action's store dependency key ('feed' or 'profiles')
// storeFactory - () => a fresh store
// pageDeps - extra dependencies passed to the page constructor
function buildPage ({ Page, Action, actionKey, storeKey, storeFactory, pageDeps = {} }) {
const wallet = fakeWallet()
const store = storeFactory()
const action = new Action({ wallet, [storeKey]: store })
const navigations = []
const page = new Page({
[actionKey]: action,
navigate: (path) => navigations.push(path),
...pageDeps
})
return { wallet, store, action, page, navigations }
}
module.exports = { buildPage }
+131
View File
@@ -0,0 +1,131 @@
'use strict'
const test = require('node:test')
const assert = require('node:assert/strict')
// Register the page-controller tests shared by the New Post and Set Name pages.
// Both pages extend PageController, so the in-flight flag, missing-handler, and
// broadcast-failure behaviors are identical; only the page-specific pieces
// differ. `cfg` supplies:
// buildPage - () => ({ wallet, page, navigations }) with a working handler
// buildBarePage - (navigations) => a page with no action handler
// busyFlag - the page's in-flight flag name ('posting' or 'settingName')
// prefix - the broadcast prefix ('6d02' or '6d01')
function registerPageControllerTests (cfg) {
const { buildPage, buildBarePage, busyFlag, prefix } = cfg
test(`${busyFlag} is true while a submit is in flight and false once it settles`, async () => {
const { wallet, page } = buildPage()
// Defer the broadcast so we can observe the in-flight state.
let resolveSend
wallet.sendOpReturn = async () => new Promise((resolve) => { resolveSend = resolve })
page.setInput('hello')
assert.equal(page[busyFlag], false)
const pending = page.submit()
assert.equal(page[busyFlag], true)
// Yield until the async chain reaches the deferred sendOpReturn call.
await new Promise((resolve) => setImmediate(resolve))
assert.equal(typeof resolveSend, 'function')
resolveSend('in-flight-txid')
await pending
assert.equal(page[busyFlag], false)
})
test('submitting without a handler reports an error and does not navigate', async () => {
const navigations = []
const page = buildBarePage(navigations)
page.setInput('hello')
const result = await page.submit()
assert.equal(result.ok, false)
assert.deepEqual(navigations, [])
})
test('a failed broadcast surfaces the real error and does not navigate', async () => {
const { wallet, page, navigations } = buildPage()
wallet.failWith = 'BCH UTXO list is empty'
page.setInput('hello')
const result = await page.submit()
assert.equal(result.ok, false)
assert.equal(page.submitError, 'broadcast')
assert.match(page.broadcastError, /BCH UTXO list is empty/)
// The broadcast was attempted (recorded) before it failed.
assert.equal(wallet.broadcasts.length, 1)
assert.equal(wallet.broadcasts[0].prefix, prefix)
// The user stays on the page.
assert.deepEqual(navigations, [])
})
}
// Register the page-submit tests shared by the New Post and Reply Thread pages.
// Both pages extend PageController and submit a single action, so the valid,
// empty, and over-long submit behaviors are identical; only the page-specific
// pieces differ. `cfg` supplies:
// buildPage - () => ({ wallet, store, page, navigations })
// verb - the action verb for test names ('posting' or 'submitting')
// label - the noun for test names ('memo' or 'reply')
// busyFlag - the page's in-flight flag name ('posting' or 'replying')
// prefix - the broadcast prefix ('6d02' or '6d03')
// validationCode - the validation error code
// lengthCode - the length error code
// MAX - the length limit
// successPath - the navigation path on success, or null for no navigation
// assertBroadcastMsg - (broadcast) => asserts the broadcast message (optional)
// assertStore - (store, wallet) => asserts the store reflects the value
// assertStoreEmpty - (store, wallet) => asserts the store was not updated
function registerPageSubmitTests (cfg) {
const { buildPage, verb, label, busyFlag, prefix, validationCode, lengthCode, MAX, successPath, assertBroadcastMsg, assertStore, assertStoreEmpty } = cfg
test(`${verb} a valid ${label} broadcasts the ${prefix} prefix and reflects it`, async () => {
const { wallet, store, page, navigations } = buildPage()
page.setInput('hello memo')
const result = await page.submit()
assert.equal(result.ok, true)
assert.equal(page[busyFlag], false)
assert.equal(wallet.broadcasts.length, 1)
assert.equal(wallet.broadcasts[0].prefix, prefix)
if (assertBroadcastMsg) assertBroadcastMsg(wallet.broadcasts[0])
assert.deepEqual(navigations, successPath ? [successPath] : [])
assertStore(store, wallet)
})
test(`${verb} an empty ${label} is rejected with a validation error and nothing is broadcast`, async () => {
const { wallet, store, page, navigations } = buildPage()
page.setInput('')
const result = await page.submit()
assert.equal(result.ok, false)
assert.equal(result.error, validationCode)
assert.equal(page.submitError, validationCode)
assert.equal(page[busyFlag], false)
assert.equal(wallet.broadcasts.length, 0)
assertStoreEmpty(store, wallet)
assert.deepEqual(navigations, [])
})
test(`${verb} an over-long ${label} is rejected with a length error and nothing is broadcast`, async () => {
const { wallet, store, page, navigations } = buildPage()
page.setInput('y'.repeat(MAX + 1))
const result = await page.submit()
assert.equal(result.ok, false)
assert.equal(result.error, lengthCode)
assert.equal(page.submitError, lengthCode)
assert.equal(page[busyFlag], false)
assert.equal(wallet.broadcasts.length, 0)
assertStoreEmpty(store, wallet)
assert.deepEqual(navigations, [])
})
}
module.exports = { registerPageControllerTests, registerPageSubmitTests }
+49
View File
@@ -0,0 +1,49 @@
/*
Unit tests for the session profile store (src/services/profiles.js).
The store indexes display names by BCH cash address so that pages can read
a name immediately after it is broadcast.
*/
'use strict'
const test = require('node:test')
const assert = require('node:assert/strict')
const Profiles = require('../../src/services/profiles')
test('returns null when no name has been set for an address', () => {
const profiles = new Profiles()
assert.equal(profiles.getName('bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d'), null)
})
test('stores and retrieves a name by address', () => {
const profiles = new Profiles()
const addr = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d'
profiles.setName(addr, 'trout')
assert.equal(profiles.getName(addr), 'trout')
})
test('updating a name overwrites the previous value', () => {
const profiles = new Profiles()
const addr = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d'
profiles.setName(addr, 'trout')
profiles.setName(addr, 'salmon')
assert.equal(profiles.getName(addr), 'salmon')
})
test('different addresses keep independent names', () => {
const profiles = new Profiles()
const addr1 = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d'
const addr2 = 'bitcoincash:qq0ktdlgekdszmxhmg7y6a90t9dpj6p0pg3gctn9e'
profiles.setName(addr1, 'trout')
profiles.setName(addr2, 'salmon')
assert.equal(profiles.getName(addr1), 'trout')
assert.equal(profiles.getName(addr2), 'salmon')
})
test('ignores setName for a missing address', () => {
const profiles = new Profiles()
profiles.setName(null, 'trout')
assert.equal(profiles.getName(null), null)
})
+133
View File
@@ -0,0 +1,133 @@
/*
Unit tests for the Reply Thread Page behavior slice (src/services/reply-thread-page.js).
Expresses the observable behavior described by specs/reply-memo.feature:
- replying with a valid message broadcasts an OP_RETURN with the Memo reply
prefix and reflects the reply in the thread.
- an empty reply is rejected with a validation error; nothing is broadcast.
- an over-long reply is rejected with a length error; nothing is broadcast.
- the byte counter counts down from the reply limit.
*/
'use strict'
const test = require('node:test')
const assert = require('node:assert/strict')
const MemoReply = require('../../src/services/memo-reply')
const ReplyThreadPage = require('../../src/services/reply-thread-page')
const { fakeWallet } = require('../helpers/fake-wallet')
const { registerPageControllerTests, registerPageSubmitTests } = require('./page-controller-helpers')
const { buildPage } = require('./page-build-helpers')
const MAX = MemoReply.MAX_REPLY_BYTES // 184
const PARENT_TXID = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
function fakeThread (rootTxid = PARENT_TXID) {
const replies = []
return { rootTxid, replies, addReply: (r) => replies.push(r) }
}
function build () {
return buildPage({
Page: ReplyThreadPage,
Action: MemoReply,
actionKey: 'memoReply',
storeKey: 'thread',
storeFactory: fakeThread,
pageDeps: { parentTxid: PARENT_TXID }
})
}
function buildBarePage (navigations) {
return new ReplyThreadPage({ navigate: (p) => navigations.push(p) })
}
test('REPLY_THREAD_PATH constant', () => {
assert.equal(ReplyThreadPage.REPLY_THREAD_PATH, '/posts/thread')
})
test('the byte counter counts down from the reply limit for an empty reply', () => {
const { page } = build()
page.setInput('')
assert.equal(page.remainingCount(), MAX)
})
test('the byte counter counts down from the reply limit for a short reply', () => {
const { page } = build()
page.setInput('hello')
assert.equal(page.remainingCount(), MAX - 5)
})
test('the byte counter counts multi-byte characters by bytes, not characters', () => {
const { page } = build()
page.setInput('é')
assert.equal(page.remainingCount(), MAX - 2)
})
test('the byte counter reaches zero at the reply byte limit', () => {
const { page } = build()
page.setInput('x'.repeat(MAX))
assert.equal(page.remainingCount(), 0)
})
registerPageSubmitTests({
buildPage: build,
verb: 'submitting',
label: 'reply',
busyFlag: 'replying',
prefix: '6d03',
validationCode: 'reply_validation',
lengthCode: 'reply_length',
MAX,
successPath: null,
assertStore: (store) => {
assert.equal(store.replies[0].text, 'hello memo')
assert.equal(store.replies[0].parentTxid, PARENT_TXID)
},
assertStoreEmpty: (store) => assert.equal(store.replies.length, 0)
})
test('the reply page starts idle (not replying)', () => {
const { page } = build()
assert.equal(page.replying, false)
})
registerPageControllerTests({
buildPage: build,
buildBarePage,
busyFlag: 'replying',
prefix: '6d03'
})
test('replying to a nested reply uses the selected parent txid', async () => {
const nestedTxid = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'
const { store, page } = build()
page.setParent(nestedTxid)
page.setInput('hello nested')
const result = await page.submit()
assert.equal(result.ok, true)
assert.equal(store.replies[0].parentTxid, nestedTxid)
assert.equal(store.replies[0].text, 'hello nested')
})
test('the reply page navigates to a configured success path on success', async () => {
const wallet = fakeWallet()
const thread = fakeThread()
const memoReply = new MemoReply({ wallet, thread })
const navigations = []
const page = new ReplyThreadPage({
memoReply,
navigate: (p) => navigations.push(p),
successPath: '/custom-path',
parentTxid: PARENT_TXID
})
page.setInput('hello memo')
const result = await page.submit()
assert.equal(result.ok, true)
assert.deepEqual(navigations, ['/custom-path'])
})
+129
View File
@@ -0,0 +1,129 @@
/*
Unit tests for the Set Name Page behavior slice (src/services/set-name-page.js).
Expresses the observable behavior described by specs/set-name.feature:
- setting a valid name broadcasts an OP_RETURN with the Memo set-name prefix
and navigates the user to the account page.
- an empty name is rejected with a validation error; nothing is broadcast.
- an over-long name is rejected with a length error; nothing is broadcast.
- the byte counter counts down from the name limit.
*/
'use strict'
const test = require('node:test')
const assert = require('node:assert/strict')
const MemoSetName = require('../../src/services/memo-set-name')
const SetNamePage = require('../../src/services/set-name-page')
const { fakeProfiles } = require('../helpers/fake-profiles')
const { registerPageControllerTests } = require('./page-controller-helpers')
const { buildPage } = require('./page-build-helpers')
const MAX = MemoSetName.MAX_NAME_BYTES // 77
function build () {
return buildPage({
Page: SetNamePage,
Action: MemoSetName,
actionKey: 'memoSetName',
storeKey: 'profiles',
storeFactory: fakeProfiles
})
}
function buildBarePage (navigations) {
return new SetNamePage({ navigate: (p) => navigations.push(p) })
}
test('SET_NAME_PATH and ACCOUNT_PATH constants', () => {
assert.equal(SetNamePage.SET_NAME_PATH, '/memo/set-name')
assert.equal(SetNamePage.ACCOUNT_PATH, '/account')
})
test('the byte counter counts down from the name limit for an empty name', () => {
const { page } = build()
page.setInput('')
assert.equal(page.remainingCount(), MAX)
})
test('the byte counter counts multi-byte characters by bytes, not characters', () => {
const { page } = build()
page.setInput('é')
assert.equal(page.remainingCount(), MAX - 2)
})
test('the byte counter reaches zero at the byte limit with multi-byte characters', () => {
const { page } = build()
page.setInput('é'.repeat(38))
assert.equal(page.remainingCount(), 1)
})
test('the byte counter counts down from the name limit for a short name', () => {
const { page } = build()
page.setInput('trout')
assert.equal(page.remainingCount(), MAX - 5)
})
test('the byte counter reaches zero at the name limit', () => {
const { page } = build()
page.setInput('x'.repeat(MAX))
assert.equal(page.remainingCount(), 0)
})
test('setting a valid name broadcasts the Memo set-name prefix and navigates to the account page', async () => {
const { wallet, store, page, navigations } = build()
page.setInput('trout')
const result = await page.submit()
assert.equal(result.ok, true)
assert.equal(page.settingName, false)
assert.equal(wallet.broadcasts.length, 1)
assert.equal(wallet.broadcasts[0].prefix, '6d01')
assert.equal(wallet.broadcasts[0].msg, 'trout')
assert.deepEqual(navigations, ['/account'])
assert.equal(store.getName(wallet.walletInfo.cashAddress), 'trout')
})
test('setting an empty name is rejected with a validation error and nothing is broadcast', async () => {
const { wallet, store, page, navigations } = build()
page.setInput('')
const result = await page.submit()
assert.equal(result.ok, false)
assert.equal(result.error, 'name_validation')
assert.equal(page.submitError, 'name_validation')
assert.equal(page.settingName, false)
assert.equal(wallet.broadcasts.length, 0)
assert.equal(store.getName(wallet.walletInfo.cashAddress), null)
assert.deepEqual(navigations, [])
})
test('setting an over-long name is rejected with a length error and nothing is broadcast', async () => {
const { wallet, store, page, navigations } = build()
page.setInput('y'.repeat(MAX + 1))
const result = await page.submit()
assert.equal(result.ok, false)
assert.equal(result.error, 'name_length')
assert.equal(page.submitError, 'name_length')
assert.equal(page.settingName, false)
assert.equal(wallet.broadcasts.length, 0)
assert.equal(store.getName(wallet.walletInfo.cashAddress), null)
assert.deepEqual(navigations, [])
})
test('the set name page starts idle (not setting name)', () => {
const { page } = build()
assert.equal(page.settingName, false)
})
registerPageControllerTests({
buildPage: build,
buildBarePage,
busyFlag: 'settingName',
prefix: '6d01'
})
+38
View File
@@ -0,0 +1,38 @@
/*
Unit tests for the UTF-8 byte-length helper (src/services/utf8.js).
The helper must report the same UTF-8 byte length as Node's Buffer without
depending on the Node-only `Buffer` global, so the browser build (which has
no Buffer) can count bytes for the Memo set-name counter and length check.
*/
'use strict'
const test = require('node:test')
const assert = require('node:assert/strict')
const { byteLength } = require('../../src/services/utf8')
test('byteLength matches Buffer.byteLength for ASCII text', () => {
for (const s of ['', 'trout', 'a longer name with spaces', 'x'.repeat(77)]) {
assert.equal(byteLength(s), Buffer.byteLength(s, 'utf8'))
}
})
test('byteLength matches Buffer.byteLength for multi-byte characters', () => {
for (const s of ['é', 'é'.repeat(38), '😀', '😀'.repeat(20), '日本語']) {
assert.equal(byteLength(s), Buffer.byteLength(s, 'utf8'))
}
})
test('byteLength counts UTF-8 bytes, not characters', () => {
// 'é' is 1 character but 2 UTF-8 bytes; an emoji is 1 character but 4 bytes.
assert.equal(byteLength('é'), 2)
assert.equal(byteLength('😀'), 4)
assert.equal(byteLength('a'), 1)
})
test('byteLength coerces non-string input to a string', () => {
assert.equal(byteLength(42), 2)
assert.equal(byteLength(null), 4) // String(null) === 'null'
})