Merge completed Post a Memo and New Post Page work

By specifier.
This commit is contained in:
Chris Troutner
2026-08-25 17:20:21 -07:00
31 changed files with 1608 additions and 127 deletions
+1
View File
@@ -1,5 +1,6 @@
node_modules/
build/
docs/
tmp/
.gitsigners
+99
View File
@@ -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()
+112
View File
@@ -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 <json-ir> <generated-test-output-dir>
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 <json-ir> <generated-test-output-dir>')
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()
+228
View File
@@ -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. <message>) and fetches
the example value from the scenario example store.
The handlers serve both specs/post-memo.feature and specs/memo-new.feature,
whose wording differs but which share the same underlying Memo post behavior.
*/
'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 }
+68
View File
@@ -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)
})
+69
View File
@@ -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 }
+62
View File
@@ -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.
+66
View File
@@ -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.
@@ -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.
+3 -1
View File
@@ -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",
+4
View File
@@ -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
+4
View File
@@ -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
@@ -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) => {
@@ -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')}
/>
</Col>
<Col xs={6} sm={1} lg={2} style={{ textAlign: 'center' }}>
@@ -104,7 +89,7 @@ function WalletSummary (props) {
style={{ cursor: 'pointer' }}
icon={eyeIcon.privateKey}
size='lg'
onClick={() => togglePrivateKeyBlur({ walletSummaryData })}
onClick={() => toggleBlur('blurredPrivateKey')}
/>
</Col>
<Col xs={6} sm={1} lg={2} style={{ textAlign: 'center' }}>
+2
View File
@@ -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) {
<Route path='/profile/recent' element={<RecentProfiles />} />
<Route path='/profile/:addr' element={<Profile />} />
<Route path='/posts/recent' element={<RecentPosts />} />
<Route path='/posts/new' element={<NewPost appData={appData} />} />
<Route path='/placeholder2' element={<Placeholder2 />} />
<Route path='/placeholder3' element={<Placeholder3 />} />
<Route path='/servers' element={<ServerSelectView appData={appData} />} />
+90
View File
@@ -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 (
<Container>
<Row className='justify-content-center'>
<Col lg={8} md={10} xs={12}>
<header className='new-post-heading'>
<h1>New Post</h1>
<p>Compose a Memo message and publish it to Bitcoin Cash.</p>
</header>
<Form onSubmit={handleSubmit}>
<Form.Group controlId='new-post-message' className='mb-3'>
<Form.Label><b>Message</b></Form.Label>
<Form.Control
as='textarea'
rows={6}
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder='Write your Memo here...'
/>
</Form.Group>
<p className='new-post-counter'>
{remaining} characters remaining
</p>
{err && <p className='new-post-error'>{err}</p>}
<Button type='submit' variant='primary' disabled={posting}>
{posting ? 'Posting...' : 'Post'}
</Button>
</Form>
</Col>
</Row>
</Container>
)
}
export default NewPost
@@ -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 (
<>
<p style={{ padding: '25px' }}>This is placeholder View #{viewNumber}</p>
</>
)
}
export default PlaceholderView
+3 -11
View File
@@ -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 (
<>
<p style={{ padding: '25px' }}>This is placeholder View #2</p>
</>
)
return <PlaceholderView viewNumber={2} />
}
export default Placeholder2
+3 -11
View File
@@ -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 (
<>
<p style={{ padding: '25px' }}>This is placeholder View #3</p>
</>
)
return <PlaceholderView viewNumber={3} />
}
export default Placeholder3
@@ -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)}
</Link>
</td>
<td>{profile.text}</td>
@@ -100,7 +94,7 @@ function RecentProfiles () {
title={profile.txid}
onClick={() => appUtil.copyToClipboard(profile.txid)}
>
{truncate(profile.txid, 20)}
{truncateTxid(profile.txid, 20)}
</span>
</td>
</tr>
@@ -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 = () => {
+8
View File
@@ -70,6 +70,14 @@ function NavMenu (props) {
Posts
</NavLink>
<NavLink
className={currentPath === '/posts/new' ? 'nav-link-active' : 'nav-link-inactive'}
to='/posts/new'
onClick={handleClickEvent}
>
New Post
</NavLink>
<NavLink
className={currentPath === '/wallet' ? 'nav-link-active' : 'nav-link-inactive'}
to='/wallet'
+4 -10
View File
@@ -2,6 +2,10 @@
Shared display helpers for post feed and thread views.
*/
import { truncateAddr } from '../../util'
export { truncateAddr, truncateTxid } from '../../util'
export function formatRelativeSeen (seen) {
if (!seen) return ''
const ms = seen > 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) {
+25 -46
View File
@@ -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
}
}
+101
View File
@@ -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
+87
View File
@@ -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
+21
View File
@@ -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
+48
View File
@@ -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 }
+112
View File
@@ -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' }
)
})
+124
View File
@@ -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)
)
})
+173
View File
@@ -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, [])
})