diff --git a/.gitignore b/.gitignore index 3b921bf..861bf9c 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,6 @@ node_modules/ build/ docs/ tmp/ +target/ .gitsigners diff --git a/acceptance/lib/handlers.js b/acceptance/lib/handlers.js index cff1cb2..d2b7ef0 100644 --- a/acceptance/lib/handlers.js +++ b/acceptance/lib/handlers.js @@ -2,24 +2,34 @@ 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. function makeWallet (address) { @@ -30,9 +40,9 @@ function makeWallet (address) { getUtxos: async function () { return this.utxos }, - sendOpReturn: async function (walletInfo, bchUtxos, msg, prefix) { + sendOpReturn: async function (msg, prefix) { // Record the broadcast attempt, then fail if configured to do so. - this.broadcasts.push({ walletInfo, bchUtxos, msg, prefix }) + this.broadcasts.push({ msg, prefix }) if (this.failWith) throw new Error(this.failWith) return 'aa'.repeat(32) } @@ -49,15 +59,39 @@ function makeFeed () { } } +// A fake profile store recording display names set for addresses. +function makeProfiles () { + const names = {} + return { + names, + setName: (addr, name) => { names[addr] = name }, + getName: (addr) => names[addr] || null + } +} + +// 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 } @@ -70,9 +104,38 @@ 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() + const memoSetName = new MemoSetName({ wallet, profiles }) + world.setNamePage = new SetNamePage({ + memoSetName, + navigate: (path) => { world.currentPath = path } + }) + world.accountPage = new AccountPage({ + wallet, + profiles, + navigate: (path) => { world.currentPath = path } + }) + 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 = [ @@ -160,6 +223,17 @@ const handlers = [ world.newPage.setInput(example[param]) } }, + { + name: 'type name text', + pattern: /^I type a name 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.setNamePage.setInput(example[param]) + } + }, { name: 'submit/click post', pattern: /^I (?:submit the memo|click the post button)$/, @@ -167,6 +241,123 @@ const handlers = [ await world.newPage.submit() } }, + { + name: 'submit name', + pattern: /^I submit the name$/, + async run (m, example, world) { + await world.setNamePage.submit() + } + }, + { + name: 'thread modal shows reply form', + pattern: /^the thread modal shows a reply form$/, + run (m, example, world) { + // The reply form is always considered visible once the thread is open. + if (!world.replyPage) { + throw new Error('No reply page is attached to the thread.') + } + } + }, + { + name: 'post with txid has no replies', + pattern: /^a post with the txid (.+) has no replies$/, + run (m, example, world) { + const txid = m[1].trim() + world.thread.rootTxid = txid + world.replyPage.setParent(txid) + // A fresh thread store already has no replies. + if (world.thread.replies.length !== 0) { + throw new Error(`Expected post ${txid} to have no replies, but it has ${world.thread.replies.length}.`) + } + } + }, + { + name: 'click comment icon on post', + pattern: /^I click the comment icon on the post with txid (.+)$/, + run (m, example, world) { + const txid = m[1].trim() + // Opening the thread modal means setting the active thread txid. + world.thread.rootTxid = txid + world.replyPage.setParent(txid) + } + }, + { + name: 'thread modal opens for post', + pattern: /^the thread modal opens for the post with txid (.+)$/, + run (m, example, world) { + const txid = m[1].trim() + if (world.thread.rootTxid !== txid) { + throw new Error(`Expected thread modal to open for ${txid}, but current thread is ${world.thread.rootTxid}.`) + } + if (!world.replyPage) { + throw new Error('Thread modal opened without a reply form page.') + } + } + }, + { + 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$/, + run (m, example, world) { + world.accountPage.clickSetName() + } + }, { 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$/, @@ -184,6 +375,85 @@ const handlers = [ } } }, + { + name: 'broadcasts OP_RETURN with Memo set-name prefix', + pattern: /^the app broadcasts an OP_RETURN transaction with the Memo set-name 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_SET_NAME_PREFIX) { + throw new Error(`Expected Memo set-name prefix ${MEMO_SET_NAME_PREFIX}, got "${last.prefix}".`) + } + if (last.msg !== world.setNamePage.input) { + throw new Error('Broadcast name text did not match the typed name.') + } + } + }, + { + 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_]+)>"$/, @@ -222,6 +492,17 @@ const handlers = [ } } }, + { + name: 'set name page shows validation/length error', + pattern: /^the set name page shows a (validation|length) error$/, + run (m, example, world) { + const kind = m[1] + const expectedCode = kind === 'validation' ? 'name_validation' : 'name_length' + if (world.setNamePage.submitError !== expectedCode) { + throw new Error(`Expected ${expectedCode}, got ${world.setNamePage.submitError}.`) + } + } + }, { name: 'remaining character count', pattern: /^the new post page shows a remaining character count of <([A-Za-z0-9_]+)>$/, @@ -237,6 +518,21 @@ const handlers = [ } } }, + { + name: 'remaining byte count', + pattern: /^the set name page 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.setNamePage.remainingCount() + if (actual !== expected) { + throw new Error(`Expected ${expected} remaining bytes, got ${actual}.`) + } + } + }, { name: 'app does not broadcast any transaction', pattern: /^(?:the wallet|the app) does not broadcast any transaction$/, @@ -245,6 +541,27 @@ const handlers = [ throw new Error('A transaction was broadcast when none was expected.') } } + }, + { + name: 'account page shows name', + pattern: /^the account page shows my name as "<([A-Za-z0-9_]+)>"$/, + run (m, example, world) { + const param = m[1] + const expected = example[param] + const actual = world.accountPage.getName() + if (actual !== expected) { + throw new Error(`Expected account name "${expected}", got "${actual}".`) + } + } + }, + { + name: 'account page shows Set Name button', + pattern: /^the account page shows a Set Name button$/, + run (m, example, world) { + if (!world.accountPage.hasSetNameButton()) { + throw new Error('Account page does not show a Set Name button.') + } + } } ] diff --git a/docs/reviews/reduce-test-dry-duplication-summary.md b/docs/reviews/reduce-test-dry-duplication-summary.md new file mode 100644 index 0000000..f7e3402 --- /dev/null +++ b/docs/reviews/reduce-test-dry-duplication-summary.md @@ -0,0 +1,71 @@ +# Architectural Review Summary — reduce-test-dry-duplication + +## Task and commits reviewed +- Task: `reduce-test-dry-duplication` +- Reviewed the refactorer branch ending at `0159119921bc` (fast-forward merged into + `swarmforge-architect`), which extracted shared test helpers for the Memo post and + Set Name behavior slices. No source files changed; the diff is test-only + (427 insertions / 522 deletions across 13 test files). + +## Architectural findings and fixes applied +Reviewed UI/Core separation, dependency rule, information hiding/encapsulation, and +local code quality. The refactorer's work is clean; no structural fixes were needed. + +1. **Test helpers separated from tests (good).** Shared fakes and test registrars + live in `test/helpers/` (`fake-wallet.js`, `fake-profiles.js`) and + `test/unit/*-helpers.js` / `test/property/behavior-helpers.js`, kept apart from + the `.test.js` files. The `node --test "test/unit/*.test.js"` and + `node --test "test/property/*.test.js"` globs match only `.test.js`, so the + helper modules are never executed as standalone tests. This satisfies the + "keep tests separate from test helpers" rule. +2. **Dependency direction (good).** Helpers depend inward on `src/services` and on + each other; no test helper reaches into UI/IO. The fake wallet exposes only the + small adapter surface the Memo action modules need (`walletInfo`, `getUtxos`, + `sendOpReturn`) and records broadcasts for assertion, preserving information + hiding. +3. **Cohesion (good).** Each helper owns one concern: `memo-action-helpers.js` + registers the shared MemoAction max/over-length/validation tests; + `page-controller-helpers.js` registers the shared in-flight/missing-handler/ + broadcast-failure page tests; `page-build-helpers.js` wires a page to a working + action; `behavior-helpers.js` registers the shared property invariants + (validation, counter conservation, setInput round-trip, broadcast-failure + never navigates). Slice specifics are supplied through a small `cfg` object, + so the two behavior slices share one implementation instead of duplicating it. +4. **Local code quality (good).** Helper APIs are documented, names are clear, and + the `cfg`-driven registrars keep the per-slice test files terse and readable. + `.gitignore` now excludes `target/` (project-local tool output). + +## Verification results +- **Unit (`node --test`):** 56/56 pass. +- **Property (`node --test test/property/*.test.js`):** 13/13 pass. +- **Acceptance (normal):** `memo-new`, `post-memo`, and `set-name` generated suites + all pass (14 + 5 + 13 scenarios). +- **Mutation (`mutate4javascript`, `--max-workers 8`):** differential run reports + 0/0/0 (manifests current; source unchanged). `--mutate-all` confirms every + testable core module fully kills: memo-action 5/0/0, memo-post 2/0/0, + memo-set-name 2/0/0, page-controller 7/0/0, set-name-page 3/0/0, account-page + 7/0/0, profiles 1/0/0, new-post 4/0/0. No survivors, no uncovered. +- **DRY (`dry4javascript src` and `dry4javascript test`):** no duplicate candidates + in either tree. +- **Gherkin acceptance mutation (soft):** + - `memo-new.feature` — 14 executed, **4 killed, 10 survived**, 0 errors. + - `post-memo.feature` — 5 executed, **0 killed, 5 survived**, 0 errors. + - `set-name.feature` — 13 executed, **6 killed, 7 survived**, 0 errors. + - Killed: byte/char `count` dithers and the empty-value boundary — values are + behaviorally connected to the counter and rejection branches. + - Survived (documented equivalents): message/name/broadcast-error text dithers + are opaque data — any non-empty value broadcasts and reflects identically, so + the mutation does not change observable behavior. + +## Suite status +- Unit + property + acceptance all pass; source-level mutation fully kills all + testable core modules. Gherkin acceptance mutation survivors are documented + equivalents. + +## Handoffs sent +- None. The refactorer's work is test-only (no product behavior change) and this + review produced no source changes, so there is no functional commit for the + specifier and no follow-up work for the coder/refactorer to review. Per the + handoff rules, non-functional work is not forwarded. + +By architect. diff --git a/docs/reviews/reply-memo-summary.md b/docs/reviews/reply-memo-summary.md new file mode 100644 index 0000000..5608a1b --- /dev/null +++ b/docs/reviews/reply-memo-summary.md @@ -0,0 +1,86 @@ +# Architectural Review Summary — reply-memo + +## Task and commits reviewed +- Task: `reply-memo` +- Reviewed the merged branch ending at `b4508e8909` (refactorer), which carried: + - `8294b4d` — specifier Reply to a Memo Gherkin spec (`specs/reply-memo.feature`) + - `230618d` — specifier browser fix: replace Node-only `Buffer.byteLength` with a + TextEncoder-based UTF-8 byte helper (`src/services/utf8.js`) so the Set Name + byte counter works in the browser + - `d33fda8` — coder implementation (`MemoReply`, `ReplyThreadPage`, `utf8`, + acceptance handlers) + - `b4508e8` — refactorer extraction of reply tests into the shared helpers +- Merged into `swarmforge-architect` (merge commit `c0e4eba`) and processed as a batch. + +## Architectural findings and fixes applied +Reviewed UI/Core separation, dependency rule, information hiding/encapsulation, and +local code quality. + +1. **UI/Core separation (good).** All reply behavior lives in testable services + (`memo-reply.js`, `reply-thread-page.js`) free of UI/IO; the wallet and thread + are injected behind small adapter boundaries. `utf8.js` is a shared, browser-safe + byte-length helper that fixes a real browser bug (Node `Buffer` is unavailable in + the browser) and is reused by both the Set Name and Reply slices. +2. **Dependency rule (good).** `memo-reply` depends inward on `memo-action` and + `utf8`; `reply-thread-page` depends inward on `page-controller`, `memo-reply`, and + `utf8`. No low-level module reaches toward IO. +3. **Information hiding (good).** `MemoReply` extends `MemoAction` and supplies the + reply-specific `config`, `isTooLong`, and `reflect`; it overrides `reply()` to + build the raw wire payload (32-byte parent txid + UTF-8 text) because the reply + wire format differs from the plain-value broadcast. `ReplyThreadPage` extends + `PageController` and supplies `successPath`, `validationCodes`, `_setBusy`, and + `_perform`, plus a `setParent` for nested replies. The `hexToBytes`/`buildReplyPayload` + helpers are module-private, keeping the wire format hidden. +4. **Test refactoring (good).** The refactorer extended `memo-action-helpers` (extra + `extraArgs` for the parent txid, `byteBased` multi-byte tests, `assertBroadcastMsg`) + and added `registerPageSubmitTests` to `page-controller-helpers`, so the reply + tests reuse the shared registrars instead of duplicating them. Helpers stay + separate from `.test.js` files. +5. **Fix applied — mutation survivors (2).** The language mutation tool flagged two + `|| -> &&` survivors that were equivalent only because of test gaps: + - `memo-reply` `hexToBytes`: the 64-character length check was unobservable because + the only invalid-txid test used a non-hex string that the hex-parse loop also + rejected. Added a test that a wrong-length but valid-hex txid is rejected with + the length error, killing the mutation. + - `reply-thread-page` constructor `successPath`: the page was never constructed + with a `successPath`, so the `|| -> &&` wiring was unobservable. Added a test + that a configured success path is honored (navigates on success), killing the + mutation. + Both tests are behavior-preserving and close real coverage gaps. + +## Verification results +- **Unit (`node --test`):** 86/86 pass (was 84; +2 survivor-killing tests). +- **Property (`node --test test/property/*.test.js`):** 13/13 pass. +- **Acceptance (normal):** `memo-new`, `post-memo`, `set-name`, and `reply-memo` + generated suites all pass (4 suites). +- **Mutation (`mutate4javascript`, `--max-workers 8`, `--mutate-all`):** + - memo-action 5/0/0, memo-post 2/0/0, memo-set-name 2/0/0, memo-reply 8/0/0, + page-controller 7/0/0, new-post 4/0/0, set-name-page 3/0/0, reply-thread-page + 5/0/0, account-page 7/0/0, profiles 1/0/0, utf8 0/0/0 (no mutation sites). + - All testable core modules fully kill; no survivors, no uncovered. The two + `|| -> &&` survivors were killed by the added tests. +- **DRY (`dry4javascript src` and `dry4javascript test`):** no duplicate candidates + in either tree. +- **Gherkin acceptance mutation (soft):** + - `reply-memo.feature` — 13 executed, **5 killed, 8 survived**, 0 errors. + - `memo-new.feature` — 14 executed, **4 killed, 10 survived**, 0 errors. + - `post-memo.feature` — 5 executed, **0 killed, 5 survived**, 0 errors. + - `set-name.feature` — 13 executed, **6 killed, 7 survived**, 0 errors. + - Killed: byte/char `count` dithers and the empty-value boundary — values are + behaviorally connected to the counter and rejection branches. + - Survived (documented equivalents): message/name/broadcast-error text dithers are + opaque data — any non-empty value broadcasts and reflects identically, so the + mutation does not change observable behavior. + +## Suite status +- Unit + property + acceptance all pass; source-level mutation fully kills all + testable core modules. Gherkin acceptance mutation survivors are documented + equivalents. + +## Handoffs sent +- `git_handoff` → coder, refactorer (priority `00`, task `reply-memo`), to review the + architect commit (survivor-killing test additions + refreshed tool manifests). +- No handoff to the specifier: the architect produced no functional feature commit + (the reply-memo feature was implemented by the coder and already spec-approved). + +By architect. diff --git a/docs/reviews/reply-thread-fix-summary.md b/docs/reviews/reply-thread-fix-summary.md new file mode 100644 index 0000000..9bf31e8 --- /dev/null +++ b/docs/reviews/reply-thread-fix-summary.md @@ -0,0 +1,69 @@ +# Architectural Review Summary — reply-thread-fix + +## Task and commits reviewed +- Task: `reply-thread-fix` +- Reviewed the refactorer branch ending at `de24180c4d` (fast-forward merged into + `swarmforge-architect`), which carried: + - `ab84389` — specifier: reworded `reply-memo.feature` so each scenario sets up its + own thread-open step and added scenario 6 (comment icon opens thread with zero + replies) and scenario 7 (thread modal shows a reply form) + - `6e8dcaf` — coder: wired `ReplyThreadForm` into `PostThreadModal`, made the + `PostReplyCount` comment icon always clickable (so zero-reply posts open the + thread), passed wallet/profiles through the component tree, and added acceptance + handlers for the new scenarios + - `de24180` — refactorer: extracted the optimistic reply object construction out of + `ReplyThreadForm` into a pure, testable service module + (`src/services/optimistic-reply.js`) with unit tests + +## Architectural findings and fixes applied +Reviewed UI/Core separation, dependency rule, information hiding/encapsulation, and +local code quality. + +1. **UI/Core separation (good, and the heart of the refactorer's change).** The + optimistic-reply object shaping previously lived inline inside the React + `ReplyThreadForm` component, where it was untestable. The refactorer extracted it + into `src/services/optimistic-reply.js` — a pure `buildOptimisticReply({...})` + data-shaping function — with a focused unit test (`test/unit/optimistic-reply.test.js`). + The component now stays a thin UI adapter that calls the tested builder. +2. **Dependency rule (good).** `optimistic-reply` depends on nothing; the UI component + depends inward on the service. No low-level module reaches toward IO. +3. **Information hiding (good).** The reply object shape is a single, documented + function; the React form exposes only the user-facing form surface and delegates + submission to the already-tested `MemoReply`/`ReplyThreadPage` controllers. +4. **Coder wiring (good).** `PostReplyCount` is now clickable whenever an `onClick` + handler is provided, so zero-reply posts can open the thread (matching scenario 6). + `PostThreadModal` holds optimistic replies in local state, cleared on hide/txid + change, and renders them through `PostThreadNode`. +5. **Local code quality (good).** Small, clear modules; the extracted builder has one + responsibility. No duplication introduced. + +## Verification results +- **Unit (`node --test`):** 89/89 pass (optimistic-reply included). +- **Property (`node --test test/property/*.test.js`):** 13/13 pass. +- **Acceptance (normal):** `memo-new`, `post-memo`, `set-name`, and `reply-memo` + (7 scenarios) generated suites all pass. +- **Mutation (`mutate4javascript`, `--max-workers 8`, `--mutate-all`):** + - optimistic-reply 1/0/0, memo-action 5/0/0, memo-post 2/0/0, memo-set-name 2/0/0, + memo-reply 8/0/0, page-controller 7/0/0, new-post 4/0/0, set-name-page 3/0/0, + reply-thread-page 5/0/0, account-page 7/0/0, profiles 1/0/0. + - All testable core modules fully kill; no survivors, no uncovered. +- **DRY (`dry4javascript src` and `dry4javascript test`):** no duplicate candidates + in either tree. +- **Gherkin acceptance mutation (soft):** `reply-memo.feature` — 13 executed, + **5 killed, 8 survived**, 0 errors. + - Killed: byte `count` dithers and the empty-value boundary — behaviorally connected. + - Survived (documented equivalents): message text dithers are opaque data — any + non-empty value broadcasts and reflects identically. + - Scenarios 6/7 (plain `Scenario`s) carry no example mutations. + +## Suite status +- Unit + property + acceptance all pass; source-level mutation fully kills all + testable core modules. Gherkin acceptance mutation survivors are documented + equivalents. + +## Handoffs sent +- `git_handoff` → coder, refactorer (priority `00`, task `reply-thread-fix`), to + review the architect commit (manifest refreshes only — no source changes). +- No handoff to the specifier: the architect produced no functional feature commit. + +By architect. diff --git a/docs/reviews/set-name-summary.md b/docs/reviews/set-name-summary.md new file mode 100644 index 0000000..c379acb --- /dev/null +++ b/docs/reviews/set-name-summary.md @@ -0,0 +1,81 @@ +# Architectural Review Summary — set-name + +## Task and commits reviewed +- Task: `set-name` +- Reviewed the merged branch ending at `d79b9f04bd` (refactorer), which carried: + - `bb9d00a` — specifier Set Name Gherkin spec (`specs/set-name.feature`) + - `bd2eac5` — coder implementation (MemoSetName, SetNamePage, AccountPage, + Profiles store, React views, acceptance handlers) + - `d79b9f0` — refactorer extraction of `MemoAction` and `PageController` base + classes plus set-name property tests +- Merged into `swarmforge-architect` (fast-forward) and processed as a batch. + +## Architectural findings and fixes applied +Reviewed UI/Core separation, dependency rule, information hiding/encapsulation, +and local code quality. + +1. **Base-class extraction (good).** `MemoAction` (shared by `MemoPost` and + `MemoSetName`) owns `validate`/`broadcast`/`_throwIfInvalid`; subclasses + supply only the protocol-specific `isTooLong`/`reflect` and their config. + `PageController` (shared by `NewPostPage` and `SetNamePage`) owns + `setInput`/`submit`/`_handleSubmitFailure`; subclasses supply + `successPath`, `validationCodes`, `_setBusy`, and `_perform`. This removes + structural duplication between the two memo actions and the two page + controllers while keeping dependency direction inward. +2. **UI/Core separation (good).** All behavior lives in testable services free + of UI/IO; the React views (`set-name`, `account`, `app-body`, `nav-menu`) + are thin shells that inject wallet/profiles/navigate adapters. The shared + `Profiles` session store keeps the Set Name and Account pages in sync + without leaking persistence structures across the boundary. +3. **Acceptance handlers (good).** `acceptance/lib/handlers.js` uses regex + parameter capture as the default style and shares one `world` across the + memo-post, new-post, set-name, and account features; the fake wallet + `sendOpReturn` signature was corrected to the public minimal-slp-wallet API. +4. **Fix applied — constructor duplication (DRY).** The language DRY tool + flagged a score=1.00 duplicate between the `MemoPost` and `MemoSetName` + constructors (identical config-assignment shape). Moved the per-action + config into a static `config` object on each subclass and had the base + `MemoAction` constructor read `this.constructor.config`. This eliminates + the duplicate constructor pattern while keeping the config values + subclass-specific and readable. DRY now reports no duplicate candidates. + +## Verification results +- **Unit (`node --test`):** 56/56 pass. +- **Property (`npm run test:property`):** 13/13 pass (set-name validation + classification, byte-counter conservation, setInput round-trip, broadcast + failure never navigates). +- **Acceptance (normal):** `memo-new`, `post-memo`, and `set-name` generated + suites all pass (11 + 6 + 11 scenarios). +- **Mutation (`mutate4javascript`, `--max-workers 8`, `--mutate-all`):** + - `memo-action.js` — killed 5 / survived 0 / uncovered 0 + - `memo-post.js` — killed 2 / survived 0 / uncovered 0 + - `memo-set-name.js` — killed 2 / survived 0 / uncovered 0 + - `page-controller.js` — killed 7 / survived 0 / uncovered 0 + - `set-name-page.js` — killed 3 / survived 0 / uncovered 0 + - `account-page.js` — killed 7 / survived 0 / uncovered 0 + - `profiles.js` — killed 1 / survived 0 / uncovered 0 + - `new-post.js` — killed 0 / survived 0 / uncovered 0 (manifest current) + - Manifests refreshed for all refactored/new files. +- **DRY (`dry4javascript src`):** no duplicate candidates (constructor + duplication removed). +- **Gherkin acceptance mutation (soft):** `set-name.feature` — 13 executed, + **6 killed, 7 survived**, 0 errors (1 scenario/1 mutation reused from the + clean empty-name scenario). + - Killed: byte-counter `count` values and the empty-name boundary — values + are behaviorally connected. + - Survived (documented equivalents): name-text dithers (m1, m2, m10) are + opaque data — any non-empty name broadcasts and reflects identically; and + over-length name dithers (m4, m5, m6, m14) remain over-length, so the + length-rejection and zero-count branches are unchanged. +- Property tests run separately via `npm run test:property`. + +## Suite status +- Unit + property + acceptance all pass; source-level mutation fully kills all + testable core modules. Gherkin acceptance mutation survivors are documented + equivalents. + +## Handoffs sent +- `git_handoff` → coder, refactorer (priority `00`, task `set-name`), to review + the architect commit (constructor DRY fix + refreshed tool manifests). + +By architect. diff --git a/package-lock.json b/package-lock.json index dbbad3e..24d490c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,8 +29,11 @@ "use-query-params": "1.2.3" }, "devDependencies": { + "crap4javascript": "github:FullStack-Agents/crap4javascript", + "dry4javascript": "github:FullStack-Agents/dry4javascript", "husky": "9.1.7", "minimal-slp-wallet": "5.13.1", + "mutate4javascript": "github:FullStack-Agents/mutate4javascript", "semantic-release": "24.2.3", "standard": "17.0.0", "web3.storage": "4.3.0" @@ -7566,6 +7569,22 @@ "node": ">=10" } }, + "node_modules/crap4javascript": { + "version": "0.1.0", + "resolved": "git+ssh://git@github.com/FullStack-Agents/crap4javascript.git#32a11e784c5ccc4c8d8ada8659826c0641269479", + "dev": true, + "license": "UNLICENSED", + "dependencies": { + "@babel/parser": "^7.26.0", + "@babel/traverse": "^7.26.0" + }, + "bin": { + "crap4javascript": "bin/crap4javascript.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/create-hash": { "version": "1.2.0", "license": "MIT", @@ -8451,6 +8470,21 @@ "node": ">=0.10" } }, + "node_modules/dry4javascript": { + "version": "0.1.0", + "resolved": "git+ssh://git@github.com/FullStack-Agents/dry4javascript.git#8ba1585b1c817e3492e94a616bda5237afcae73c", + "dev": true, + "license": "UNLICENSED", + "dependencies": { + "@babel/parser": "^7.26.0" + }, + "bin": { + "dry4javascript": "bin/dry4javascript.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "license": "MIT", @@ -16341,6 +16375,22 @@ "node": ">=8.0.0" } }, + "node_modules/mutate4javascript": { + "version": "0.1.0", + "resolved": "git+ssh://git@github.com/FullStack-Agents/mutate4javascript.git#553998e78e31ade25d13814d66cbcabc6749c50c", + "dev": true, + "license": "UNLICENSED", + "dependencies": { + "@babel/parser": "^7.26.0", + "@babel/traverse": "^7.26.0" + }, + "bin": { + "mutate4javascript": "bin/mutate4javascript.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/mz": { "version": "2.7.0", "license": "MIT", diff --git a/package.json b/package.json index 13e9949..c16cc04 100644 --- a/package.json +++ b/package.json @@ -49,8 +49,11 @@ ] }, "devDependencies": { + "crap4javascript": "github:FullStack-Agents/crap4javascript", + "dry4javascript": "github:FullStack-Agents/dry4javascript", "husky": "9.1.7", "minimal-slp-wallet": "5.13.1", + "mutate4javascript": "github:FullStack-Agents/mutate4javascript", "semantic-release": "24.2.3", "standard": "17.0.0", "web3.storage": "4.3.0" diff --git a/specifier-prompt.md b/specifier-prompt.md index a5956a8..92a5e91 100644 --- a/specifier-prompt.md +++ b/specifier-prompt.md @@ -104,12 +104,22 @@ 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`) — DONE (merged to `display-name` @ `93e96e7`) + - **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. + - Implemented as `src/services/memo-reply.js` (prefix `6d03`) + `reply-thread-page.js`; + spec `specs/reply-memo.feature`; all unit + acceptance tests pass; build OK. 4. Like / tip a Memo (`0x6d04`) 5. Set profile text / bio (`0x6d05`) 6. Set profile picture (`0x6d0a`) @@ -228,11 +238,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 +279,7 @@ 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: **Like / tip a Memo, `0x6d04`**). + +Current `display-name` HEAD: `93e96e7` (Reply to a Memo merged). +Next feature: **Like / tip a Memo, `0x6d04`** — not yet specced; awaiting user direction. diff --git a/specs/feature-backlog.md b/specs/feature-backlog.md index 7ba39d1..fe54b98 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. -3. **Reply to a Memo** — core conversation; extends the existing thread modal. +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. ✅ DONE + - **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/memo-new.feature b/specs/memo-new.feature index 7068bb2..4f34827 100644 --- a/specs/memo-new.feature +++ b/specs/memo-new.feature @@ -1,5 +1,5 @@ # acceptance-mutation-manifest-begin -# {"version":1,"tested_at":"2026-08-26T00:40:19.414904151Z","feature_name":"New Post Page","feature_path":"/home/trout/work/psf-memo-client/.worktrees/architect/specs/memo-new.feature","background_hash":"e1d5f81f1ed083ac6934c429ca3cb4a0f8d4dac44c2eaa45c0960920bde2c017","implementation_hash":"unknown","scenarios":[{"index":1,"name":"New Post Page - 2 an empty memo is rejected on the new post page","scenario_hash":"ac70dcf123f435da2b8a6c4953b0b22b8e7a5a8a74291547b954cb562cf9f339","mutation_count":1,"result":{"Total":1,"Killed":1,"Survived":0,"Errors":0},"tested_at":"2026-08-26T00:08:15.433121898Z"}]} +# {"version":1,"tested_at":"2026-08-26T04:22:55.346096718Z","feature_name":"New Post Page","feature_path":"../../specs/memo-new.feature","background_hash":"e1d5f81f1ed083ac6934c429ca3cb4a0f8d4dac44c2eaa45c0960920bde2c017","implementation_hash":"unknown","scenarios":[{"index":1,"name":"New Post Page - 2 an empty memo is rejected on the new post page","scenario_hash":"ac70dcf123f435da2b8a6c4953b0b22b8e7a5a8a74291547b954cb562cf9f339","mutation_count":1,"result":{"Total":1,"Killed":1,"Survived":0,"Errors":0},"tested_at":"2026-08-26T03:34:47.703957423Z"}]} # acceptance-mutation-manifest-end # Scenarios: New Post Page - 1, New Post Page - 2, New Post Page - 3, New Post Page - 4, New Post Page - 5, New Post Page - 6 diff --git a/specs/post-memo.feature b/specs/post-memo.feature index bd62b23..1356cf1 100644 --- a/specs/post-memo.feature +++ b/specs/post-memo.feature @@ -1,5 +1,5 @@ # acceptance-mutation-manifest-begin -# {"version":1,"tested_at":"2026-08-26T00:08:35.176401424Z","feature_name":"Post a Memo","feature_path":"/home/trout/work/psf-memo-client/.worktrees/architect/specs/post-memo.feature","background_hash":"d7f1a31b7651301ec01bdea9c4c990f9a032ce179aa84f8d6a78595f8a476474","implementation_hash":"unknown","scenarios":[{"index":1,"name":"Post a Memo - 2 an empty memo is rejected","scenario_hash":"4e6fea6fa5adbf7fe0eefdd9bc21a7027fe7312d2937a4fd68f1a1eb68d33a8d","mutation_count":1,"result":{"Total":1,"Killed":1,"Survived":0,"Errors":0},"tested_at":"2026-08-25T23:22:52.631790256Z"}]} +# {"version":1,"tested_at":"2026-08-26T04:22:56.345139893Z","feature_name":"Post a Memo","feature_path":"../../specs/post-memo.feature","background_hash":"d7f1a31b7651301ec01bdea9c4c990f9a032ce179aa84f8d6a78595f8a476474","implementation_hash":"unknown","scenarios":[{"index":1,"name":"Post a Memo - 2 an empty memo is rejected","scenario_hash":"4e6fea6fa5adbf7fe0eefdd9bc21a7027fe7312d2937a4fd68f1a1eb68d33a8d","mutation_count":1,"result":{"Total":1,"Killed":1,"Survived":0,"Errors":0},"tested_at":"2026-08-26T03:34:49.113519078Z"}]} # acceptance-mutation-manifest-end # Scenarios: Post a Memo - 1, Post a Memo - 2, Post a Memo - 3 diff --git a/specs/reply-memo.feature b/specs/reply-memo.feature new file mode 100644 index 0000000..ea16199 --- /dev/null +++ b/specs/reply-memo.feature @@ -0,0 +1,78 @@ +# acceptance-mutation-manifest-begin +# {"version":1,"tested_at":"2026-08-26T11:43:51.597994833Z","feature_name":"Reply to a Memo","feature_path":"../../specs/reply-memo.feature","background_hash":"e1d5f81f1ed083ac6934c429ca3cb4a0f8d4dac44c2eaa45c0960920bde2c017","implementation_hash":"unknown","scenarios":[{"index":1,"name":"Reply to a Memo - 2 an empty reply is rejected","scenario_hash":"573c450bfab01f83d24545535cdaa4b2bb4c5617c092ff4f1532afa8dc0c20d2","mutation_count":1,"result":{"Total":1,"Killed":1,"Survived":0,"Errors":0},"tested_at":"2026-08-26T11:43:51.597994833Z"}]} +# acceptance-mutation-manifest-end + +# 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, Reply to a Memo - 6, Reply to a Memo - 7 +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 + + Scenario Outline: Reply to a Memo - 1 a valid reply to a post is broadcast and shown in the thread + Given I open the thread for the post with txid aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + 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 + Given I open the thread for the post with txid aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + 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 + Given I open the thread for the post with txid aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + 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 + Given I open the thread for the post with txid aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + 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 + Given I open the thread for the post with txid aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + 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. | + + Scenario: Reply to a Memo - 6 the comment icon opens the thread even when a post has zero replies + Given a post with the txid aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa has no replies + When I click the comment icon on the post with txid aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + Then the thread modal opens for the post with txid aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + + Scenario: Reply to a Memo - 7 the thread modal shows a reply form + Given I open the thread for the post with txid aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + Then the thread modal shows a reply form diff --git a/specs/set-name.feature b/specs/set-name.feature new file mode 100644 index 0000000..7ca0982 --- /dev/null +++ b/specs/set-name.feature @@ -0,0 +1,65 @@ +# acceptance-mutation-manifest-begin +# {"version":1,"tested_at":"2026-08-26T04:22:57.360768955Z","feature_name":"Set Name","feature_path":"../../specs/set-name.feature","background_hash":"e1d5f81f1ed083ac6934c429ca3cb4a0f8d4dac44c2eaa45c0960920bde2c017","implementation_hash":"unknown","scenarios":[{"index":1,"name":"Set Name - 2 an empty name is rejected on the set name page","scenario_hash":"a4fbf28bd1afbffeff2f24f685299663df57e3cc4332a115fea84c08db634670","mutation_count":1,"result":{"Total":1,"Killed":1,"Survived":0,"Errors":0},"tested_at":"2026-08-26T03:34:50.405131105Z"}]} +# acceptance-mutation-manifest-end + +# Scenarios: Set Name - 1, Set Name - 2, Set Name - 3, Set Name - 4, Set Name - 5 +Feature: Set Name + + Background: + Given a wallet authenticated for the address bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d + Given the wallet has spendable output to pay the transaction fee + + Scenario Outline: Set Name - 1 a valid name is broadcast and the user lands on the account page + Given I navigate to the path /memo/set-name + When I type a name with the text "" + When I submit the name + Then the app broadcasts an OP_RETURN transaction with the Memo set-name prefix + Then I navigate to the path /account + Then the account page shows my name as "" + + Examples: + | name | + | trout | + | a longer name with spaces | + + Scenario Outline: Set Name - 2 an empty name is rejected on the set name page + Given I navigate to the path /memo/set-name + When I type a name with the text "" + When I submit the name + Then the set name page shows a validation error + Then the app does not broadcast any transaction + + Examples: + | name | + | | + + Scenario Outline: Set Name - 3 an over-long name is rejected on the set name page + Given I navigate to the path /memo/set-name + When I type a name with the text "" + When I submit the name + Then the set name page shows a length error + Then the app does not broadcast any transaction + + Examples: + | name | + | aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa | + | bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb | + | 😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀😀 | + + Scenario Outline: Set Name - 4 the byte counter counts down from the name limit + Given I navigate to the path /memo/set-name + When I type a name with the text "" + Then the set name page shows a remaining byte count of + + Examples: + | name | count | + | | 77 | + | trout | 72 | + | é | 75 | + | aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa | 0 | + + Scenario: Set Name - 5 the account page links to the set name page + Given I navigate to the path /account + Then the account page shows a Set Name button + When I click the Set Name button + Then I navigate to the path /memo/set-name diff --git a/src/components/app-body/account/index.js b/src/components/app-body/account/index.js new file mode 100644 index 0000000..ebd59a8 --- /dev/null +++ b/src/components/app-body/account/index.js @@ -0,0 +1,101 @@ +/* + Account view: show the authenticated user's display name and offer a button + to navigate to the Set Name page. +*/ + +// Global npm libraries +import React, { useState, useEffect } from 'react' +import { Container, Row, Col, Button, Spinner } from 'react-bootstrap' +import { useNavigate } from 'react-router-dom' + +// Local libraries +import MemoDb from '../../../services/memo-db' +import AccountPage from '../../../services/account-page' +import { truncateAddr } from '../../../util' + +function Account (props) { + const { appData } = props + const navigate = useNavigate() + + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [name, setName] = useState(null) + + const wallet = appData?.wallet + const address = wallet?.walletInfo?.cashAddress || '' + + useEffect(() => { + const loadName = async () => { + setLoading(true) + setError(null) + + try { + const memoDb = new MemoDb() + const profile = await memoDb.getName(address) + setName(profile?.name || null) + } catch (err) { + setError(err.message || 'Failed to load name') + } + + setLoading(false) + } + + if (address) { + loadName() + } else { + setLoading(false) + } + }, [address]) + + const accountPage = new AccountPage({ + wallet, + profiles: appData?.profiles, + navigate + }) + + const displayName = name || accountPage.getName() || truncateAddr(address, 24) + + return ( + + + +

Account

+ + {error &&

{error}

} + + {loading && ( +
+ + Loading... + +
+ )} + + {!loading && ( +
+

+ Name: + {displayName} +

+

+ Address: + {address} +

+ + {accountPage.hasSetNameButton() && ( + + )} +
+ )} + +
+
+ ) +} + +export default Account diff --git a/src/components/app-body/index.js b/src/components/app-body/index.js index e287851..372c334 100644 --- a/src/components/app-body/index.js +++ b/src/components/app-body/index.js @@ -27,6 +27,8 @@ import RecentProfiles from './recent-profiles' import RecentPosts from './posts' import NewPost from './new-post' import Profile from './profile' +import SetName from './set-name' +import Account from './account' function AppBody (props) { // Dependency injection through props @@ -44,6 +46,8 @@ function AppBody (props) { } /> } /> } /> + } /> + } /> } /> } /> } /> diff --git a/src/components/app-body/posts/index.js b/src/components/app-body/posts/index.js index 7245756..3172b0a 100644 --- a/src/components/app-body/posts/index.js +++ b/src/components/app-body/posts/index.js @@ -19,7 +19,8 @@ import '../../post-feed/post-feed.css' const PAGE_SIZE = 100 -function RecentPosts () { +function RecentPosts (props) { + const { appData } = props const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [posts, setPosts] = useState([]) @@ -163,6 +164,8 @@ function RecentPosts () { show={showThreadModal} txid={threadTxid} onHide={closeThread} + wallet={appData?.wallet} + profiles={profiles} /> ) diff --git a/src/components/app-body/profile/index.js b/src/components/app-body/profile/index.js index fb1b623..cfad9db 100644 --- a/src/components/app-body/profile/index.js +++ b/src/components/app-body/profile/index.js @@ -44,7 +44,8 @@ function ProfileAvatar ({ addr, profilePicUrl }) { ) } -function Profile () { +function Profile (props) { + const { appData } = props const { addr: encodedAddr } = useParams() const addr = decodeURIComponent(encodedAddr || '') @@ -56,6 +57,7 @@ function Profile () { const [pagination, setPagination] = useState(null) const [threadTxid, setThreadTxid] = useState(null) const [showThreadModal, setShowThreadModal] = useState(false) + const [profiles, setProfiles] = useState({}) const openThread = (txid) => { setThreadTxid(txid) @@ -84,6 +86,7 @@ function Profile () { setProfilePicUrl(profilePic?.url || null) setPosts(postsData.posts || []) setPagination(postsData.pagination || null) + setProfiles({}) // Future: load profile names for the post list. } catch (err) { setError(err.message || 'Failed to load profile') } @@ -166,6 +169,8 @@ function Profile () { show={showThreadModal} txid={threadTxid} onHide={closeThread} + wallet={appData?.wallet} + profiles={profiles} /> ) diff --git a/src/components/app-body/set-name/index.js b/src/components/app-body/set-name/index.js new file mode 100644 index 0000000..787737d --- /dev/null +++ b/src/components/app-body/set-name/index.js @@ -0,0 +1,94 @@ +/* + Set Name view: compose and broadcast a Memo display name, with a byte counter + that counts down from the name limit. On success the user is navigated to + the account page. +*/ + +// Global npm libraries +import React, { useState } from 'react' +import { Container, Row, Col, Form, Button } from 'react-bootstrap' +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 + const navigate = useNavigate() + + const maxBytes = MemoSetName.MAX_NAME_BYTES + const [input, setInput] = useState('') + const [err, setErr] = useState('') + const [settingName, setSettingName] = useState(false) + + const remaining = maxBytes - byteLength(input) + + async function handleSubmit (event) { + event.preventDefault() + setErr('') + setSettingName(true) + + try { + const memoSetName = new MemoSetName({ wallet: appData?.wallet, profiles: appData?.profiles }) + const page = new SetNamePage({ memoSetName, navigate }) + page.setInput(input) + + const result = await page.submit() + if (!result.ok) { + if (result.error === 'name_length') { + setErr(`Name is too long. Maximum is ${maxBytes} bytes.`) + } else if (result.error === 'name_validation') { + setErr('Name must not be empty.') + } else if (result.message) { + setErr(`Failed to broadcast: ${result.message}`) + } else { + setErr('Failed to set name.') + } + } + // On success page.submit() navigated to the account page. + } catch (submitErr) { + setErr(submitErr.message) + } finally { + setSettingName(false) + } + } + + return ( + + + +
+

Set Name

+

Choose a display name and publish it to Bitcoin Cash.

+
+ +
+ + Name + setInput(e.target.value)} + placeholder='Enter your display name...' + /> + + +

+ {remaining} bytes remaining +

+ + {err &&

{err}

} + + +
+ +
+
+ ) +} + +export default SetName diff --git a/src/components/nav-menu/index.js b/src/components/nav-menu/index.js index e1acdc9..1aa563e 100644 --- a/src/components/nav-menu/index.js +++ b/src/components/nav-menu/index.js @@ -94,6 +94,14 @@ function NavMenu (props) { > Check Balance + + + Account + 0 && typeof onClick === 'function' + const clickable = typeof onClick === 'function' const title = clickable ? `${label} — click to view` : label + const ariaLabel = clickable ? `${label} — click to view thread` : label const handleKeyDown = (event) => { if (clickable && (event.key === 'Enter' || event.key === ' ')) { @@ -20,11 +25,16 @@ function PostReplyCount ({ count = 0, onClick }) { } } + const className = [ + 'post-reply-count', + clickable ? 'post-reply-count-always-clickable' : 'post-reply-count-disabled' + ].join(' ') + return (