diff --git a/acceptance/lib/handlers.js b/acceptance/lib/handlers.js index 3b54ce4..edf4722 100644 --- a/acceptance/lib/handlers.js +++ b/acceptance/lib/handlers.js @@ -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. ) 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_]+)>"$/, diff --git a/specifier-prompt.md b/specifier-prompt.md index a5956a8..b07bbca 100644 --- a/specifier-prompt.md +++ b/specifier-prompt.md @@ -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: `). 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/`) 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. diff --git a/specs/feature-backlog.md b/specs/feature-backlog.md index 7ba39d1..4da416d 100644 --- a/specs/feature-backlog.md +++ b/specs/feature-backlog.md @@ -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. diff --git a/specs/reply-memo.feature b/specs/reply-memo.feature new file mode 100644 index 0000000..c4ff571 --- /dev/null +++ b/specs/reply-memo.feature @@ -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 "" + 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 "" + + 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 "" + 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 "" + 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 "" + Then the thread shows a remaining byte count of + + 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 "" + 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 "" + + Examples: + | message | + | hello nested | + | a longer nested reply. | diff --git a/src/components/app-body/set-name/index.js b/src/components/app-body/set-name/index.js index 8d6a849..787737d 100644 --- a/src/components/app-body/set-name/index.js +++ b/src/components/app-body/set-name/index.js @@ -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() diff --git a/src/services/memo-reply.js b/src/services/memo-reply.js new file mode 100644 index 0000000..c5d2a14 --- /dev/null +++ b/src/services/memo-reply.js @@ -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: . +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 diff --git a/src/services/memo-set-name.js b/src/services/memo-set-name.js index 4f87773..2763c55 100644 --- a/src/services/memo-set-name.js +++ b/src/services/memo-set-name.js @@ -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. diff --git a/src/services/page-controller.js b/src/services/page-controller.js index 083becd..3b31bc1 100644 --- a/src/services/page-controller.js +++ b/src/services/page-controller.js @@ -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) { diff --git a/src/services/reply-thread-page.js b/src/services/reply-thread-page.js new file mode 100644 index 0000000..a65f480 --- /dev/null +++ b/src/services/reply-thread-page.js @@ -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 diff --git a/src/services/set-name-page.js b/src/services/set-name-page.js index 571a8cc..1337a61 100644 --- a/src/services/set-name-page.js +++ b/src/services/set-name-page.js @@ -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. diff --git a/src/services/utf8.js b/src/services/utf8.js new file mode 100644 index 0000000..52d4531 --- /dev/null +++ b/src/services/utf8.js @@ -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 } diff --git a/test/unit/memo-action-helpers.js b/test/unit/memo-action-helpers.js index 0db0983..b4d3f77 100644 --- a/test/unit/memo-action-helpers.js +++ b/test/unit/memo-action-helpers.js @@ -18,17 +18,21 @@ const { fakeWallet } = require('../helpers/fake-wallet') // 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 } = 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) + const txid = await action[method](value, ...extraArgs) assert.equal(txid, 'fake-txid') - assert.equal(wallet.broadcasts[0].msg, value) + checkBroadcastMsg(wallet.broadcasts[0], value) }) test(`${label} over the limit (${MAX + 1}) throws a length error and broadcasts nothing`, async () => { @@ -37,7 +41,7 @@ function registerMemoActionTests (cfg) { const action = new Action({ wallet, [storeKey]: store }) await assert.rejects( - action[method]('y'.repeat(MAX + 1)), + action[method]('y'.repeat(MAX + 1), ...extraArgs), (err) => err.code === lengthCode ) assert.equal(wallet.broadcasts.length, 0) @@ -50,12 +54,54 @@ function registerMemoActionTests (cfg) { const action = new Action({ wallet }) await assert.rejects( - action[method](invalid), + 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 } diff --git a/test/unit/memo-post.test.js b/test/unit/memo-post.test.js index b04f92d..148b382 100644 --- a/test/unit/memo-post.test.js +++ b/test/unit/memo-post.test.js @@ -59,19 +59,6 @@ test('posting a valid memo broadcasts an OP_RETURN with the Memo post prefix and assert.equal(feed.posts[0].address, wallet.walletInfo.cashAddress) }) -test('posting an empty memo throws a validation error and broadcasts nothing', async () => { - const wallet = fakeWallet() - const feed = fakeFeed() - const memoPost = new MemoPost({ wallet, feed }) - - await assert.rejects( - memoPost.post(''), - (err) => err.code === 'memo_validation' - ) - assert.equal(wallet.broadcasts.length, 0) - assert.equal(feed.posts.length, 0) -}) - test('posting without a wallet reports a missing-wallet error', async () => { const memoPost = new MemoPost({}) await assert.rejects( diff --git a/test/unit/memo-reply.test.js b/test/unit/memo-reply.test.js new file mode 100644 index 0000000..319e7fc --- /dev/null +++ b/test/unit/memo-reply.test.js @@ -0,0 +1,103 @@ +/* + 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) +}) diff --git a/test/unit/memo-set-name.test.js b/test/unit/memo-set-name.test.js index 623ff17..c902e70 100644 --- a/test/unit/memo-set-name.test.js +++ b/test/unit/memo-set-name.test.js @@ -28,7 +28,8 @@ registerMemoActionTests({ label: 'setting a name', storeKey: 'profiles', storeFactory: fakeProfiles, - assertStoreEmpty: (profiles, wallet) => assert.equal(profiles.getName(wallet.walletInfo.cashAddress), null) + 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') @@ -51,31 +52,6 @@ test('setting a valid name broadcasts an OP_RETURN with the Memo set-name prefix assert.equal(profiles.getName(wallet.walletInfo.cashAddress), 'trout') }) -test('setting a name at the maximum byte length with multi-byte characters is accepted', async () => { - const wallet = fakeWallet() - const memoSetName = new MemoSetName({ wallet }) - - // 38 'é' characters are 76 bytes in UTF-8. - const name = 'é'.repeat(38) - assert.equal(Buffer.byteLength(name, 'utf8'), 76) - const txid = await memoSetName.setName(name) - assert.equal(txid, 'fake-txid') -}) - -test('setting an over-long name in bytes (78) throws a length error even when char count is lower', async () => { - const wallet = fakeWallet() - const memoSetName = new MemoSetName({ wallet }) - - // 40 'é' characters are 80 bytes, exceeding the 77-byte limit. - const name = 'é'.repeat(40) - assert.ok(Buffer.byteLength(name, 'utf8') > 77) - await assert.rejects( - memoSetName.setName(name), - (err) => err.code === 'name_length' - ) - assert.equal(wallet.broadcasts.length, 0) -}) - test('setting an empty name throws a validation error and broadcasts nothing', async () => { const wallet = fakeWallet() const profiles = fakeProfiles() diff --git a/test/unit/new-post.test.js b/test/unit/new-post.test.js index f1944ab..dd4a0dd 100644 --- a/test/unit/new-post.test.js +++ b/test/unit/new-post.test.js @@ -18,7 +18,7 @@ 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 } = require('./page-controller-helpers') +const { registerPageControllerTests, registerPageSubmitTests } = require('./page-controller-helpers') const { buildPage } = require('./page-build-helpers') const MAX = MemoPost.MAX_MEMO_CHARS // 217 @@ -70,54 +70,19 @@ test('the character counter reaches zero at the memo limit', () => { assert.equal(page.remainingCount(), 0) }) -test('posting a valid memo broadcasts the Memo post prefix and navigates to the feed', async () => { - const { wallet, store, page, navigations } = build() - page.setInput('hello memo') - - const result = await page.submit() - - assert.equal(result.ok, true) - // The page returns to an idle (not posting) state after success. - assert.equal(page.posting, false) - // Broadcast happened with the Memo post prefix and the exact message. - assert.equal(wallet.broadcasts.length, 1) - assert.equal(wallet.broadcasts[0].prefix, '6d02') - assert.equal(wallet.broadcasts[0].msg, 'hello memo') - // Navigated to the recent feed after posting. - assert.deepEqual(navigations, ['/posts/recent']) - // The feed reflects the new post from this address. - assert.equal(store.posts.length, 1) - assert.equal(store.posts[0].text, 'hello memo') -}) - -test('posting an empty memo 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, 'memo_validation') - assert.equal(page.submitError, 'memo_validation') - assert.equal(page.posting, false) - assert.equal(wallet.broadcasts.length, 0) - assert.equal(store.posts.length, 0) - assert.deepEqual(navigations, []) -}) - -test('posting an over-long memo 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, 'memo_length') - assert.equal(page.submitError, 'memo_length') - assert.equal(page.posting, false) - assert.equal(wallet.broadcasts.length, 0) - assert.equal(store.posts.length, 0) - assert.deepEqual(navigations, []) +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)', () => { diff --git a/test/unit/page-build-helpers.js b/test/unit/page-build-helpers.js index 402b571..ea48d03 100644 --- a/test/unit/page-build-helpers.js +++ b/test/unit/page-build-helpers.js @@ -11,14 +11,16 @@ const { fakeWallet } = require('../helpers/fake-wallet') // actionKey - the page's action dependency key ('memoPost' or 'memoSetName') // storeKey - the action's store dependency key ('feed' or 'profiles') // storeFactory - () => a fresh store -function buildPage ({ Page, Action, actionKey, storeKey, storeFactory }) { +// 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) + navigate: (path) => navigations.push(path), + ...pageDeps }) return { wallet, store, action, page, navigations } } diff --git a/test/unit/page-controller-helpers.js b/test/unit/page-controller-helpers.js index 795b16b..3ec30c2 100644 --- a/test/unit/page-controller-helpers.js +++ b/test/unit/page-controller-helpers.js @@ -63,4 +63,69 @@ function registerPageControllerTests (cfg) { }) } -module.exports = { registerPageControllerTests } +// 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 } diff --git a/test/unit/reply-thread-page.test.js b/test/unit/reply-thread-page.test.js new file mode 100644 index 0000000..2e1f828 --- /dev/null +++ b/test/unit/reply-thread-page.test.js @@ -0,0 +1,113 @@ +/* + 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 { 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') +}) diff --git a/test/unit/utf8.test.js b/test/unit/utf8.test.js new file mode 100644 index 0000000..e4e3e3a --- /dev/null +++ b/test/unit/utf8.test.js @@ -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' +})