From 164bdb3cba111fa7d98ba36d040c5ffed853e018 Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Tue, 25 Aug 2026 17:33:28 -0700 Subject: [PATCH] Surface broadcast errors on the new post page Implements New Post Page scenario 6 (specs/memo-new.feature): when a memo broadcast fails, the app surfaces the real wallet error and the user stays on /posts/new instead of navigating to the feed. - src/services/new-post.js: distinguish local validation errors from broadcast failures; expose the real error message via broadcastError. - src/components/app-body/new-post/index.js: render the broadcast error. - acceptance/lib/handlers.js: support wallet-fail, attempts-to-broadcast, error-containing, and remain-on-path steps. - Unit tests for broadcast-failure surfacing. By coder. --- acceptance/lib/handlers.js | 39 +++++++++++++++++++++-- src/components/app-body/new-post/index.js | 14 +++++--- src/services/new-post.js | 16 ++++++++-- test/unit/new-post.test.js | 39 +++++++++++++++++++++++ 4 files changed, 98 insertions(+), 10 deletions(-) diff --git a/acceptance/lib/handlers.js b/acceptance/lib/handlers.js index 5ce441e..cff1cb2 100644 --- a/acceptance/lib/handlers.js +++ b/acceptance/lib/handlers.js @@ -31,7 +31,9 @@ function makeWallet (address) { return this.utxos }, sendOpReturn: async function (walletInfo, bchUtxos, msg, prefix) { + // Record the broadcast attempt, then fail if configured to do so. this.broadcasts.push({ walletInfo, bchUtxos, msg, prefix }) + if (this.failWith) throw new Error(this.failWith) return 'aa'.repeat(32) } } @@ -95,6 +97,17 @@ const handlers = [ world.currentPath = NewPostPage.RECENT_FEED_PATH } }, + { + name: 'wallet fails to broadcast with error', + pattern: /^the wallet fails to broadcast with the error "<([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.wallet.failWith = example[param] + } + }, { name: 'navigate to path', pattern: /^I navigate to the path (.+)$/, @@ -109,6 +122,16 @@ const handlers = [ } } }, + { + name: 'remain on path', + pattern: /^I remain on the path (.+)$/, + run (m, example, world) { + const target = m[1].trim() + if (world.currentPath !== target) { + throw new Error(`Expected to remain on path ${target}, but current path is ${world.currentPath}.`) + } + } + }, { name: 'open navigation menu', pattern: /^I open the navigation menu$/, @@ -145,8 +168,8 @@ const handlers = [ } }, { - name: 'broadcasts OP_RETURN with Memo post prefix', - pattern: /^(?:the wallet|the app) broadcasts an OP_RETURN transaction with the Memo post prefix$/, + name: 'broadcasts/attempts OP_RETURN with Memo post prefix', + pattern: /^(?:the wallet|the app) (?:broadcasts|attempts to broadcast) an OP_RETURN transaction with the Memo post prefix$/, run (m, example, world) { const broadcasts = world.wallet.broadcasts if (!broadcasts.length) { @@ -176,6 +199,18 @@ const handlers = [ } } }, + { + name: 'page shows error containing text', + pattern: /^the new post page shows an error containing "<([A-Za-z0-9_]+)>"$/, + run (m, example, world) { + const param = m[1] + const expected = example[param] + const actual = world.newPage.broadcastError || '' + if (!actual.includes(expected)) { + throw new Error(`Expected an error containing "${expected}", got "${actual}".`) + } + } + }, { name: 'page shows validation/length error', pattern: /^the (?:app|new post page) shows a (validation|length) error$/, diff --git a/src/components/app-body/new-post/index.js b/src/components/app-body/new-post/index.js index 647abfc..23b344e 100644 --- a/src/components/app-body/new-post/index.js +++ b/src/components/app-body/new-post/index.js @@ -36,11 +36,15 @@ function NewPost (props) { const result = await page.submit() if (!result.ok) { - setErr( - result.error === 'memo_length' - ? `Memo is too long. Maximum is ${maxChars} characters.` - : 'Memo must not be empty.' - ) + if (result.error === 'memo_length') { + setErr(`Memo is too long. Maximum is ${maxChars} characters.`) + } else if (result.error === 'memo_validation') { + setErr('Memo must not be empty.') + } else if (result.message) { + setErr(`Failed to broadcast: ${result.message}`) + } else { + setErr('Failed to post memo.') + } } // On success page.submit() navigated to the recent feed. } catch (submitErr) { diff --git a/src/services/new-post.js b/src/services/new-post.js index 8949544..a52b38b 100644 --- a/src/services/new-post.js +++ b/src/services/new-post.js @@ -26,6 +26,7 @@ class NewPostPage { this.input = '' this.submitError = null + this.broadcastError = null this.posting = false // The navigation menu links to the new post page. @@ -55,10 +56,12 @@ class NewPostPage { } // Validate and post the current draft. On success, navigate to the recent - // feed. On failure, record the typed error. Resolves with a result object. + // feed. On failure, record the typed error and stay on the page. Resolves + // with a result object. async submit () { this.posting = true this.submitError = null + this.broadcastError = null try { if (!this.memoPost) { @@ -70,9 +73,16 @@ class NewPostPage { this.posting = false return { ok: true, txid } } catch (err) { - this.submitError = err.code || 'memo_validation' + if (err.code === 'memo_validation' || err.code === 'memo_length') { + // Local validation failure: record the typed validation error. + this.submitError = err.code + } else { + // Broadcast (or handler) failure: surface the real error message. + this.broadcastError = err.message || String(err) + this.submitError = 'broadcast' + } this.posting = false - return { ok: false, error: this.submitError } + return { ok: false, error: this.submitError, message: this.broadcastError } } } } diff --git a/test/unit/new-post.test.js b/test/unit/new-post.test.js index 0c5acd8..485b347 100644 --- a/test/unit/new-post.test.js +++ b/test/unit/new-post.test.js @@ -29,6 +29,7 @@ function fakeWallet (cashAddress = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0 getUtxos: async function () { return this.utxos }, sendOpReturn: async function (walletInfo, bchUtxos, msg, prefix) { this.broadcasts.push({ walletInfo, bchUtxos, msg, prefix }) + if (this.failWith) throw new Error(this.failWith) return 'newpost-txid' } } @@ -171,3 +172,41 @@ test('submitting without a memo post handler reports an error and does not navig 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 feed = fakeFeed() + wallet.failWith = 'BCH UTXO list is empty' + const navigations = [] + const page = new NewPostPage({ + memoPost: new MemoPost({ wallet, feed }), + 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/) + // The broadcast was attempted (recorded) before it failed. + assert.equal(wallet.broadcasts.length, 1) + assert.equal(wallet.broadcasts[0].prefix, '6d02') + // The user stays on the page. + assert.deepEqual(navigations, []) +}) + +test('a failed broadcast surfaces a different real error message', async () => { + const wallet = fakeWallet() + wallet.failWith = 'Insufficient balance' + const page = new NewPostPage({ + memoPost: new MemoPost({ wallet }), + navigate: () => {} + }) + page.setInput('hello memo') + + const result = await page.submit() + + assert.equal(result.ok, false) + assert.match(page.broadcastError, /Insufficient balance/) +})