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..9fadb92 --- /dev/null +++ b/acceptance/lib/handlers.js @@ -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. ) 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 } 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/package.json b/package.json index da6cbd5..cb38dfc 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/services/memo-post.js b/src/services/memo-post.js new file mode 100644 index 0000000..12b1a90 --- /dev/null +++ b/src/services/memo-post.js @@ -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 diff --git a/test/unit/memo-post.test.js b/test/unit/memo-post.test.js new file mode 100644 index 0000000..1a5fef2 --- /dev/null +++ b/test/unit/memo-post.test.js @@ -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) + ) +})