diff --git a/.gitignore b/.gitignore index 2c9b815..3b921bf 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ node_modules/ build/ docs/ +tmp/ .gitsigners diff --git a/acceptance/acceptance.js b/acceptance/acceptance.js new file mode 100644 index 0000000..c2ae8ef --- /dev/null +++ b/acceptance/acceptance.js @@ -0,0 +1,99 @@ +/* + Normal acceptance runner for psf-memo-client. + + Orchestrates the acceptance pipeline: + feature file -> bb gherkin-parser -> JSON IR -> acceptance entrypoint + generator -> generated test entry points -> node test runner + + It procures the latest Babashka APS tools from the Acceptance-Pipeline- + Specification repository on first use, then parses, generates, and runs every + Gherkin feature under specs/. + + Exit code 0 when all acceptance tests pass; non-zero otherwise. +*/ + +'use strict' + +const { execFileSync } = require('node:child_process') +const fs = require('node:fs') +const path = require('node:path') + +const root = path.resolve(__dirname, '..') +const specsDir = path.join(root, 'specs') +const buildDir = path.join(root, 'build', 'acceptance') +const irDir = path.join(buildDir, 'ir') +const genDir = path.join(buildDir, 'generated') +const apsDir = path.join(root, 'tmp', 'aps-spec') + +function sh (cmd, args, opts = {}) { + return execFileSync(cmd, args, { + stdio: ['pipe', 'pipe', 'pipe'], + ...opts + }).toString() +} + +// Procure the latest APS tools if not already present in the worktree. +function ensureAps () { + if (fs.existsSync(apsDir)) return + fs.mkdirSync(path.dirname(apsDir), { recursive: true }) + sh('git', ['clone', '--depth', '1', + 'https://github.com/unclebob/Acceptance-Pipeline-Specification.git', apsDir]) +} + +function main () { + ensureAps() + + const features = fs + .readdirSync(specsDir) + .filter((f) => f.endsWith('.feature')) + .sort() + + if (features.length === 0) { + console.log('No feature files found under specs/.') + return + } + + fs.mkdirSync(irDir, { recursive: true }) + fs.mkdirSync(genDir, { recursive: true }) + + for (const featureFile of features) { + const base = featureFile.replace(/\.feature$/i, '') + const featurePath = path.join(specsDir, featureFile) + const irPath = path.join(irDir, `${base}.json`) + + // 1) Parse the feature to JSON IR using the Babashka APS gherkin-parser. + sh('bb', ['gherkin-parser', featurePath, irPath], { cwd: apsDir }) + + // 2) Generate executable acceptance entry points from the IR. + sh('node', [path.join(root, 'acceptance', 'lib', 'generate.js'), irPath, genDir]) + } + + // 3) Run every generated acceptance test. + const tests = fs + .readdirSync(genDir) + .filter((f) => f.endsWith('.acceptance.test.js')) + .sort() + + let failures = 0 + for (const testFile of tests) { + try { + const out = sh('node', [path.join(genDir, testFile)]) + process.stdout.write(out) + console.log(`ACCEPTANCE PASS: ${testFile}`) + } catch (err) { + failures++ + process.stdout.write(err.stdout || '') + process.stderr.write(err.stderr || '') + console.error(`ACCEPTANCE FAIL: ${testFile}`) + } + } + + if (failures > 0) { + console.error(`ACCEPTANCE: ${failures} failing test file(s)`) + process.exit(1) + } else { + console.log(`ACCEPTANCE: all ${tests.length} generated test file(s) passed`) + } +} + +main() diff --git a/acceptance/lib/generate.js b/acceptance/lib/generate.js new file mode 100644 index 0000000..592049d --- /dev/null +++ b/acceptance/lib/generate.js @@ -0,0 +1,112 @@ +/* + Project-specific acceptance entrypoint generator. + + Reads parser JSON IR and writes executable generated test entry points plus + per-feature metadata. Generated tests delegate all step behavior to the + acceptance runtime and project step handlers. + + Usage: + node acceptance/lib/generate.js + + Exit codes: + 0 generation succeeded + 1 generation error + 2 wrong command usage +*/ + +'use strict' + +const fs = require('node:fs') +const path = require('node:path') +const crypto = require('node:crypto') + +// Convert a feature path to a strict lowercase-and-hyphen metadata filename. +function metadataName (featureName) { + const slug = featureName + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + return `${slug || 'feature'}.json` +} + +// Compute a stable relative require path from a generated file's directory to +// a target module, with a './' or '../' prefix for require(). +function relativeRequire (fromDir, targetFile) { + let rel = path.relative(fromDir, targetFile).replace(/\\/g, '/') + if (!rel.startsWith('.')) rel = `./${rel}` + return rel +} + +function main () { + const irArg = process.argv[2] + const outArg = process.argv[3] + + if (!irArg || !outArg) { + console.error('usage: acceptance-entrypoint-generator ') + process.exit(2) + } + + let ir + try { + ir = JSON.parse(fs.readFileSync(irArg, 'utf8')) + } catch (err) { + console.error(`Failed to read JSON IR "${irArg}": ${err.message}`) + process.exit(1) + } + + const genDir = outArg + fs.mkdirSync(genDir, { recursive: true }) + + const featureKey = path.basename(irArg).replace(/\.json$/i, '') + const testFile = path.join(genDir, `${featureKey}.acceptance.test.js`) + const relRuntime = relativeRequire(genDir, path.join(__dirname, 'runtime.js')) + + const body = `'use strict' +const { runFeature } = require('${relRuntime}') +const ir = ${JSON.stringify(ir, null, 2)} +async function main () { + const report = await runFeature(ir) + for (const r of report.results) { + console.log((r.status === 'passed' ? 'PASS ' : 'FAIL ') + r.name) + if (r.detail) console.log(' ' + r.detail) + } + if (report.failures > 0) { + console.error('ACCEPTANCE FAILURES: ' + report.failures + ' of ' + report.total) + process.exitCode = 1 + } +} +main().catch((err) => { console.error(err); process.exit(1) }) +` + + try { + fs.writeFileSync(testFile, body) + } catch (err) { + console.error(`Failed to write generated test "${testFile}": ${err.message}`) + process.exit(1) + } + + // Per-feature metadata with an implementation hash over generated files only. + const metaDir = path.join(genDir, 'metadata') + fs.mkdirSync(metaDir, { recursive: true }) + const hash = crypto + .createHash('sha256') + .update(fs.readFileSync(testFile)) + .digest('hex') + + const metadata = { + schema_version: 1, + feature_path: `${featureKey}.feature`, + ir_path: path.resolve(irArg), + implementation_hash: `sha256:${hash}`, + hash_scope: 'generated_files', + generated_files: [testFile] + } + fs.writeFileSync( + path.join(metaDir, metadataName(featureKey)), + JSON.stringify(metadata, null, 2) + ) + + process.exit(0) +} + +main() diff --git a/acceptance/lib/handlers.js b/acceptance/lib/handlers.js new file mode 100644 index 0000000..5ce441e --- /dev/null +++ b/acceptance/lib/handlers.js @@ -0,0 +1,228 @@ +/* + 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. + + 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. +*/ + +'use strict' + +const MemoPost = require('../../src/services/memo-post') +const NewPostPage = require('../../src/services/new-post') + +const MEMO_POST_PREFIX = MemoPost.MEMO_POST_PREFIX + +// A fake wallet exposing the minimal-slp-wallet adapter surface the app uses. +function makeWallet (address) { + const wallet = { + walletInfo: { cashAddress: address }, + utxos: [], + broadcasts: [], + getUtxos: async function () { + return this.utxos + }, + sendOpReturn: async function (walletInfo, bchUtxos, msg, prefix) { + this.broadcasts.push({ walletInfo, bchUtxos, msg, prefix }) + return 'aa'.repeat(32) + } + } + return wallet +} + +// A fake feed reflecting posts added to the recent posts feed. +function makeFeed () { + const posts = [] + return { + posts, + addPost: (post) => posts.push(post) + } +} + +// Fresh world/state object for a single scenario execution. +function createWorld () { + const wallet = makeWallet('') + const feed = makeFeed() + const memoPost = new MemoPost({ wallet, feed }) + const world = { + wallet, + feed, + memoPost, + currentPath: null, + menuOpen: false + } + + // The New Post Page controller wraps the memo post behavior. Its navigate + // adapter updates the world's current path so navigation can be asserted. + world.newPage = new NewPostPage({ + memoPost, + navigate: (path) => { world.currentPath = path }, + menuLinks: [] + }) + + return world +} + +// Handler registry. Each entry: { pattern, run }. +// run receives (match, exampleStore, world, step). +const handlers = [ + { + name: 'wallet authenticated for address', + pattern: /^a wallet authenticated for the address (.+)$/, + run (m, example, world) { + world.wallet.walletInfo.cashAddress = m[1].trim() + } + }, + { + name: 'wallet has spendable output', + pattern: /^the wallet has (?:a )?spendable output to pay the transaction fee$/, + run (m, example, world) { + world.wallet.utxos = [{ txid: 'utxo-for-fee', value: 100000 }] + } + }, + { + name: 'viewing recent posts feed', + pattern: /^I am viewing the recent posts feed$/, + run (m, example, world) { + world.currentPath = NewPostPage.RECENT_FEED_PATH + } + }, + { + name: 'navigate to path', + pattern: /^I navigate to the path (.+)$/, + run (m, example, world, step) { + const target = m[1].trim() + if (step.keyword === 'Then') { + if (world.currentPath !== target) { + throw new Error(`Expected to be on path ${target}, but current path is ${world.currentPath}.`) + } + } else { + world.currentPath = target + } + } + }, + { + name: 'open navigation menu', + pattern: /^I open the navigation menu$/, + run (m, example, world) { + world.menuOpen = true + } + }, + { + name: 'menu shows link to path', + pattern: /^the menu shows a link to the path (.+)$/, + run (m, example, world) { + const target = m[1].trim() + if (!world.newPage.hasMenuLink(target)) { + throw new Error(`Navigation menu does not link to ${target}.`) + } + } + }, + { + name: 'compose/type memo text', + pattern: /^I (?:compose|type) a memo 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.newPage.setInput(example[param]) + } + }, + { + name: 'submit/click post', + pattern: /^I (?:submit the memo|click the post button)$/, + async run (m, example, world) { + await world.newPage.submit() + } + }, + { + name: 'broadcasts OP_RETURN with Memo post prefix', + pattern: /^(?:the wallet|the app) broadcasts an OP_RETURN transaction with the Memo post 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_POST_PREFIX) { + throw new Error(`Expected Memo post prefix ${MEMO_POST_PREFIX}, got "${last.prefix}".`) + } + if (last.msg !== world.newPage.input) { + throw new Error('Broadcast message text did not match the composed memo.') + } + } + }, + { + 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_]+)>"$/, + run (m, example, world) { + const param = m[1] + const expectedText = example[param] + const myAddress = world.wallet.walletInfo.cashAddress + const found = world.feed.posts.find( + (p) => p.text === expectedText && p.address === myAddress + ) + if (!found) { + throw new Error(`Feed does not show the new post with text "${expectedText}".`) + } + } + }, + { + name: 'page shows validation/length error', + pattern: /^the (?:app|new post page) shows a (validation|length) error$/, + run (m, example, world) { + const kind = m[1] + const expectedCode = kind === 'validation' ? 'memo_validation' : 'memo_length' + if (world.newPage.submitError !== expectedCode) { + throw new Error(`Expected ${expectedCode}, got ${world.newPage.submitError}.`) + } + } + }, + { + name: 'remaining character count', + pattern: /^the new post page shows a remaining character 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.newPage.remainingCount() + if (actual !== expected) { + throw new Error(`Expected ${expected} remaining characters, got ${actual}.`) + } + } + }, + { + name: 'app does not broadcast any transaction', + pattern: /^(?:the wallet|the app) does not broadcast any transaction$/, + run (m, example, world) { + if (world.wallet.broadcasts.length !== 0) { + throw new Error('A transaction was broadcast when none was expected.') + } + } + } +] + +// Route a single step to its handler. Throws on unsupported step text. +async function handleStep (step, example, world) { + for (const handler of handlers) { + const match = handler.pattern.exec(step.text) + if (match) { + await handler.run(match, example, world, step) + return + } + } + throw new Error(`Unsupported step: ${step.keyword} ${step.text}`) +} + +module.exports = { createWorld, handleStep } diff --git a/acceptance/lib/runner-worker.js b/acceptance/lib/runner-worker.js new file mode 100644 index 0000000..332eaae --- /dev/null +++ b/acceptance/lib/runner-worker.js @@ -0,0 +1,68 @@ +/* + Persistent runner adapter for the APS gherkin-mutator. + + The mutator starts this process (once per worker) and sends mutation jobs + over newline-delimited JSON on stdin. Each job carries the path to a mutated + feature JSON IR; this worker evaluates it through the same acceptance runtime + and step handlers used by the normal acceptance pipeline and replies with the + runner outcome. + + Protocol (mutator-spec.md): + request: { "id", "feature_json", "generated_dir", "work_dir" } + response: { "id", "outcome", "output", "error", "duration" } + outcome: test_success | test_failure | infrastructure_error + + test_failure (acceptance failed) -> mutation killed + test_success (acceptance passed) -> mutation survived +*/ + +'use strict' + +const readline = require('node:readline') +const fs = require('node:fs') +const { runFeature } = require('./runtime') + +const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + terminal: false +}) + +rl.on('line', async (line) => { + const started = Date.now() + const respond = (payload) => { + process.stdout.write(`${JSON.stringify(payload)}\n`) + } + + let job + try { + job = JSON.parse(line) + } catch (err) { + respond({ id: 'unknown', outcome: 'infrastructure_error', output: '', error: `bad job: ${err.message}`, duration: Date.now() - started }) + return + } + + try { + const ir = JSON.parse(fs.readFileSync(job.feature_json, 'utf8')) + const report = await runFeature(ir) + respond({ + id: job.id, + outcome: report.failures === 0 ? 'test_success' : 'test_failure', + output: report.results.map((r) => `${r.status} ${r.name}`).join('\n'), + error: '', + duration: Date.now() - started + }) + } catch (err) { + respond({ + id: job.id, + outcome: 'infrastructure_error', + output: '', + error: err.message, + duration: Date.now() - started + }) + } +}) + +rl.on('close', () => { + process.exit(0) +}) diff --git a/acceptance/lib/runtime.js b/acceptance/lib/runtime.js new file mode 100644 index 0000000..71c3421 --- /dev/null +++ b/acceptance/lib/runtime.js @@ -0,0 +1,69 @@ +/* + Acceptance runtime for psf-memo-client. + + Expands each scenario (and each example row) from parser JSON IR into + scenario executions, prepends background steps, and dispatches every step to + the project step handlers. Unsupported steps, invalid example values, or + failed assertions fail that execution. +*/ + +'use strict' + +const { createWorld, handleStep } = require('./handlers') + +// Expand the IR scenarios into concrete executions. +// For scenario outlines with examples, one execution per example row; for +// scenarios without examples, one execution with an empty example store. +function expandScenarios (ir) { + const executions = [] + const background = ir.background || [] + + for (const scenario of ir.scenarios) { + const examples = (scenario.examples && scenario.examples.length > 0) + ? scenario.examples.map((example, i) => ({ example, suffix: `example_${i + 1}` })) + : [{ example: {}, suffix: 'example_1' }] + + for (const { example, suffix } of examples) { + executions.push({ + name: `${scenario.name}/${suffix}`, + steps: [...background, ...scenario.steps], + example + }) + } + } + + return executions +} + +// Run a full feature and return a report of scenario outcomes. +async function runFeature (ir) { + const executions = expandScenarios(ir) + const results = [] + let failures = 0 + + for (const ex of executions) { + // A fresh world/state object for each scenario execution. + const world = createWorld() + let failure = null + + for (const step of ex.steps) { + try { + await handleStep(step, ex.example, world) + } catch (err) { + failure = err.message + break + } + } + + if (failure) { + failures++ + results.push({ name: ex.name, status: 'failed', detail: failure }) + } else { + results.push({ name: ex.name, status: 'passed' }) + } + } + + return { feature: ir.name, results, failures, total: results.length } +} + +module.exports = { expandScenarios, runFeature } diff --git a/docs/reviews/new-post-page-summary.md b/docs/reviews/new-post-page-summary.md new file mode 100644 index 0000000..a003fc1 --- /dev/null +++ b/docs/reviews/new-post-page-summary.md @@ -0,0 +1,62 @@ +# Architectural Review Summary — new-post-page + +## Task and commits reviewed +- Task: `new-post-page` +- Reviewed the merged branch ending at `d57975c466` (refactorer), which carried: + - `9385034`/`4fa92d1` — specifier New Post Page Gherkin spec (`specs/memo-new.feature`) + - `05f393c` — coder implementation (`src/services/new-post.js`, page wiring) + - `d57975c` — refactorer property tests for memo post / new post invariants +- 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. **Testable controller behind adapters (good).** `src/services/new-post.js` is a + testable controller wrapping `memo-post.js`; it holds draft input, the remaining- + character counter, typed validation/length errors, and feed navigation. `memoPost` + and `navigate` are injected so the module stays free of UI/IO concerns. Dependency + direction is inward. +2. **Unified acceptance handlers (good).** `acceptance/lib/handlers.js` now drives both + `post-memo.feature` and `memo-new.feature` through one `world.newPage`, reusing the + shared Memo post behavior. Wording differences are normalized via shared regex + alternations; no step logic is duplicated. +3. **Property tests (good).** `test/property/{harness,memo-post.property.test}.js` + assert seeded invariants (validation classification across the length boundary, + counter conservation `remaining === MAX - len`, `setInput` round-trip, menu-link + idempotence). Kept separate from unit tests as the architecture requires. +4. **Fix applied — posting-state coverage.** The language mutation tool surfaced 4 + survivors in `new-post.js`, all around the `posting` state flag (initial value and + its true/false transitions in `submit`). Added unit coverage asserting the page + starts idle, is `posting=true` while a submit is in flight (via a deferred wallet), + and returns to `posting=false` on success and on error. This killed all 4 survivors. + +## Verification results +- **Unit (`node --test`):** 18/18 pass (added 2 posting-state tests). +- **Property (`npm run test:property`):** 6/6 pass. +- **Acceptance (normal):** both `memo-new` and `post-memo` generated suites pass. +- **Mutation (`mutate4javascript`, `--max-workers 8`):** + - `memo-post.js`: differential reuse (module unchanged since its 7/7 kill). + - `new-post.js`: after coverage fix, **killed 11 / survived 0 / uncovered 0** + (was 4 survivors before the fix). +- **DRY (`dry4javascript src`):** no duplicate candidates. +- **Gherkin acceptance mutation (soft):** + - `memo-new.feature`: 11 executed — **5 killed, 6 survived**, 0 errors. + - Killed: empty-memo boundary; character-counter `count` example values (217/212/0) + — proving those values are connected to behavior. + - Survived (documented equivalents): message-content dithers (4) plus case/length- + neutral dithers in the counter scenario — message text is opaque data; only length + affects the counter, so these don't change observable behavior. + - `post-memo.feature`: empty-memo scenario reused as killed; remaining 5 executed are + the same documented message-content equivalents. +- Property tests: run separately via `npm run test:property`. + +## Suite status +- Unit + property + acceptance all pass; source-level mutation fully kills both testable + core modules. Gherkin acceptance mutation survivors are documented equivalents. + +## Handoffs sent +- `git_handoff` → coder, refactorer (priority `00`, task `new-post-page`), to review the + architect commit (posting-state coverage + tool manifests). + +By architect. diff --git a/docs/reviews/post-memo-summary.md b/docs/reviews/post-memo-summary.md new file mode 100644 index 0000000..2f506fd --- /dev/null +++ b/docs/reviews/post-memo-summary.md @@ -0,0 +1,66 @@ +# Architectural Review Summary — post-memo + +## Task and commits reviewed +- Task: `post-memo` +- Reviewed the merged branch ending at `8e2693ad08` (refactorer), which carried: + - `d071b21`/`c9556be` — specifier Post-a-Memo Gherkin spec + feature backlog + - `514298b` — coder implementation with acceptance pipeline + - `64a7f78` — refactorer merge of coder work + - `8e2693a` — refactorer memo-post complexity reduction +- 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. **Testable core behind small adapters (good).** `src/services/memo-post.js` is a + testable module free of UI/IO concerns. It injects `wallet` and `feed` adapters + (minimal-slp-wallet surface + feed reflection), so OP_RETURN/broadcast and UI + concerns stay outside the core. Environmentally unsuitable I/O is confined to + adapter boundaries. Dependency direction is inward. +2. **Acceptance pipeline separation (good).** `acceptance/lib/{generate,runtime,handlers}` + are the project-specific components the APS spec prescribes. The feature is parsed + by the **APS-supplied Babashka `gherkin-parser`** (procured fresh from + github.com/unclebob/Acceptance-Pipeline-Specification on first use), not + reimplemented. `handlers.js` drives real core behavior through fake wallet/feed + adapters so the run is deterministic and offline. +3. **Information hiding (good).** memo-post hides the Memo `0x6d02` prefix and + broadcast mechanics; handlers only assert observable outcomes (broadcast prefix, + feed reflection, empty/length rejection). +4. **Fix applied — runner adapter added.** Built the project-specific persistent + runner adapter required by `gherkin-mutator` at + `acceptance/lib/runner-worker.js` (newline-delimited JSON protocol; evaluates each + mutated feature IR through the same runtime/handlers and reports + `test_failure|test_success|infrastructure_error`). +5. **Manifests updated by tools (permitted).** The language mutation tool embedded + its differential footer manifest in `src/services/memo-post.js`; the APS mutator + wrote the acceptance-mutation scenario manifest into `specs/post-memo.feature`. + Both are normal tool output and were left for the tooling, not hand-edited. + +## Verification results +- **Unit (`node --test`):** 7/7 pass (`memo-post.test.js`). +- **Acceptance (normal):** all 6 scenario executions pass. +- **Mutation (`mutate4javascript` differential, `--max-workers 8`):** + `memo-post.js` — **killed 7, survived 0, uncovered 0**. Full kill. +- **DRY (`dry4javascript src`):** no duplicate candidates. +- **Gherkin acceptance mutation (soft):** 6 discovered; **1 killed**, **5 survived**, + 0 errors. + - Killed: the empty-memo boundary — dithering the `" "` example makes the + validation branch fail the scenario, confirming the empty-rejection guardrail is + connected to the example data. + - Survived (documented equivalents): message-content dithers in the valid-memo + scenario and the over-long scenarios. The tests treat the message as opaque data + and assert the *same* (mutated) text is composed and reflected, so changing one + character leaves the exercised branch identical. These are acceptable equivalent + survivors, not missing guardrails. +- **Property tests:** none present in this project. + +## Suite status +- Unit + acceptance suites pass; mutation fully kills source-level mutants for the + testable core. Gherkin acceptance mutation has 5 documented-equivalent survivors. + +## Handoffs sent +- `git_handoff` → coder, refactorer (priority `00`, task `post-memo`), to review the + architectural commit (runner adapter + tool manifests). + +By architect. diff --git a/docs/reviews/reduce-dry-duplication-summary.md b/docs/reviews/reduce-dry-duplication-summary.md new file mode 100644 index 0000000..6b280c1 --- /dev/null +++ b/docs/reviews/reduce-dry-duplication-summary.md @@ -0,0 +1,56 @@ +# Architectural Review Summary — reduce-dry-duplication + +## Task and commits reviewed +- Task: `reduce-dry-duplication` +- Source commit merged and reviewed: `eed9d70f6f` (`Reduce DRY duplication in services and components`, from refactorer) +- Reviewed as a batch via `ready_for_next.sh`; merged into `swarmforge-architect` as a fast-forward and processed. + +## Architectural findings and fixes applied +Reviewed the refactorer's DRY reduction for UI/Core separation, dependency rule, +information hiding/encapsulation, and local code quality. + +Findings (mostly sound, two notes): +1. **MemoDb service consolidation** (`src/services/memo-db.js`) — `getRecentProfiles`/ + `getRecentPosts` → `getRecent`, and `getProfile`/`getProfilePic`/`getName` → + `getLevelResource`. Preserves error semantics (recent → always throws; level → null + on 404). Good cohesion, HTTP details stay in the service. **No change needed.** +2. **AppUtil `pasteFromClipboard`** (`src/util/index.js`) — centralizes clipboard-paste + into the shared utility; components no longer duplicate clipboard reading. Good + information hiding. **No change needed.** +3. **`PlaceholderView` extraction** — shared placeholder used by placeholder2/3. + Cohesive. **No change needed.** +4. **`wallet-summary` blur toggle generalization** — `toggleBlur(field)` via dynamic + setter name reduces duplication. Functional and correct; the dynamic + `set${Capitalized}` construction is a mild readability tradeoff but acceptable to + preserve the DRY intent. **No change.** +5. **Cross-feature dependency (fixed)** — `recent-profiles` imported the general-purpose + `truncateAddr`/`truncateTxid` from the feed-specific `post-feed/post-display`. + For cohesion and dependency direction, these pure string helpers belong in the + shared utility layer. **Applied fix:** moved `truncateAddr`/`truncateTxid` to + `src/util/index.js`; `post-display` now imports from util and re-exports them for + its existing consumers; `recent-profiles` imports directly from `src/util`. + This removes a sibling-feature → sibling-feature module coupling and keeps generic + display helpers in the shared utility. + +## Verification results +- **Mutation** (`mutate4javascript --scan`): memo-db.js 4 sites, util/index.js 6 sites, + post-display.js 8 sites. No JS unit test suite exists in this project + (`npm test` → `echo 'no tests'`); there is no coverage harness, so all sites are + uncovered and no survivors can be killed by tests. JSX component files cannot be + parsed by the mutation tool (no `jsx` Babel plugin); they are UI adapters and are + outside the currently testable boundary. +- **DRY** (`dry4javascript src`): **no duplicate candidates found** — the reduction + is effective and my fix introduced no duplication. +- **Gherkin acceptance mutation (soft)**: not runnable — this project has no + `.feature` files, no acceptance pipeline, and no runner adapter. +- Property tests: none present in this project. + +## Suite status +- No JavaScript unit/acceptance suite exists (`npm test` echoes "no tests"). +- `bb.edn` test task covers the swarmforge helper scripts only (unrelated to this UI work). + +## Handoffs sent +- `git_handoff` → coder, refactorer (priority `00`, task `reduce-dry-duplication`), + to review the architectural commit. + +By architect. diff --git a/package.json b/package.json index da6cbd5..13e9949 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,9 @@ "scripts": { "start": "react-scripts start", "build": "react-scripts build", - "test": "echo 'no tests'", + "test": "node --test \"test/unit/*.test.js\"", + "test:property": "node --test \"test/property/*.test.js\"", + "test:acceptance": "node acceptance/acceptance.js", "eject": "react-scripts eject", "lint": "standard --env mocha --fix", "pub": "node deploy/publish-main.js", diff --git a/specs/memo-new.feature b/specs/memo-new.feature index 031e1a7..3c9377f 100644 --- a/specs/memo-new.feature +++ b/specs/memo-new.feature @@ -1,3 +1,7 @@ +# acceptance-mutation-manifest-begin +# {"version":1,"tested_at":"2026-08-26T00:08:15.433121898Z","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"}]} +# 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 Feature: New Post Page diff --git a/specs/post-memo.feature b/specs/post-memo.feature index 9433ecf..bd62b23 100644 --- a/specs/post-memo.feature +++ b/specs/post-memo.feature @@ -1,3 +1,7 @@ +# 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"}]} +# acceptance-mutation-manifest-end + # Scenarios: Post a Memo - 1, Post a Memo - 2, Post a Memo - 3 Feature: Post a Memo diff --git a/src/components/app-body/bch-wallet/import-wallet.js b/src/components/app-body/bch-wallet/import-wallet.js index 38cca79..6708a1e 100644 --- a/src/components/app-body/bch-wallet/import-wallet.js +++ b/src/components/app-body/bch-wallet/import-wallet.js @@ -14,14 +14,7 @@ const WalletImport = (props) => { const { appData } = props // Load mnemonic from clipboard - const pasteFromClipboard = async () => { - try { - const mnemonic = await appData.appUtil.readFromClipboard() - setNewMnemonic(mnemonic) - } catch (err) { - console.warn('Error pasting from clipboard: ', err) - } - } + const pasteFromClipboard = () => appData.appUtil.pasteFromClipboard(setNewMnemonic) // Handle input change for mnemonic const handleImportMnemonic = async (event) => { diff --git a/src/components/app-body/bch-wallet/wallet-summary.js b/src/components/app-body/bch-wallet/wallet-summary.js index 998a4dc..b766d34 100644 --- a/src/components/app-body/bch-wallet/wallet-summary.js +++ b/src/components/app-body/bch-wallet/wallet-summary.js @@ -38,29 +38,14 @@ function WalletSummary (props) { privateKey: blurredPrivateKey ? faEyeSlash : faEye } - // Toggle the state of blurring for the mnemonic - const toggleMnemonicBlur = (inObj = {}) => { + // Toggle the state of blurring for a field (mnemonic or private key) + const toggleBlur = (field) => { try { - const { walletSummaryData } = inObj - - // toggle the state of blurring - const blurredState = walletSummaryData.blurredMnemonic - walletSummaryData.setBlurredMnemonic(!blurredState) + const blurredState = walletSummaryData[field] + const setterName = `set${field[0].toUpperCase()}${field.slice(1)}` + walletSummaryData[setterName](!blurredState) } catch (error) { - console.error('Error toggling mnemonic blur: ', error) - } - } - - // Toggle the state of blurring for the private key - const togglePrivateKeyBlur = (inObj = {}) => { - try { - const { walletSummaryData } = inObj - - // toggle the state of blurring - const blurredState = walletSummaryData.blurredPrivateKey - walletSummaryData.setBlurredPrivateKey(!blurredState) - } catch (error) { - console.error('Error toggling private key blur: ', error) + console.error(`Error toggling ${field} blur: `, error) } } @@ -87,7 +72,7 @@ function WalletSummary (props) { style={{ cursor: 'pointer' }} icon={eyeIcon.mnemonic} size='lg' - onClick={() => toggleMnemonicBlur({ walletSummaryData })} + onClick={() => toggleBlur('blurredMnemonic')} /> @@ -104,7 +89,7 @@ function WalletSummary (props) { style={{ cursor: 'pointer' }} icon={eyeIcon.privateKey} size='lg' - onClick={() => togglePrivateKeyBlur({ walletSummaryData })} + onClick={() => toggleBlur('blurredPrivateKey')} /> diff --git a/src/components/app-body/index.js b/src/components/app-body/index.js index dae45d3..e287851 100644 --- a/src/components/app-body/index.js +++ b/src/components/app-body/index.js @@ -25,6 +25,7 @@ import ServerSelectView from './configuration/select-server-view' import UserDataReview from './user-data-review' import RecentProfiles from './recent-profiles' import RecentPosts from './posts' +import NewPost from './new-post' import Profile from './profile' function AppBody (props) { @@ -42,6 +43,7 @@ function AppBody (props) { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/components/app-body/new-post/index.js b/src/components/app-body/new-post/index.js new file mode 100644 index 0000000..647abfc --- /dev/null +++ b/src/components/app-body/new-post/index.js @@ -0,0 +1,90 @@ +/* + New Post view: compose and broadcast a Memo post, with a character counter + that counts down from the memo limit. On success the user is navigated to the + recent feed. +*/ + +// 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 MemoPost from '../../../services/memo-post' +import NewPostPage from '../../../services/new-post' + +function NewPost (props) { + const { appData } = props + const navigate = useNavigate() + + const maxChars = MemoPost.MAX_MEMO_CHARS + const [input, setInput] = useState('') + const [err, setErr] = useState('') + const [posting, setPosting] = useState(false) + + const remaining = maxChars - input.length + + async function handleSubmit (event) { + event.preventDefault() + setErr('') + setPosting(true) + + try { + const memoPost = new MemoPost({ wallet: appData?.wallet }) + const page = new NewPostPage({ memoPost, navigate }) + page.setInput(input) + + 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.' + ) + } + // On success page.submit() navigated to the recent feed. + } catch (submitErr) { + setErr(submitErr.message) + } finally { + setPosting(false) + } + } + + return ( + + + +
+

