Merge commit 'd33fda88ba' into swarmforge-refactorer

This commit is contained in:
Chris Troutner
2026-08-25 21:10:20 -07:00
14 changed files with 873 additions and 19 deletions
+159 -5
View File
@@ -2,27 +2,33 @@
Project step handlers for the psf-memo-client acceptance pipeline.
These handlers connect Gherkin step text to real project behavior
(src/services/memo-post.js and src/services/new-post.js), driving them
through small injected adapters (a fake wallet, a fake feed, and a fake
navigator) so the acceptance run is deterministic and offline.
(src/services/memo-post.js, src/services/new-post.js, src/services/memo-reply.js,
src/services/reply-thread-page.js, src/services/memo-set-name.js, and
src/services/set-name-page.js), driving them through small injected adapters
(a fake wallet, a fake feed, a fake thread, and a fake navigator) so the
acceptance run is deterministic and offline.
Regex matching with placeholder-name capture is the default style: a single
handler pattern captures the placeholder name (e.g. <message>) and fetches
the example value from the scenario example store.
The handlers serve both specs/post-memo.feature and specs/memo-new.feature,
whose wording differs but which share the same underlying Memo post behavior.
The handlers serve specs/post-memo.feature, specs/memo-new.feature,
specs/reply-memo.feature, and specs/set-name.feature, whose wording differs
but which share the same underlying Memo action/page-controller behavior.
*/
'use strict'
const MemoPost = require('../../src/services/memo-post')
const NewPostPage = require('../../src/services/new-post')
const MemoReply = require('../../src/services/memo-reply')
const ReplyThreadPage = require('../../src/services/reply-thread-page')
const MemoSetName = require('../../src/services/memo-set-name')
const SetNamePage = require('../../src/services/set-name-page')
const AccountPage = require('../../src/services/account-page')
const MEMO_POST_PREFIX = MemoPost.MEMO_POST_PREFIX
const MEMO_REPLY_PREFIX = MemoReply.MEMO_REPLY_PREFIX
const MEMO_SET_NAME_PREFIX = MemoSetName.MEMO_SET_NAME_PREFIX
// A fake wallet exposing the minimal-slp-wallet adapter surface the app uses.
@@ -63,15 +69,29 @@ function makeProfiles () {
}
}
// A fake thread store recording replies added to a post thread.
function makeThread () {
const replies = []
return {
rootTxid: null,
replies,
addReply: (r) => replies.push(r)
}
}
// Fresh world/state object for a single scenario execution.
function createWorld () {
const wallet = makeWallet('')
const feed = makeFeed()
const memoPost = new MemoPost({ wallet, feed })
const thread = makeThread()
const memoReply = new MemoReply({ wallet, thread })
const world = {
wallet,
feed,
thread,
memoPost,
memoReply,
currentPath: null,
menuOpen: false
}
@@ -84,6 +104,13 @@ function createWorld () {
menuLinks: []
})
// The Reply Thread Page controller wraps the memo reply behavior. It does
// not navigate on success so the user stays in the thread modal.
world.replyPage = new ReplyThreadPage({
memoReply,
navigate: () => {}
})
// The Set Name Page and Account Page controllers share a profile store so
// a name set on one page is visible on the other.
const profiles = makeProfiles()
@@ -101,6 +128,14 @@ function createWorld () {
return world
}
// Decode a raw reply payload into its parent txid (hex) and reply text.
function decodeReplyPayload (raw) {
const buf = Buffer.from(raw)
const parentTxid = buf.slice(0, 32).toString('hex')
const text = buf.slice(32).toString('utf8')
return { parentTxid, text }
}
// Handler registry. Each entry: { pattern, run }.
// run receives (match, exampleStore, world, step).
const handlers = [
@@ -213,6 +248,63 @@ const handlers = [
await world.setNamePage.submit()
}
},
{
name: 'open reply thread',
pattern: /^I open the thread for the post with txid (.+)$/,
run (m, example, world) {
const txid = m[1].trim()
world.thread.rootTxid = txid
world.replyPage.setParent(txid)
}
},
{
name: 'type reply text',
pattern: /^I type a reply with the text "<([A-Za-z0-9_]+)>"$/,
run (m, example, world) {
const param = m[1]
if (!(param in example)) {
throw new Error(`Missing example value for "${param}"`)
}
world.replyPage.setInput(example[param])
world.replyPage.setParent(world.thread.rootTxid)
}
},
{
name: 'type reply to nested reply',
pattern: /^I type a reply to the nested reply with the text "<([A-Za-z0-9_]+)>"$/,
run (m, example, world) {
const param = m[1]
if (!(param in example)) {
throw new Error(`Missing example value for "${param}"`)
}
world.replyPage.setInput(example[param])
if (!world.nestedTxid) {
throw new Error('No nested reply has been selected.')
}
world.replyPage.setParent(world.nestedTxid)
}
},
{
name: 'submit reply',
pattern: /^I submit the reply$/,
async run (m, example, world) {
await world.replyPage.submit()
}
},
{
name: 'thread shows nested reply',
pattern: /^the thread shows a nested reply with the txid (.+)$/,
run (m, example, world) {
const txid = m[1].trim()
world.nestedTxid = txid
world.thread.addReply({
txid,
address: 'someone-else',
text: 'nested reply',
parentTxid: world.thread.rootTxid
})
}
},
{
name: 'click Set Name button',
pattern: /^I click the Set Name button$/,
@@ -254,6 +346,68 @@ const handlers = [
}
}
},
{
name: 'broadcasts OP_RETURN with Memo reply prefix',
pattern: /^(?:the wallet|the app) broadcasts an OP_RETURN transaction with the Memo reply prefix$/,
run (m, example, world) {
const broadcasts = world.wallet.broadcasts
if (!broadcasts.length) {
throw new Error('No OP_RETURN transaction was broadcast.')
}
const last = broadcasts[broadcasts.length - 1]
if (last.prefix !== MEMO_REPLY_PREFIX) {
throw new Error(`Expected Memo reply prefix ${MEMO_REPLY_PREFIX}, got "${last.prefix}".`)
}
const { parentTxid, text } = decodeReplyPayload(last.msg)
if (parentTxid !== world.replyPage.parentTxid) {
throw new Error('Broadcast parent txid did not match the expected reply target.')
}
if (text !== world.replyPage.input) {
throw new Error('Broadcast reply text did not match the typed reply.')
}
}
},
{
name: 'thread shows new reply from my address',
pattern: /^the thread shows a new reply from my address with the text "<([A-Za-z0-9_]+)>"$/,
run (m, example, world) {
const param = m[1]
const expectedText = example[param]
const myAddress = world.wallet.walletInfo.cashAddress
const found = world.thread.replies.find(
(r) => r.text === expectedText && r.address === myAddress
)
if (!found) {
throw new Error(`Thread does not show the new reply with text "${expectedText}".`)
}
}
},
{
name: 'thread shows validation/length error',
pattern: /^the thread shows a (validation|length) error$/,
run (m, example, world) {
const kind = m[1]
const expectedCode = kind === 'validation' ? 'reply_validation' : 'reply_length'
if (world.replyPage.submitError !== expectedCode) {
throw new Error(`Expected ${expectedCode}, got ${world.replyPage.submitError}.`)
}
}
},
{
name: 'thread remaining byte count',
pattern: /^the thread shows a remaining byte count of <([A-Za-z0-9_]+)>$/,
run (m, example, world) {
const param = m[1]
const expected = parseInt(example[param], 10)
if (Number.isNaN(expected)) {
throw new Error(`Invalid expected count for "${param}".`)
}
const actual = world.replyPage.remainingCount()
if (actual !== expected) {
throw new Error(`Expected ${expected} remaining bytes, got ${actual}.`)
}
}
},
{
name: 'feed shows new post from my address',
pattern: /^the feed shows a new post from my address with the text "<([A-Za-z0-9_]+)>"$/,
+32 -7
View File
@@ -104,12 +104,20 @@ Saved (and updated) at `specs/feature-backlog.md`. memo protocol action bytes be
**Completed ✓ (merged to `feat1`):**
- Post a Memo (`0x6d02`) — service + `/posts/new` page + broadcast fix. FULLY DONE.
- Set display name (`0x6d01`) — `/account` + `/memo/set-name` pages, byte counter (77 bytes). DONE.
**Tier P1 — Core social verbs (write + read) — do these next, in order:**
1. ✅ Post a Memo (`0x6d02`) — DONE
2. Set display name (`0x6d01`) — **NEXT** (breadcrumb: the feed already renders
display names; the write action is missing)
3. Reply to a Memo (`0x6d03`) — thread already renders; add reply broadcast
2. Set display name (`0x6d01`) — DONE
3. Reply to a Memo (`0x6d03`) — **NEXT** (thread already renders; add reply broadcast)
- **User-approved decisions (2026-08-26, from memo.cash UI review):**
- Reply max = **184 bytes** (UTF-8 byte count, memo.cash `MaxSize.Reply`).
- Reply form lives **inside the thread modal** (not inline in the feed).
- Keep the existing comment-icon behavior (opens the thread modal); put the reply
form in the modal.
- Replicate the live `[remaining]` byte counter (turns red when over limit).
- Update the thread **optimistically** after broadcast (no refresh).
- Users can **reply to a reply** (nested), not just the root post.
4. Like / tip a Memo (`0x6d04`)
5. Set profile text / bio (`0x6d05`)
6. Set profile picture (`0x6d0a`)
@@ -228,11 +236,24 @@ specing reply/like/follow.
(`Failed to broadcast: <msg>`). Keep that behavior in specs.
6. **memo.cash pages are behind Cloudflare** — `/memo/new` etc. are hard to scrape; rely
on user-provided behavior details and the protocol spec.
7. **Byte vs char:** the 217 limit and the counter currently count characters
(`input.length`, UTF-16), not bytes. The user is aware; multi-byte unicode may
diverge. Ask/decide per feature.
7. **Byte vs char:** the 217 post limit and its counter count characters (`input.length`,
UTF-16), not bytes. The user is aware; multi-byte unicode may diverge. **Set Name
(`0x6d01`) uses BYTE counting (77 bytes) for memo.cash parity** — its byte counter and
length check use UTF-8 byte length. Ask/decide per feature.
8. **Live backend for e2e:** `https://memo-api.fullstackcash.net/` (prod memo-db). The
user can provide BCH for real broadcasts.
9. **memo.cash login is Cloudflare-blocked for automation:** the `/login` page shows a
hard Turnstile challenge that does not auto-resolve, even with a persistent Playwright
profile. The public pages (home, `/all` feed, `/post/<txid>`) DO resolve with a
persistent profile (`launchPersistentContext` + `--headless=new` +
`--disable-blink-features=AutomationControlled` + realistic UA). To explore the
logged-in UI, either solve Turnstile (real session / captcha service) or get
screenshots/HTML from the user. The reply UI was captured from public feed/post pages
plus reverse-engineering `https://memo.cash/js/min.js`:
- login flow `POST /login/submit {username,password,rid,loginToken}` → `SessionKey`;
- reply submit `memo/reply-submit` with `{txHash,message}`;
- `MaxSize.Reply = 184`; reply form = Message label + `[remaining]` byte counter +
textarea + "Post Reply"/"Cancel" + "Creating..."/"Processing..." states.
---
@@ -256,4 +277,8 @@ At the end of each session, update this file:
- Mark features completed in the backlog (§5).
- Add any new gotchas to §10.
- Note the current `feat1` HEAD commit.
- State the next feature to work on (currently: **Set display name, `0x6d01`**).
- State the next feature to work on (currently: **Reply to a Memo, `0x6d03`**).
Current `display-name` HEAD: `230618d` (Set Name buffer fix merged).
Next feature: **Reply to a Memo, `0x6d03`** — spec decisions captured in §5; Gherkin
not yet written; awaiting user approval before handoff to coder.
+8 -3
View File
@@ -63,7 +63,7 @@ action plus its read/display surface. This is the recommended first development
| # | Feature | Memo action | Write | Read surface |
|---|---------|-------------|-------|--------------|
| 1 | Post a Memo | `0x6d02` | Compose + `sendOpReturn` | Appears in recent feed & own profile after indexing |
| 2 | Set display name | `0x6d01` | Broadcast name | Name shown on posts, profiles, feed |
| 2 | Set display name | `0x6d01` | Broadcast name | Name shown on posts, profiles, feed | ✅ DONE |
| 3 | Reply to a Memo | `0x6d03` | Broadcast reply to parent txid | Nested thread view |
| 4 | Like a Memo | `0x6d04` | Broadcast like for a post txid | Like count + liked state on post |
| 5 | Set profile text (bio) | `0x6d05` | Broadcast bio | Shown on profile page |
@@ -77,9 +77,14 @@ follower/following lists; name + profile + avatar joined into feed/profile respo
## Priority order within P1
1. **Post a Memo** — the primary verb; unblocks all others.
2. **Set display name** — makes the feed readable and gives identity.
1. **Post a Memo** — the primary verb; unblocks all others. ✅ DONE
2. **Set display name** — makes the feed readable and gives identity. ✅ DONE
3. **Reply to a Memo** — core conversation; extends the existing thread modal.
- **Decisions (2026-08-26, from memo.cash UI review):** reply max = **184 bytes**
(UTF-8 byte count); reply form **inside the thread modal**; keep the existing
comment-icon behavior (opens the thread modal); replicate the live `[remaining]`
byte counter (turns red when over); update the thread **optimistically** after
broadcast; users can **reply to a reply** (nested).
4. **Like a Memo** — social signal; needs like-count API.
5. **Set profile text** — bio for the profile page.
6. **Set profile picture** — avatar for posts/profiles.
+61
View File
@@ -0,0 +1,61 @@
# Scenarios: Reply to a Memo - 1, Reply to a Memo - 2, Reply to a Memo - 3, Reply to a Memo - 4, Reply to a Memo - 5
Feature: Reply to a Memo
Background:
Given a wallet authenticated for the address bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d
Given the wallet has spendable output to pay the transaction fee
Given I open the thread for the post with txid aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
Scenario Outline: Reply to a Memo - 1 a valid reply to a post is broadcast and shown in the thread
When I type a reply with the text "<message>"
When I submit the reply
Then the wallet broadcasts an OP_RETURN transaction with the Memo reply prefix
Then the thread shows a new reply from my address with the text "<message>"
Examples:
| message |
| hello memo |
| a longer reply with several words. |
Scenario Outline: Reply to a Memo - 2 an empty reply is rejected
When I type a reply with the text "<message>"
When I submit the reply
Then the thread shows a validation error
Then the wallet does not broadcast any transaction
Examples:
| message |
| |
Scenario Outline: Reply to a Memo - 3 an over-long reply is rejected
When I type a reply with the text "<message>"
When I submit the reply
Then the thread shows a length error
Then the wallet does not broadcast any transaction
Examples:
| message |
| aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa |
| bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb |
Scenario Outline: Reply to a Memo - 4 the byte counter counts down from the reply limit
When I type a reply with the text "<message>"
Then the thread shows a remaining byte count of <count>
Examples:
| message | count |
| | 184 |
| hello | 179 |
| aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa | 0 |
Scenario Outline: Reply to a Memo - 5 a reply to a nested reply is broadcast
And the thread shows a nested reply with the txid bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
When I type a reply to the nested reply with the text "<message>"
When I submit the reply
Then the wallet broadcasts an OP_RETURN transaction with the Memo reply prefix
Then the thread shows a new reply from my address with the text "<message>"
Examples:
| message |
| hello nested |
| a longer nested reply. |
+2 -1
View File
@@ -12,6 +12,7 @@ import { useNavigate } from 'react-router-dom'
// Local libraries
import MemoSetName from '../../../services/memo-set-name'
import SetNamePage from '../../../services/set-name-page'
import { byteLength } from '../../../services/utf8'
function SetName (props) {
const { appData } = props
@@ -22,7 +23,7 @@ function SetName (props) {
const [err, setErr] = useState('')
const [settingName, setSettingName] = useState(false)
const remaining = maxBytes - Buffer.byteLength(input, 'utf8')
const remaining = maxBytes - byteLength(input)
async function handleSubmit (event) {
event.preventDefault()
+112
View File
@@ -0,0 +1,112 @@
/*
Memo reply behavior: compose, validate, and broadcast a Memo "reply" message.
A Memo reply is an OP_RETURN Bitcoin Cash transaction carrying the Memo reply
protocol prefix (0x6d03) followed by the parent transaction hash (32 bytes)
and the reply message text. Broadcasting is done through a wallet that
exposes the minimal-slp-wallet adapter surface (walletInfo, getUtxos(),
sendOpReturn()).
The wallet and thread are injected so this module stays testable and free of
network/UI concerns; environmentally unsuitable I/O lives behind those small
adapter boundaries.
Constants
MEMO_REPLY_PREFIX : hex prefix for the Memo "reply" action (0x6d03)
MAX_REPLY_BYTES : maximum allowed reply text length (184 bytes)
*/
const MemoAction = require('./memo-action')
const { byteLength } = require('./utf8')
const MEMO_REPLY_PREFIX = '6d03'
const MAX_REPLY_BYTES = 184
const PARENT_TXID_BYTES = 32
class MemoReply extends MemoAction {
static config = {
prefix: MEMO_REPLY_PREFIX,
walletRequiredMsg: 'Memo reply requires a wallet.',
lengthMessage: `Reply is too long. Maximum is ${MAX_REPLY_BYTES} bytes.`,
emptyMessage: 'Reply must not be empty.',
lengthCode: 'reply_length',
validationCode: 'reply_validation'
}
constructor (deps = {}) {
super(deps)
this.thread = deps.thread
}
// A reply is over-length when its UTF-8 byte count exceeds the limit.
isTooLong (message) {
return byteLength(message) > MAX_REPLY_BYTES
}
// Compose and broadcast a Memo reply for the given message and parent txid.
// Resolves with the transaction id, or rejects with a typed error.
async reply (message, parentTxid) {
const check = this.validate(message)
this._throwIfInvalid(check)
if (!this.wallet) {
throw new Error(this.walletRequiredMsg)
}
// Refresh the wallet's spendable UTXO store so the broadcast has inputs.
await this.wallet.getUtxos()
// Build the raw payload: parent txid bytes followed by UTF-8 message bytes.
const raw = buildReplyPayload(parentTxid, message)
const txid = await this.wallet.sendOpReturn(raw, this.prefix)
// Reflect the result on the injected thread once broadcast succeeds.
this.reflect(txid, message, parentTxid)
return txid
}
// Record the new reply on the injected thread store when one is present.
reflect (txid, message, parentTxid) {
if (this.thread && typeof this.thread.addReply === 'function') {
this.thread.addReply({
txid,
address: this.wallet.walletInfo.cashAddress,
text: message,
parentTxid
})
}
}
}
// Build the raw OP_RETURN message payload for a reply.
// The protocol wire format is: <parent txid 32 bytes><reply text UTF-8 bytes>.
function buildReplyPayload (parentTxid, message) {
const parentBytes = hexToBytes(parentTxid)
const textBytes = new TextEncoder().encode(message)
const raw = new Uint8Array(parentBytes.length + textBytes.length)
raw.set(parentBytes, 0)
raw.set(textBytes, parentBytes.length)
return raw
}
// Decode a 64-character hex transaction id into 32 raw bytes.
function hexToBytes (hex) {
if (typeof hex !== 'string' || hex.length !== PARENT_TXID_BYTES * 2) {
throw new Error('Parent txid must be a 64-character hex string.')
}
const bytes = new Uint8Array(PARENT_TXID_BYTES)
for (let i = 0; i < hex.length; i += 2) {
const byte = parseInt(hex.substr(i, 2), 16)
if (Number.isNaN(byte)) {
throw new Error('Parent txid must be a valid hex string.')
}
bytes[i / 2] = byte
}
return bytes
}
MemoReply.MEMO_REPLY_PREFIX = MEMO_REPLY_PREFIX
MemoReply.MAX_REPLY_BYTES = MAX_REPLY_BYTES
module.exports = MemoReply
+2 -1
View File
@@ -17,6 +17,7 @@
*/
const MemoAction = require('./memo-action')
const { byteLength } = require('./utf8')
const MEMO_SET_NAME_PREFIX = '6d01'
const MAX_NAME_BYTES = 77
@@ -38,7 +39,7 @@ class MemoSetName extends MemoAction {
// A name is over-length when it exceeds the byte limit.
isTooLong (name) {
return Buffer.byteLength(name, 'utf8') > MAX_NAME_BYTES
return byteLength(name) > MAX_NAME_BYTES
}
// Compose and broadcast a Memo set-name transaction for the given name.
+3 -1
View File
@@ -33,7 +33,9 @@ class PageController {
try {
const txid = await this._perform(this.input)
this.navigate(this.successPath)
if (this.successPath) {
this.navigate(this.successPath)
}
this._setBusy(false)
return { ok: true, txid }
} catch (err) {
+59
View File
@@ -0,0 +1,59 @@
/*
Reply Thread Page behavior: compose and submit a reply inside a thread
modal, with a live byte counter that counts down from the reply limit.
This is the testable controller behind the Reply form in the React thread
modal. It wraps the Memo reply behavior (src/services/memo-reply.js) and
adds page-level concerns: holding the current input, tracking the parent txid
being replied to, computing the remaining byte count, and surfacing
validation/length/broadcast errors.
The memoReply and navigate concerns are injected so this module stays free of
UI/network concerns; environmentally unsuitable I/O lives behind those small
adapter boundaries.
*/
const PageController = require('./page-controller')
const MemoReply = require('./memo-reply')
const { byteLength } = require('./utf8')
const REPLY_THREAD_PATH = '/posts/thread'
class ReplyThreadPage extends PageController {
constructor (deps = {}) {
super(deps)
this.memoReply = deps.memoReply || null
this.replying = false
this.successPath = deps.successPath || null
this.validationCodes = ['reply_validation', 'reply_length']
this.parentTxid = deps.parentTxid || null
}
// Set the parent txid that the next reply will be attached to.
setParent (txid) {
this.parentTxid = txid
return this
}
// Bytes remaining before the reply byte limit is reached.
remainingCount () {
return MemoReply.MAX_REPLY_BYTES - byteLength(this.input)
}
// Set the in-flight replying flag.
_setBusy (value) {
this.replying = value
}
// Run the memo reply action for the current input against the current parent.
async _perform (input) {
if (!this.memoReply) {
throw new Error('Reply thread requires a memo reply handler.')
}
return this.memoReply.reply(input, this.parentTxid)
}
}
ReplyThreadPage.REPLY_THREAD_PATH = REPLY_THREAD_PATH
module.exports = ReplyThreadPage
+2 -1
View File
@@ -15,6 +15,7 @@
const PageController = require('./page-controller')
const MemoSetName = require('./memo-set-name')
const { byteLength } = require('./utf8')
const SET_NAME_PATH = '/memo/set-name'
const ACCOUNT_PATH = '/account'
@@ -30,7 +31,7 @@ class SetNamePage extends PageController {
// Bytes remaining before the name limit is reached.
remainingCount () {
return MemoSetName.MAX_NAME_BYTES - Buffer.byteLength(this.input, 'utf8')
return MemoSetName.MAX_NAME_BYTES - byteLength(this.input)
}
// Set the in-flight setting-name flag.
+15
View File
@@ -0,0 +1,15 @@
/*
UTF-8 byte-length helper for browser and Node.
The Node global `Buffer` is not available in the browser, so byte counting
(used by the Memo set-name byte counter and length check) must not depend on
it. TextEncoder is available in both environments and reports the UTF-8 byte
length of a string.
*/
// Return the number of UTF-8 bytes in a string.
function byteLength (str) {
return new TextEncoder().encode(String(str)).length
}
module.exports = { byteLength }
+176
View File
@@ -0,0 +1,176 @@
/*
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 PARENT_TXID = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
// A fake wallet that records every broadcast attempt.
function fakeWallet (cashAddress = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d', utxos = [{ txid: 'utxo1' }]) {
const broadcasts = []
const wallet = {
walletInfo: { cashAddress },
getUtxos: async () => utxos,
sendOpReturn: async (msg, prefix) => {
broadcasts.push({ msg, prefix })
return 'fake-txid'
}
}
wallet.broadcasts = broadcasts
return wallet
}
// 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')
}
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 at the maximum byte length (184) is accepted', async () => {
const wallet = fakeWallet()
const memoReply = new MemoReply({ wallet })
const msg = 'x'.repeat(184)
const txid = await memoReply.reply(msg, PARENT_TXID)
assert.equal(txid, 'fake-txid')
assert.equal(decodeReplyText(wallet.broadcasts[0].msg), msg)
})
test('replying with a multi-byte character at the byte limit is accepted', async () => {
const wallet = fakeWallet()
const memoReply = new MemoReply({ wallet })
// 92 'é' characters encode to 184 UTF-8 bytes.
const msg = 'é'.repeat(92)
assert.equal(Buffer.byteLength(msg, 'utf8'), 184)
const txid = await memoReply.reply(msg, PARENT_TXID)
assert.equal(txid, 'fake-txid')
})
test('replying with an empty message throws a validation error and broadcasts nothing', async () => {
const wallet = fakeWallet()
const thread = fakeThread()
const memoReply = new MemoReply({ wallet, thread })
await assert.rejects(
memoReply.reply('', PARENT_TXID),
(err) => err.code === 'reply_validation'
)
assert.equal(wallet.broadcasts.length, 0)
assert.equal(thread.replies.length, 0)
})
test('replying with a whitespace-only or non-string message throws a validation error and broadcasts nothing', async () => {
for (const invalid of [' ', 42]) {
const wallet = fakeWallet()
const memoReply = new MemoReply({ wallet })
await assert.rejects(
memoReply.reply(invalid, PARENT_TXID),
(err) => err.code === 'reply_validation'
)
assert.equal(wallet.broadcasts.length, 0)
}
})
test('replying with an over-long message (185 bytes) throws a length error and broadcasts nothing', async () => {
const wallet = fakeWallet()
const thread = fakeThread()
const memoReply = new MemoReply({ wallet, thread })
await assert.rejects(
memoReply.reply('y'.repeat(185), PARENT_TXID),
(err) => err.code === 'reply_length'
)
assert.equal(wallet.broadcasts.length, 0)
assert.equal(thread.replies.length, 0)
})
test('replying with a multi-byte character that exceeds the byte limit throws a length error', async () => {
const wallet = fakeWallet()
const memoReply = new MemoReply({ wallet })
// 93 'é' characters encode to 186 UTF-8 bytes, exceeding the 184-byte limit.
const msg = 'é'.repeat(93)
assert.ok(Buffer.byteLength(msg, 'utf8') > 184)
await assert.rejects(
memoReply.reply(msg, PARENT_TXID),
(err) => err.code === 'reply_length'
)
assert.equal(wallet.broadcasts.length, 0)
})
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)
})
+204
View File
@@ -0,0 +1,204 @@
/*
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 MAX = MemoReply.MAX_REPLY_BYTES // 184
const PARENT_TXID = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
function fakeWallet (cashAddress = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d') {
const broadcasts = []
const wallet = {
walletInfo: { cashAddress },
utxos: [{ txid: 'utxo-fee' }],
getUtxos: async function () { return this.utxos },
sendOpReturn: async function (msg, prefix) {
this.broadcasts.push({ msg, prefix })
if (this.failWith) throw new Error(this.failWith)
return 'reply-txid'
}
}
wallet.broadcasts = broadcasts
return wallet
}
function fakeThread (rootTxid = PARENT_TXID) {
const replies = []
return { rootTxid, replies, addReply: (r) => replies.push(r) }
}
function build (deps = {}) {
const wallet = deps.wallet || fakeWallet()
const thread = deps.thread || fakeThread()
const memoReply = new MemoReply({ wallet, thread })
const navigations = []
const page = new ReplyThreadPage({
memoReply,
parentTxid: deps.parentTxid || PARENT_TXID,
navigate: (path) => navigations.push(path)
})
return { wallet, thread, memoReply, page, navigations }
}
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)
})
test('submitting a valid reply broadcasts the Memo reply prefix and reflects it in the thread', async () => {
const { wallet, thread, page, navigations } = build()
page.setInput('hello memo')
const result = await page.submit()
assert.equal(result.ok, true)
assert.equal(page.replying, false)
assert.equal(wallet.broadcasts.length, 1)
assert.equal(wallet.broadcasts[0].prefix, '6d03')
assert.deepEqual(navigations, [])
assert.equal(thread.replies.length, 1)
assert.equal(thread.replies[0].text, 'hello memo')
assert.equal(thread.replies[0].parentTxid, PARENT_TXID)
})
test('submitting an empty reply is rejected with a validation error and nothing is broadcast', async () => {
const { wallet, thread, page, navigations } = build()
page.setInput('')
const result = await page.submit()
assert.equal(result.ok, false)
assert.equal(result.error, 'reply_validation')
assert.equal(page.submitError, 'reply_validation')
assert.equal(page.replying, false)
assert.equal(wallet.broadcasts.length, 0)
assert.equal(thread.replies.length, 0)
assert.deepEqual(navigations, [])
})
test('submitting an over-long reply is rejected with a length error and nothing is broadcast', async () => {
const { wallet, thread, page, navigations } = build()
page.setInput('y'.repeat(MAX + 1))
const result = await page.submit()
assert.equal(result.ok, false)
assert.equal(result.error, 'reply_length')
assert.equal(page.submitError, 'reply_length')
assert.equal(page.replying, false)
assert.equal(wallet.broadcasts.length, 0)
assert.equal(thread.replies.length, 0)
assert.deepEqual(navigations, [])
})
test('the reply page starts idle (not replying)', () => {
const { page } = build()
assert.equal(page.replying, false)
})
test('replying is true while a submit is in flight and false once it settles', async () => {
const wallet = fakeWallet()
const thread = fakeThread()
let resolveSend
wallet.sendOpReturn = async () => new Promise((resolve) => { resolveSend = resolve })
const page = new ReplyThreadPage({
memoReply: new MemoReply({ wallet, thread }),
parentTxid: PARENT_TXID,
navigate: () => {}
})
page.setInput('hello memo')
assert.equal(page.replying, false)
const pending = page.submit()
assert.equal(page.replying, true)
await new Promise((resolve) => setImmediate(resolve))
assert.equal(typeof resolveSend, 'function')
resolveSend('in-flight-txid')
await pending
assert.equal(page.replying, false)
})
test('submitting without a memo reply handler reports an error and does not navigate', async () => {
const navigations = []
const page = new ReplyThreadPage({ navigate: (p) => navigations.push(p) })
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 = fakeWallet()
const thread = fakeThread()
wallet.failWith = 'BCH UTXO list is empty'
const navigations = []
const page = new ReplyThreadPage({
memoReply: new MemoReply({ wallet, thread }),
parentTxid: PARENT_TXID,
navigate: (p) => navigations.push(p)
})
page.setInput('hello memo')
const result = await page.submit()
assert.equal(result.ok, false)
assert.equal(page.submitError, 'broadcast')
assert.match(page.broadcastError, /BCH UTXO list is empty/)
assert.equal(wallet.broadcasts.length, 1)
assert.equal(wallet.broadcasts[0].prefix, '6d03')
assert.deepEqual(navigations, [])
})
test('replying to a nested reply uses the selected parent txid', async () => {
const nestedTxid = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'
const { thread, page } = build()
page.setParent(nestedTxid)
page.setInput('hello nested')
const result = await page.submit()
assert.equal(result.ok, true)
assert.equal(thread.replies[0].parentTxid, nestedTxid)
assert.equal(thread.replies[0].text, 'hello nested')
})
+38
View File
@@ -0,0 +1,38 @@
/*
Unit tests for the UTF-8 byte-length helper (src/services/utf8.js).
The helper must report the same UTF-8 byte length as Node's Buffer without
depending on the Node-only `Buffer` global, so the browser build (which has
no Buffer) can count bytes for the Memo set-name counter and length check.
*/
'use strict'
const test = require('node:test')
const assert = require('node:assert/strict')
const { byteLength } = require('../../src/services/utf8')
test('byteLength matches Buffer.byteLength for ASCII text', () => {
for (const s of ['', 'trout', 'a longer name with spaces', 'x'.repeat(77)]) {
assert.equal(byteLength(s), Buffer.byteLength(s, 'utf8'))
}
})
test('byteLength matches Buffer.byteLength for multi-byte characters', () => {
for (const s of ['é', 'é'.repeat(38), '😀', '😀'.repeat(20), '日本語']) {
assert.equal(byteLength(s), Buffer.byteLength(s, 'utf8'))
}
})
test('byteLength counts UTF-8 bytes, not characters', () => {
// 'é' is 1 character but 2 UTF-8 bytes; an emoji is 1 character but 4 bytes.
assert.equal(byteLength('é'), 2)
assert.equal(byteLength('😀'), 4)
assert.equal(byteLength('a'), 1)
})
test('byteLength coerces non-string input to a string', () => {
assert.equal(byteLength(42), 2)
assert.equal(byteLength(null), 4) // String(null) === 'null'
})