Implement Post a Memo behavior with acceptance pipeline

Implements the post-memo behavior slice per specs/post-memo.feature:
- src/services/memo-post.js: compose, validate (empty/length), and broadcast
  a Memo post OP_RETURN (0x6d02) via the minimal-slp-wallet adapter surface.
- Stands up the Babashka APS acceptance pipeline (entrypoint generator,
  runtime, regex step handlers, convenience runner).
- Adds focused unit tests for the memo behavior.

By coder.
This commit is contained in:
Chris Troutner
2026-08-25 16:13:05 -07:00
parent c9556be67a
commit 514298bf96
8 changed files with 678 additions and 1 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()
+173
View File
@@ -0,0 +1,173 @@
/*
Project step handlers for the psf-memo-client acceptance pipeline.
These handlers connect Gherkin step text to the real project behavior in
src/services/memo-post.js, driving it through small injected adapters (a fake
wallet and a fake feed) 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.
*/
'use strict'
const MemoPost = require('../../src/services/memo-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 })
return {
wallet,
feed,
memoPost,
message: null,
submitted: null
}
}
// Handler registry. Each entry: { pattern, run }.
// run receives (match, exampleStore, world).
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 () {}
},
{
name: 'compose memo text',
pattern: /^I compose 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.message = example[param]
}
},
{
name: 'submit memo',
pattern: /^I submit the memo$/,
async run (m, example, world) {
world.submitted = { error: null, txid: null }
try {
world.submitted.txid = await world.memoPost.post(world.message)
} catch (err) {
world.submitted.error = err
}
}
},
{
name: 'broadcasts OP_RETURN with Memo post prefix',
pattern: /^the wallet 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.message) {
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: 'app shows validation/length error',
pattern: /^the app shows a (validation|length) error$/,
run (m, example, world) {
const kind = m[1]
const expectedCode = kind === 'validation' ? 'memo_validation' : 'memo_length'
if (!world.submitted || !world.submitted.error) {
throw new Error(`Expected a ${kind} error but the submit succeeded.`)
}
if (world.submitted.error.code !== expectedCode) {
throw new Error(`Expected ${expectedCode}, got ${world.submitted.error.code}.`)
}
}
},
{
name: 'wallet does not broadcast any transaction',
pattern: /^the wallet 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)
return
}
}
throw new Error(`Unsupported step: ${step.keyword} ${step.text}`)
}
module.exports = { createWorld, handleStep }
+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 }
+2 -1
View File
@@ -25,7 +25,8 @@
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "echo 'no tests'",
"test": "node --test \"test/unit/*.test.js\"",
"test:acceptance": "node acceptance/acceptance.js",
"eject": "react-scripts eject",
"lint": "standard --env mocha --fix",
"pub": "node deploy/publish-main.js",
+89
View File
@@ -0,0 +1,89 @@
/*
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)
*/
'use strict'
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)
if (!check.ok) {
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
}
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.
if (this.feed && typeof this.feed.addPost === 'function') {
this.feed.addPost({
txid,
address: this.wallet.walletInfo.cashAddress,
text: message
})
}
return txid
}
}
MemoPost.MEMO_POST_PREFIX = MEMO_POST_PREFIX
MemoPost.MAX_MEMO_CHARS = MAX_MEMO_CHARS
module.exports = MemoPost
+133
View File
@@ -0,0 +1,133 @@
/*
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 memo throws a validation error and broadcasts nothing', async () => {
const wallet = fakeWallet()
const memoPost = new MemoPost({ wallet })
await assert.rejects(
memoPost.post(' '),
(err) => err.code === 'memo_validation'
)
assert.equal(wallet.broadcasts.length, 0)
})
test('posting a non-string memo throws a validation error', async () => {
const wallet = fakeWallet()
const memoPost = new MemoPost({ wallet })
await assert.rejects(
memoPost.post(42),
(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)
)
})