New Post

+

Compose a Memo message and publish it to Bitcoin Cash.

+
+ +
+ + Message + setInput(e.target.value)} + placeholder='Write your Memo here...' + /> + + +

+ {remaining} characters remaining +

+ + {err &&

{err}

} + + +
+ +
+
+ ) +} + +export default NewPost diff --git a/src/components/app-body/placeholder-view.js b/src/components/app-body/placeholder-view.js new file mode 100644 index 0000000..202d2da --- /dev/null +++ b/src/components/app-body/placeholder-view.js @@ -0,0 +1,22 @@ +/* + A placeholder view used for unreviewed routes. +*/ + +// Global npm libraries +import React, { useEffect } from 'react' + +function PlaceholderView (props) { + const { viewNumber } = props + + useEffect(() => { + console.log(`Placeholder ${viewNumber} loaded.`) + }, [viewNumber]) + + return ( + <> +

This is placeholder View #{viewNumber}

+ + ) +} + +export default PlaceholderView diff --git a/src/components/app-body/placeholder2.js b/src/components/app-body/placeholder2.js index 7fc24f6..825d48a 100644 --- a/src/components/app-body/placeholder2.js +++ b/src/components/app-body/placeholder2.js @@ -2,19 +2,11 @@ This is a placeholder View */ -// Global npm libraries -import React, { useEffect } from 'react' +// Local libraries +import PlaceholderView from './placeholder-view' function Placeholder2 (props) { - useEffect(() => { - console.log('Placeholder 2 loaded.') - }, []) - - return ( - <> -

