mirror of
https://github.com/Permissionless-Software-Foundation/psf-memo.git
synced 2026-09-21 16:52:01 -07:00
Creating mono-repo from psf-memo-client
This commit is contained in:
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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' }
|
||||
)
|
||||
})
|
||||
@@ -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' }
|
||||
)
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
@@ -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))))
|
||||
@@ -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)))))
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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/
|
||||
)
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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 }
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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'])
|
||||
})
|
||||
@@ -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'
|
||||
})
|
||||
@@ -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'
|
||||
})
|
||||
Reference in New Issue
Block a user