mirror of
https://github.com/Permissionless-Software-Foundation/psf-memo-client.git
synced 2026-09-21 16:52:02 -07:00
Merge commit 'd33fda88ba' into swarmforge-refactorer
This commit is contained in:
+159
-5
@@ -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
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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. |
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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 }
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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