This is placeholder View #2

- - ) + return } export default Placeholder2 diff --git a/src/components/app-body/placeholder3.js b/src/components/app-body/placeholder3.js index 22ff854..cd390a3 100644 --- a/src/components/app-body/placeholder3.js +++ b/src/components/app-body/placeholder3.js @@ -2,19 +2,11 @@ This is a placeholder View */ -// Global npm libraries -import React, { useEffect } from 'react' +// Local libraries +import PlaceholderView from './placeholder-view' function Placeholder3 (props) { - useEffect(() => { - console.log('Placeholder 3 loaded.') - }, []) - - return ( - <> -

This is placeholder View #3

- - ) + return } export default Placeholder3 diff --git a/src/components/app-body/recent-profiles/index.js b/src/components/app-body/recent-profiles/index.js index fa596a9..210dcde 100644 --- a/src/components/app-body/recent-profiles/index.js +++ b/src/components/app-body/recent-profiles/index.js @@ -8,17 +8,11 @@ import { Container, Row, Col, Spinner, Table } from 'react-bootstrap' // Local libraries import MemoDb from '../../../services/memo-db' -import AppUtil from '../../../util' +import AppUtil, { truncateAddr, truncateTxid } from '../../../util' import '../../../App.css' const appUtil = new AppUtil() -function truncate (str, maxLen = 16) { - if (!str || str.length <= maxLen) return str - const half = Math.floor((maxLen - 3) / 2) - return `${str.slice(0, half)}...${str.slice(-half)}` -} - function formatSeen (seen) { if (!seen) return '' const ms = seen > 1e12 ? seen : seen * 1000 @@ -88,7 +82,7 @@ function RecentProfiles () { style={{ fontFamily: 'monospace' }} title={profile.addr} > - {truncate(profile.addr, 24)} + {truncateAddr(profile.addr, 24)} {profile.text} @@ -100,7 +94,7 @@ function RecentProfiles () { title={profile.txid} onClick={() => appUtil.copyToClipboard(profile.txid)} > - {truncate(profile.txid, 20)} + {truncateTxid(profile.txid, 20)} diff --git a/src/components/app-body/slp-tokens/send-token-button.js b/src/components/app-body/slp-tokens/send-token-button.js index b268f74..1f68fc8 100644 --- a/src/components/app-body/slp-tokens/send-token-button.js +++ b/src/components/app-body/slp-tokens/send-token-button.js @@ -105,14 +105,7 @@ function SendTokenButton ({ token, appData, refreshTokens }) { } // Load address from clipboard - const pasteFromClipboard = async () => { - try { - const address = await appData.appUtil.readFromClipboard() - setSendToAddress(address) - } catch (err) { - console.warn('Error pasting from clipboard: ', err) - } - } + const pasteFromClipboard = () => appData.appUtil.pasteFromClipboard(setSendToAddress) // Modal JSX const getModal = () => { diff --git a/src/components/nav-menu/index.js b/src/components/nav-menu/index.js index 9c1a7ae..e1acdc9 100644 --- a/src/components/nav-menu/index.js +++ b/src/components/nav-menu/index.js @@ -70,6 +70,14 @@ function NavMenu (props) { Posts + + New Post + + 1e12 ? seen : seen * 1000 @@ -26,16 +30,6 @@ export function formatRelativeSeen (seen) { return `${years}y` } -export function truncateAddr (addr, maxLen = 20) { - if (!addr || addr.length <= maxLen) return addr - const half = Math.floor((maxLen - 3) / 2) - return `${addr.slice(0, half)}...${addr.slice(-half)}` -} - -export function truncateTxid (txid, maxLen = 20) { - return truncateAddr(txid, maxLen) -} - export function getDisplayName (addr, profiles) { const profile = profiles?.[addr] if (profile?.name) { diff --git a/src/services/memo-db.js b/src/services/memo-db.js index fea9537..2eb49ab 100644 --- a/src/services/memo-db.js +++ b/src/services/memo-db.js @@ -11,72 +11,51 @@ class MemoDb { } async getRecentProfiles ({ limit = 100, offset = 0 } = {}) { - try { - const result = await this.axios.get(`${config.backend}/profile/recent`, { - params: { limit, offset } - }) - - return result.data - } catch (err) { - console.error('Error in getRecentProfiles()') - throw err - } + return this.getRecent('/profile/recent', 'getRecentProfiles', { limit, offset }) } async getRecentPosts ({ limit = 100, offset = 0 } = {}) { + return this.getRecent('/posts/recent', 'getRecentPosts', { limit, offset }) + } + + async getProfile (addr) { + return this.getLevelResource('profile', addr, 'getProfile') + } + + async getProfilePic (addr) { + return this.getLevelResource('profilepic', addr, 'getProfilePic') + } + + async getName (addr) { + return this.getLevelResource('name', addr, 'getName') + } + + // GET a paginated 'recent' listing endpoint. + async getRecent (path, name, params) { try { - const result = await this.axios.get(`${config.backend}/posts/recent`, { - params: { limit, offset } + const result = await this.axios.get(`${config.backend}${path}`, { + params }) return result.data } catch (err) { - console.error('Error in getRecentPosts()') + console.error(`Error in ${name}()`) throw err } } - async getProfile (addr) { + // GET a level endpoint that resolves an address. Returns null on 404. + async getLevelResource (endpoint, addr, name) { try { const result = await this.axios.get( - `${config.backend}/level/profile/${encodeURIComponent(addr)}` + `${config.backend}/level/${endpoint}/${encodeURIComponent(addr)}` ) return result.data } catch (err) { if (err.response && err.response.status === 404) { return null } - console.error('Error in getProfile()') - throw err - } - } - - async getProfilePic (addr) { - try { - const result = await this.axios.get( - `${config.backend}/level/profilepic/${encodeURIComponent(addr)}` - ) - return result.data - } catch (err) { - if (err.response && err.response.status === 404) { - return null - } - console.error('Error in getProfilePic()') - throw err - } - } - - async getName (addr) { - try { - const result = await this.axios.get( - `${config.backend}/level/name/${encodeURIComponent(addr)}` - ) - return result.data - } catch (err) { - if (err.response && err.response.status === 404) { - return null - } - console.error('Error in getName()') + console.error(`Error in ${name}()`) throw err } } diff --git a/src/services/memo-post.js b/src/services/memo-post.js new file mode 100644 index 0000000..16100b3 --- /dev/null +++ b/src/services/memo-post.js @@ -0,0 +1,101 @@ +/* + Memo post behavior: compose, validate, and broadcast a Memo "post" message. + + A Memo post is an OP_RETURN Bitcoin Cash transaction carrying the Memo post + protocol prefix (0x6d02) followed by the message text. Broadcasting is done + through a wallet that exposes the minimal-slp-wallet adapter surface + (walletInfo, getUtxos(), sendOpReturn()). + + The wallet and feed 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_POST_PREFIX : hex prefix for the Memo "post" action (0x6d02) + MAX_MEMO_CHARS : maximum allowed memo length (spec boundary: 217 valid, + 218 rejected) +*/ + +const MEMO_POST_PREFIX = '6d02' +const MAX_MEMO_CHARS = 217 + +class MemoPost { + constructor (deps = {}) { + this.wallet = deps.wallet + this.feed = deps.feed + } + + // Validate a candidate memo message. + // Returns { ok: true } or { ok: false, type: 'validation' | 'length' }. + validate (message) { + if (typeof message !== 'string' || message.trim().length === 0) { + return { ok: false, type: 'validation' } + } + + if (message.length > MAX_MEMO_CHARS) { + return { ok: false, type: 'length' } + } + + return { ok: true } + } + + // Compose and broadcast a Memo post for the given message. + // Resolves with the transaction id, or rejects with a typed error. + async post (message) { + const check = this.validate(message) + this._throwIfInvalid(check) + + if (!this.wallet) { + throw new Error('Memo post requires a wallet.') + } + + // Spendable outputs used to pay the transaction fee. + const bchUtxos = await this.wallet.getUtxos() + + // Broadcast the OP_RETURN transaction with the Memo post prefix. + const txid = await this.wallet.sendOpReturn( + this.wallet.walletInfo, + bchUtxos, + message, + MEMO_POST_PREFIX + ) + + // Reflect the new post in the feed once broadcast succeeds. + this._reflectPost(txid, message) + + return txid + } + + // Throw the appropriate typed error when a memo fails validation. + _throwIfInvalid (check) { + if (check.ok) return + + const err = new Error( + check.type === 'length' + ? `Memo is too long. Maximum is ${MAX_MEMO_CHARS} characters.` + : 'Memo must not be empty.' + ) + err.code = check.type === 'length' ? 'memo_length' : 'memo_validation' + throw err + } + + // Record the new post on the injected feed when one is present. + _reflectPost (txid, message) { + if (this.feed && typeof this.feed.addPost === 'function') { + this.feed.addPost({ + txid, + address: this.wallet.walletInfo.cashAddress, + text: message + }) + } + } +} + +MemoPost.MEMO_POST_PREFIX = MEMO_POST_PREFIX +MemoPost.MAX_MEMO_CHARS = MAX_MEMO_CHARS + +module.exports = MemoPost + +// mutate4javascript-manifest-begin +// {"version":1,"tested_at":"2026-08-26T00:06:35.333Z","module_hash":"600c2edb145b16db5e313a2911fe164a2c08731346f2a67f52bca18827d8081e","functions":[{"id":"func/MemoPost.constructor","name":"MemoPost.constructor","line":23,"end_line":26,"hash":"73596685cdf614a4aa3bb3ab2ee2eec1c080e41ef8c56053a521eb07ca5c7d48"},{"id":"func/MemoPost.validate","name":"MemoPost.validate","line":30,"end_line":40,"hash":"2e45fb32d480e36e04ac61c3fb414849d9daa640c5ac366ee1363be4c3903fd0"},{"id":"func/MemoPost.post","name":"MemoPost.post","line":44,"end_line":67,"hash":"6a817a7eceb24e9e4eb9689345ea3ef6456e8b872bff00a0587bddae8060ead2"},{"id":"func/MemoPost._throwIfInvalid","name":"MemoPost._throwIfInvalid","line":70,"end_line":80,"hash":"e01932c7c343519cc8dd52d3e29b695783c6cdb7e84368e193140827c26bb39c"},{"id":"func/MemoPost._reflectPost","name":"MemoPost._reflectPost","line":83,"end_line":91,"hash":"36e9b77ac19b8a0c598e02f438c3d2ac1f6b7495cf6e28d9546d064ce63f861a"}]} +// mutate4javascript-manifest-end diff --git a/src/services/new-post.js b/src/services/new-post.js new file mode 100644 index 0000000..8949544 --- /dev/null +++ b/src/services/new-post.js @@ -0,0 +1,87 @@ +/* + New Post Page behavior: compose and post a Memo, with a character counter + that counts down from the memo limit. + + This is the testable controller behind the React "New Post" page. It wraps + the Memo post behavior (src/services/memo-post.js) and adds page-level + concerns: holding the current input, computing the remaining character count, + surfacing validation/length errors, and navigating to the recent feed after a + successful post. + + The memoPost 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 MemoPost = require('./memo-post') + +const NEW_POST_PATH = '/posts/new' +const RECENT_FEED_PATH = '/posts/recent' + +class NewPostPage { + constructor (deps = {}) { + this.memoPost = deps.memoPost || null + this.navigate = deps.navigate || (() => {}) + this.menuLinks = deps.menuLinks || [] + + this.input = '' + this.submitError = null + this.posting = false + + // The navigation menu links to the new post page. + this.addMenuLink(NEW_POST_PATH) + } + + // Record a navigation menu link offered by the app. + addMenuLink (path) { + if (!this.menuLinks.includes(path)) this.menuLinks.push(path) + return this + } + + // Whether the navigation menu exposes a link to the given path. + hasMenuLink (path) { + return this.menuLinks.includes(path) + } + + // Set the draft memo text and update the counter. + setInput (text) { + this.input = typeof text === 'string' ? text : '' + return this + } + + // Characters remaining before the memo limit is reached. + remainingCount () { + return MemoPost.MAX_MEMO_CHARS - this.input.length + } + + // Validate and post the current draft. On success, navigate to the recent + // feed. On failure, record the typed error. Resolves with a result object. + async submit () { + this.posting = true + this.submitError = null + + try { + if (!this.memoPost) { + throw new Error('New post requires a memo post handler.') + } + + const txid = await this.memoPost.post(this.input) + this.navigate(RECENT_FEED_PATH) + this.posting = false + return { ok: true, txid } + } catch (err) { + this.submitError = err.code || 'memo_validation' + this.posting = false + return { ok: false, error: this.submitError } + } + } +} + +NewPostPage.NEW_POST_PATH = NEW_POST_PATH +NewPostPage.RECENT_FEED_PATH = RECENT_FEED_PATH + +module.exports = NewPostPage + +// mutate4javascript-manifest-begin +// {"version":1,"tested_at":"2026-08-26T00:07:51.843Z","module_hash":"469dfd90342f6bcacf5b89ed819620663e221e20832f6936c35c46f9681ebfdc","functions":[{"id":"func/NewPostPage.constructor","name":"NewPostPage.constructor","line":22,"end_line":33,"hash":"7c957fbaa2b8d4adb62c1bbf240749243696a68e71e7896aefdbb68437432cd8"},{"id":"func/NewPostPage.addMenuLink","name":"NewPostPage.addMenuLink","line":36,"end_line":39,"hash":"bac97164d2d70bfdb946c4d54e67983c43cad093bac558f1d169e1739ca97137"},{"id":"func/NewPostPage.hasMenuLink","name":"NewPostPage.hasMenuLink","line":42,"end_line":44,"hash":"7e1abf5d0833aaf3b3da3a024de9eb2b80da900b2930e832c7e92d77e0e50344"},{"id":"func/NewPostPage.setInput","name":"NewPostPage.setInput","line":47,"end_line":50,"hash":"595484662b7ca07ef5eef5cebbff06309242552d9b4d15687df02f260ba88244"},{"id":"func/NewPostPage.remainingCount","name":"NewPostPage.remainingCount","line":53,"end_line":55,"hash":"521ce4ed841f62099529b327f2245a9e94c09af2f67ed5d91839e52607bcea37"},{"id":"func/NewPostPage.submit","name":"NewPostPage.submit","line":59,"end_line":77,"hash":"f4ce743f5a4bb139615086165b25173641a9388af16b7527f2db243a1c6b596c"}]} +// mutate4javascript-manifest-end diff --git a/src/util/index.js b/src/util/index.js index 70c3279..fcdf269 100644 --- a/src/util/index.js +++ b/src/util/index.js @@ -32,6 +32,27 @@ class AppUtil { return false } } + + // Read text from clipboard and pass it to a state setter. + async pasteFromClipboard (setValue) { + try { + const text = await this.readFromClipboard() + setValue(text) + } catch (err) { + console.warn('Error pasting from clipboard: ', err) + } + } +} + +// Truncate a long string (address, txid, etc.) for compact display. +export function truncateAddr (addr, maxLen = 20) { + if (!addr || addr.length <= maxLen) return addr + const half = Math.floor((maxLen - 3) / 2) + return `${addr.slice(0, half)}...${addr.slice(-half)}` +} + +export function truncateTxid (txid, maxLen = 20) { + return truncateAddr(txid, maxLen) } export default AppUtil diff --git a/test/property/harness.js b/test/property/harness.js new file mode 100644 index 0000000..0ccf9af --- /dev/null +++ b/test/property/harness.js @@ -0,0 +1,48 @@ +/* + Small property-testing harness for psf-memo-client. + + Node's built-in test runner has no property-based generator, so this module + provides a tiny deterministic, seeded pseudo-random generator plus a helper + to run a property across many samples and report a counterexample. All + generation is seeded, so runs are reproducible. +*/ + +'use strict' + +const assert = require('node:assert/strict') + +// A small deterministic PRNG (mulberry32). Same seed => same stream. +function seededRandom (seed = 12345) { + let a = seed >>> 0 + return function next () { + a |= 0 + a = (a + 0x6d2b79f5) | 0 + let t = Math.imul(a ^ (a >>> 15), 1 | a) + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } +} + +// Run a property across N samples. `gen` returns a fresh input; `check` +// returns true when the property holds. Asserts a counterexample on failure. +async function forAll (gen, check, { samples = 500, label = 'property' } = {}) { + for (let i = 0; i < samples; i++) { + const input = gen(i) + const ok = await check(input) + assert.ok(ok, `${label} failed at sample ${i} for input: ${JSON.stringify(input)}`) + } +} + +// Generate a random ASCII string of a given length using a seeded RNG. +function makeStringGen (rng) { + return (length) => { + const chars = [] + for (let i = 0; i < length; i++) { + // Mix of printable ASCII (32..126). + chars.push(String.fromCharCode(32 + Math.floor(rng() * 95))) + } + return chars.join('') + } +} + +module.exports = { seededRandom, forAll, makeStringGen } diff --git a/test/property/memo-post.property.test.js b/test/property/memo-post.property.test.js new file mode 100644 index 0000000..b848f56 --- /dev/null +++ b/test/property/memo-post.property.test.js @@ -0,0 +1,112 @@ +/* + Property tests for the Memo post / New Post behavior slices. + + These assert useful invariants that unit tests cover only at a few fixed + points: + - Validation classification is stable across a broad input range: any + non-blank string up to the length limit is accepted, any longer string is + rejected with a length error, and blank/non-string input is a validation + error. + - The New Post character counter conserves its relationship to input + length: input.length + remainingCount() === MAX_MEMO_CHARS for any string. + - setInput round-trips the exact draft text. +*/ + +'use strict' + +const test = require('node:test') + +const { seededRandom, forAll, makeStringGen } = require('./harness') + +const MemoPost = require('../../src/services/memo-post') +const NewPostPage = require('../../src/services/new-post') + +const MAX = MemoPost.MAX_MEMO_CHARS // 217 + +const rng = seededRandom(20260826) +const stringOf = makeStringGen(rng) + +function buildPage () { + return new NewPostPage({ + memoPost: new MemoPost({}), + navigate: () => {}, + menuLinks: [] + }) +} + +test('memo validation: any non-blank string at or below the limit is valid', async () => { + await forAll( + (i) => { + const len = 1 + Math.floor(rng() * MAX) // 1..MAX + return stringOf(len) + }, + (msg) => { + // A random ASCII string may occasionally be all whitespace; whitespace-only + // input is a validation error, so only assert for non-blank strings. + if (msg.trim().length === 0) return true + const result = new MemoPost({}).validate(msg) + return result.ok === true + }, + { label: 'valid length' } + ) +}) + +test('memo validation: any string above the limit is a length error', async () => { + await forAll( + (i) => stringOf(MAX + 1 + Math.floor(rng() * 100)), // > MAX + (msg) => { + const result = new MemoPost({}).validate(msg) + return result.ok === false && result.type === 'length' + }, + { label: 'over-long rejected as length' } + ) +}) + +test('memo validation: blank and non-string input are validation errors', async () => { + await forAll( + (i) => (i % 2 === 0 ? ' ' : null), + (msg) => { + const result = new MemoPost({}).validate(msg) + return result.ok === false && result.type === 'validation' + }, + { label: 'blank/non-string rejected as validation' } + ) +}) + +test('character counter conserves length: remaining === MAX - input.length', async () => { + await forAll( + (i) => stringOf(Math.floor(rng() * (MAX + 8))), + (msg) => { + const page = buildPage() + page.setInput(msg) + return page.remainingCount() === MAX - msg.length + }, + { label: 'counter conservation' } + ) +}) + +test('setInput round-trips the draft text exactly', async () => { + await forAll( + (i) => stringOf(Math.floor(rng() * 50)), + (msg) => { + const page = buildPage() + page.setInput(msg) + return page.input === msg + }, + { label: 'setInput round-trip' } + ) +}) + +test('menu link registration is idempotent', async () => { + await forAll( + (i) => `/posts/${i}`, + (path) => { + const page = buildPage() + page.addMenuLink(path) + page.addMenuLink(path) + page.addMenuLink(path) + return page.menuLinks.filter((p) => p === path).length === 1 + }, + { label: 'menu link idempotence' } + ) +}) diff --git a/test/unit/memo-post.test.js b/test/unit/memo-post.test.js new file mode 100644 index 0000000..314da82 --- /dev/null +++ b/test/unit/memo-post.test.js @@ -0,0 +1,124 @@ +/* + Unit tests for the Memo post behavior slice (src/services/memo-post.js). + + These tests express the observable behavior described by + specs/post-memo.feature: + - a valid memo broadcasts an OP_RETURN transaction carrying the Memo post + prefix (0x6d02) and the message text, and the feed reflects the new post. + - an empty memo is rejected with a validation error and nothing is broadcast. + - an over-long memo is rejected with a length error and nothing is broadcast. +*/ + +'use strict' + +const test = require('node:test') +const assert = require('node:assert/strict') + +const MemoPost = require('../../src/services/memo-post') + +// A fake wallet that records every broadcast attempt. It satisfies the small +// adapter surface the MemoPost module needs: walletInfo, getUtxos(), +// sendOpReturn(). +function fakeWallet (cashAddress = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d', utxos = [{ txid: 'utxo1' }]) { + const broadcasts = [] + const wallet = { + walletInfo: { cashAddress }, + getUtxos: async () => utxos, + sendOpReturn: async (walletInfo, bchUtxos, msg, prefix) => { + broadcasts.push({ walletInfo, bchUtxos, msg, prefix }) + return 'fake-txid' + } + } + wallet.broadcasts = broadcasts + return wallet +} + +// A fake feed that records posts added to the recent posts feed. +function fakeFeed () { + const posts = [] + return { posts, addPost: (p) => posts.push(p) } +} + +test('MEMO_POST_PREFIX is the Memo post action 0x6d02', () => { + assert.equal(MemoPost.MEMO_POST_PREFIX, '6d02') +}) + +test('posting a valid memo broadcasts an OP_RETURN with the Memo post prefix and message', async () => { + const wallet = fakeWallet() + const feed = fakeFeed() + const memoPost = new MemoPost({ wallet, feed }) + + const txid = await memoPost.post('hello memo') + + assert.equal(txid, 'fake-txid') + assert.equal(wallet.broadcasts.length, 1) + const b = wallet.broadcasts[0] + assert.equal(b.prefix, '6d02') + assert.equal(b.msg, 'hello memo') + // The broadcast uses the wallet's spendable UTXOs. + assert.equal(b.bchUtxos.length, 1) + // The wallet info passed to sendOpReturn is the authenticated wallet. + assert.equal(b.walletInfo.cashAddress, wallet.walletInfo.cashAddress) + + // The feed reflects the new post from this address with this text. + assert.equal(feed.posts.length, 1) + assert.equal(feed.posts[0].text, 'hello memo') + assert.equal(feed.posts[0].address, wallet.walletInfo.cashAddress) +}) + +test('posting a memo at the maximum length (217) is accepted', async () => { + const wallet = fakeWallet() + const memoPost = new MemoPost({ wallet }) + + const msg = 'x'.repeat(217) + const txid = await memoPost.post(msg) + assert.equal(txid, 'fake-txid') + assert.equal(wallet.broadcasts[0].msg, msg) +}) + +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 a whitespace-only or non-string memo throws a validation error and broadcasts nothing', async () => { + for (const invalid of [' ', 42]) { + const wallet = fakeWallet() + const memoPost = new MemoPost({ wallet }) + + await assert.rejects( + memoPost.post(invalid), + (err) => err.code === 'memo_validation' + ) + assert.equal(wallet.broadcasts.length, 0) + } +}) + +test('posting an over-long memo (218) throws a length error and broadcasts nothing', async () => { + const wallet = fakeWallet() + const feed = fakeFeed() + const memoPost = new MemoPost({ wallet, feed }) + + await assert.rejects( + memoPost.post('y'.repeat(218)), + (err) => err.code === 'memo_length' + ) + 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( + memoPost.post('hello memo'), + (err) => /wallet/i.test(err.message) + ) +}) diff --git a/test/unit/new-post.test.js b/test/unit/new-post.test.js new file mode 100644 index 0000000..bdac616 --- /dev/null +++ b/test/unit/new-post.test.js @@ -0,0 +1,173 @@ +/* + Unit tests for the New Post Page behavior slice (src/services/new-post.js). + + Expresses the observable behavior described by specs/memo-new.feature: + - posting a valid memo broadcasts an OP_RETURN with the Memo post prefix and + navigates the user to the recent feed. + - an empty memo is rejected with a validation error; nothing is broadcast. + - an over-long memo is rejected with a length error; nothing is broadcast. + - the character counter counts down from the memo limit. + - the navigation menu links to /posts/new. +*/ + +'use strict' + +const test = require('node:test') +const assert = require('node:assert/strict') + +const MemoPost = require('../../src/services/memo-post') +const NewPostPage = require('../../src/services/new-post') + +const MAX = MemoPost.MAX_MEMO_CHARS // 217 + +// A fake wallet recording broadcast attempts. +function fakeWallet (cashAddress = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d') { + const broadcasts = [] + const wallet = { + walletInfo: { cashAddress }, + utxos: [{ txid: 'utxo-fee' }], + getUtxos: async function () { return this.utxos }, + sendOpReturn: async function (walletInfo, bchUtxos, msg, prefix) { + this.broadcasts.push({ walletInfo, bchUtxos, msg, prefix }) + return 'newpost-txid' + } + } + wallet.broadcasts = broadcasts + return wallet +} + +function fakeFeed () { + const posts = [] + return { posts, addPost: (p) => posts.push(p) } +} + +function build () { + const wallet = fakeWallet() + const feed = fakeFeed() + const memoPost = new MemoPost({ wallet, feed }) + const navigations = [] + const page = new NewPostPage({ + memoPost, + navigate: (path) => navigations.push(path) + }) + return { wallet, feed, memoPost, page, navigations } +} + +test('NEW_POST_PATH and RECENT_FEED_PATH constants', () => { + assert.equal(NewPostPage.NEW_POST_PATH, '/posts/new') + assert.equal(NewPostPage.RECENT_FEED_PATH, '/posts/recent') +}) + +test('the new post page is linked from the navigation menu', () => { + const { page } = build() + assert.equal(page.hasMenuLink('/posts/new'), true) +}) + +test('the character counter counts down from the memo limit for an empty memo', () => { + const { page } = build() + page.setInput('') + assert.equal(page.remainingCount(), MAX) +}) + +test('the character counter counts down from the memo limit for a short memo', () => { + const { page } = build() + page.setInput('hello') + assert.equal(page.remainingCount(), MAX - 5) +}) + +test('the character counter reaches zero at the memo limit', () => { + const { page } = build() + page.setInput('x'.repeat(MAX)) + assert.equal(page.remainingCount(), 0) +}) + +test('posting a valid memo broadcasts the Memo post prefix and navigates to the feed', async () => { + const { wallet, feed, 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(feed.posts.length, 1) + assert.equal(feed.posts[0].text, 'hello memo') +}) + +test('posting an empty memo is rejected with a validation error and nothing is broadcast', async () => { + const { wallet, feed, 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(feed.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, feed, 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(feed.posts.length, 0) + assert.deepEqual(navigations, []) +}) + +test('the new post page starts idle (not posting)', () => { + const { page } = build() + assert.equal(page.posting, false) +}) + +test('posting is true while a submit is in flight and false once it settles', async () => { + const wallet = fakeWallet() + const feed = fakeFeed() + + // Defer the broadcast so we can observe the in-flight posting state. + let resolveSend + wallet.sendOpReturn = async () => new Promise((resolve) => { resolveSend = resolve }) + const page = new NewPostPage({ + memoPost: new MemoPost({ wallet, feed }), + navigate: () => {} + }) + page.setInput('hello memo') + + assert.equal(page.posting, false) + const pending = page.submit() + assert.equal(page.posting, true) + + // Yield until the async chain reaches the deferred sendOpReturn call. + await new Promise((r) => setImmediate(r)) + assert.equal(typeof resolveSend, 'function') + resolveSend('in-flight-txid') + await pending + assert.equal(page.posting, false) +}) + +test('submitting without a memo post handler reports an error and does not navigate', async () => { + const navigations = [] + const page = new NewPostPage({ navigate: (p) => navigations.push(p) }) + page.setInput('hello') + + const result = await page.submit() + + assert.equal(result.ok, false) + assert.deepEqual(navigations, []) +})