From ec951bf36bc473efe00b83553285bd6af415e668 Mon Sep 17 00:00:00 2001 From: 2rdzy <232089760+2rdzy@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:14:05 +0200 Subject: [PATCH] Add a test harness, CI and current package metadata Add node:test tests against a small in-process Electrum server over TCP and TLS (with a committed, test-only certificate), covering requests, errors, concurrency, batches, notifications and close. Run them in CI on Node 18, 20 and 22. Point the repository, bugs and homepage fields at this fork, require Node 18 or later, and publish only index.js and lib. --- .github/workflows/test.yml | 19 ++++++++++ package.json | 31 +++++++++++----- test/client.test.js | 67 +++++++++++++++++++++++++++++++++ test/fixtures/cert.pem | 19 ++++++++++ test/fixtures/key.pem | 28 ++++++++++++++ test/helpers/client.js | 23 ++++++++++++ test/helpers/server.js | 76 ++++++++++++++++++++++++++++++++++++++ 7 files changed, 254 insertions(+), 9 deletions(-) create mode 100644 .github/workflows/test.yml create mode 100644 test/client.test.js create mode 100644 test/fixtures/cert.pem create mode 100644 test/fixtures/key.pem create mode 100644 test/helpers/client.js create mode 100644 test/helpers/server.js diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..9df8ef9 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,19 @@ +name: test + +on: + push: + branches: [master] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + node: [18, 20, 22] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node }} + - run: npm test diff --git a/package.json b/package.json index d6bf75e..7e2cdbe 100644 --- a/package.json +++ b/package.json @@ -4,16 +4,16 @@ "description": "Electrum protocol client for React Native & Node.js", "main": "index.js", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "test": "node --test test/*.test.js" }, "dependencies": {}, "devDependencies": {}, "repository": { "type": "git", - "url": "git://github.com/janoside/electrum-client.git" + "url": "git+https://github.com/2rdzy/electrum-client.git" }, "bugs": { - "url": "https://github.com/janoside/electrum-client/issues" + "url": "https://github.com/2rdzy/electrum-client/issues" }, "keywords": [ "bitcoin", @@ -21,13 +21,26 @@ "electrumx" ], "engines": { - "node": ">=6" + "node": ">=18" }, "contributors": [ - { "name": "Yuki Akiyama" }, - { "name": "7kharov" }, - { "name": "overtorment" }, - { "name": "janoside" } + { + "name": "Yuki Akiyama" + }, + { + "name": "7kharov" + }, + { + "name": "overtorment" + }, + { + "name": "janoside" + } ], - "license": "MIT" + "license": "MIT", + "homepage": "https://github.com/2rdzy/electrum-client#readme", + "files": [ + "index.js", + "lib" + ] } diff --git a/test/client.test.js b/test/client.test.js new file mode 100644 index 0000000..36653c9 --- /dev/null +++ b/test/client.test.js @@ -0,0 +1,67 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const { after, before, describe, test } = require('node:test'); + +const { startServer } = require('./helpers/server.js'); +const { withClient } = require('./helpers/client.js'); + +const handler = request => { + switch (request.method) { + case 'server.version': return ['test-server 1.0', '1.4']; + case 'echo': return request.params; + case 'blockchain.scripthash.get_balance': return { confirmed: request.params[0].length, unconfirmed: 0 }; + case 'fail': throw { code: 2, message: 'nope' }; + default: throw { code: -32601, message: `unknown method ${request.method}` }; + } +}; + +for (const [protocol, useTls] of [['tcp', false], ['tls', true]]) { + describe(`over ${protocol}`, () => { + let server; + + before(async () => { server = await startServer({ useTls, handler }); }); + after(() => server.close()); + + test('negotiates the protocol version', () => withClient(server, protocol, client => { + assert.deepEqual(client.versionInfo, ['test-server 1.0', '1.4']); + })); + + test('resolves a request with its result', () => withClient(server, protocol, async client => { + assert.deepEqual(await client.request('echo', ['a', 1]), ['a', 1]); + })); + + test('rejects a request with the server error', () => withClient(server, protocol, async client => { + await assert.rejects(client.request('fail', []), { code: 2, message: 'nope' }); + })); + + test('keeps concurrent requests apart', () => withClient(server, protocol, async client => { + const results = await Promise.all([1, 2, 3, 4, 5].map(n => client.request('echo', [n]))); + + assert.deepEqual(results, [[1], [2], [3], [4], [5]]); + })); + + test('resolves a batch with one entry per parameter, tagged with it', () => withClient(server, protocol, async client => { + const results = await client.blockchainScripthash_getBalanceBatch(['aa', 'bbbb', 'cccccc']); + + assert.deepEqual(results.map(r => r.param), ['aa', 'bbbb', 'cccccc']); + assert.deepEqual(results.map(r => r.result.confirmed), [2, 4, 6]); + })); + + test('emits subscription notifications', () => withClient(server, protocol, async client => { + const notified = new Promise(resolve => client.subscribe.once('blockchain.headers.subscribe', resolve)); + + server.broadcast(JSON.stringify({ jsonrpc: '2.0', method: 'blockchain.headers.subscribe', params: [{ height: 7 }] })); + + assert.deepEqual(await notified, [{ height: 7 }]); + })); + + test('refuses requests after close', async () => { + await withClient(server, protocol, async client => { + client.close(); + + await assert.rejects(client.request('echo', []), /Connection to server lost/); + }); + }); + }); +} diff --git a/test/fixtures/cert.pem b/test/fixtures/cert.pem new file mode 100644 index 0000000..a2ae783 --- /dev/null +++ b/test/fixtures/cert.pem @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDJzCCAg+gAwIBAgIUC8QQdK2UWC7Gzx1+oiCJs2ear6wwDQYJKoZIhvcNAQEL +BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MCAXDTI2MTAwNTIwMTMyNVoYDzIxMjYw +OTExMjAxMzI1WjAUMRIwEAYDVQQDDAlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEB +AQUAA4IBDwAwggEKAoIBAQCpRxHcBqjb/VShru2qOjUGM+B9owDhiwKzM47xQ/iF +G7k0mtbFv9lHTL9NWTTEHy7prg461Z2XCP3QeNYYjV4udy9G2U9YuJuHHT+gx7br +Cr1Q6WT6kqc7RXe0R79GggqHpi02s3g/qbVO/OalBSvVBFpxxw9uMdG9Q7V5y7Jn +WI9NmYB/WRkBQS4dygw+wjS4igUF3j6vwR40gXm3QA66RxdSDNTOLnWLp6On92wj +DpmGNPciXCxhXquwW0o7ylRnQYsXpafHEMHl1uGjJQpXBuvgV/q1VmLFNKbp00T0 +YxiXV2gQA+IgnFMSQ7XKoKHPVJx1gPr97ERTiLYe/ci7AgMBAAGjbzBtMB0GA1Ud +DgQWBBT+7ktr7S5GLg39hICE95f36RZXzTAfBgNVHSMEGDAWgBT+7ktr7S5GLg39 +hICE95f36RZXzTAPBgNVHRMBAf8EBTADAQH/MBoGA1UdEQQTMBGCCWxvY2FsaG9z +dIcEfwAAATANBgkqhkiG9w0BAQsFAAOCAQEAhSP9Gxcxde1s8jfTStMn+VNmKGG3 +cWIVnw1IYf8PCTUx5z2gi1MjBpEV4QyL0tWNvfk0yfDCZJ24kXqLbgo338T7A5pQ +SfJJ1XMP/INRWtcsrnQZA4MdflD+9LzDjSaTNw8rLTQqemrRGLcDXfwmk+rAsGSJ +N20d/Txy1Ixlu7bBE95vMS5nHMK3P6XtlbZPGdDyZnN7JoomUYghWUkmtM00w1Ck +WPqNBCVsTRJZgyhgzvIXEBD7XcN4vMR6wWoTUhRxpW9QqlSQyzVr/Ik9DBh+QZ/n +DVdsD2+EMFtg0jzOJ9+fn+dMYpNJOjis8D1hAJZiku500/R2xjlmVdwYeg== +-----END CERTIFICATE----- diff --git a/test/fixtures/key.pem b/test/fixtures/key.pem new file mode 100644 index 0000000..03bc7b3 --- /dev/null +++ b/test/fixtures/key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCpRxHcBqjb/VSh +ru2qOjUGM+B9owDhiwKzM47xQ/iFG7k0mtbFv9lHTL9NWTTEHy7prg461Z2XCP3Q +eNYYjV4udy9G2U9YuJuHHT+gx7brCr1Q6WT6kqc7RXe0R79GggqHpi02s3g/qbVO +/OalBSvVBFpxxw9uMdG9Q7V5y7JnWI9NmYB/WRkBQS4dygw+wjS4igUF3j6vwR40 +gXm3QA66RxdSDNTOLnWLp6On92wjDpmGNPciXCxhXquwW0o7ylRnQYsXpafHEMHl +1uGjJQpXBuvgV/q1VmLFNKbp00T0YxiXV2gQA+IgnFMSQ7XKoKHPVJx1gPr97ERT +iLYe/ci7AgMBAAECggEABiIzx3aGT9JSFWRdQDnJChHUeeOaIzys5bQMjeQZ8S4b ++91QbZc8rD9fqdHS/k2S9EIA9KjLg67TuOYSpo5uHIjnsaLWI9oskyzAIsyZwtJ/ +dvBotEZJa6BiF1Tc3WqA2dbE9KJ0Ik+9PhYegAQ787AAsaFg3Gv5tD3r1B9JYO00 +I2nQ36zxw1YlOwHS5LBgYSePHlVP2BYo6M21C/JUs6oECL0DshNRkbE0qLv95trL +JHq0PRx/0ABwpcfgjEDftvA/kFsxDrP3HEnoHIdvGawO/wiURQQiIEEIwtGkMmTP +/7hXSUOf8XWrRk1+9ljxHpIhMEizbVgW0vjvQi9a4QKBgQDQSQhmzef9zM677qvs +xmjdP4S5XQMQXxCRXRYBVs15OURM+MRvWG+zhuhnMMI8tNe9jNCci3jpk5O5NKUN +Bn3P/SLGkr9odzD2wZ+9dw/KHpaCwuGqnngTGKgTaBWA0yyl6Kk59lUbABz0T9vN +87xREtUF2x752Vugpb7wZjt6RwKBgQDQDmr6fA/tUlrPxyb5QINXmFzNrv2WYCQn +L4mZi8fAaSUIb04+LGaKSEr8JhGKir5Vgy7OLQQT5AuPWeBkJcP324jddvJ03cdO +WSVGQ2r1pOjKz+N8egFVGCgi+YE21H2g6LbpXRH4vm/pLv1m7Agp+qy/mJkWe7kn +4GkVKStD7QKBgBhLLGuxQIndxY8t+2eWjMyhh/2G18ClFe2eid6GHVkyUMEZku/Z +xM2PwrOmgEFMZo15Syu3KNc1m1lSxp2KUEpeIBxBKYopvNBLLTKQHItanfuKd3/L +E/5GNfRtg3SPLPn89SAJl0ua68WjOuP+tcxPAgG42RgTPhiyvF6VzgIpAoGBAMJf +pewr+YlDNloFedDFR6+PRaxXPsy9NylwzQGpSE3nLVTq/9MwEgBAt+VH/31KT7V+ +ID04ayXWGEKtueh2K9YkwIH+vVcXVztIJ320QHoLvfy0KZhCN3pzXhQGu4QpEtFA +eJoefErCR7U3VEBSZPIrIjbBbsxFfF7X1fSSCLylAoGBAKZ652nLK3Tctg5F1PVm +PMO0K4Vs4rcXBURFq6vLB5QEs0pSC3kjYhrbT8cW6L8zThueFnGrK9SopGing/e8 +d6zhhkx5C+OzkDxbyirJKOzCtjrssVTEZId17dDPfr06YDXsD6+6pvFYxeDRxL5J +WhiL3lHrR0+npjrBWe9zJjpy +-----END PRIVATE KEY----- diff --git a/test/helpers/client.js b/test/helpers/client.js new file mode 100644 index 0000000..7b16b53 --- /dev/null +++ b/test/helpers/client.js @@ -0,0 +1,23 @@ +'use strict'; + +// The client expects net and tls to be injected as globals (see lib/client.js). +global.net = require('net'); +global.tls = require('tls'); + +const ElectrumClient = require('../../index.js'); + +// Connect a client to `server`, run `fn(client)`, and always close the client. +async function withClient(server, protocol, fn, { options, callbacks } = {}) { + const client = new ElectrumClient(server.port, '127.0.0.1', protocol, options, callbacks); + + await client.initElectrum({ client: 'electrum-client-test', version: '1.4' }, { retryPeriod: 100000, maxRetry: 0, callback: () => {} }); + + try { + return await fn(client); + + } finally { + client.close(); + } +} + +module.exports = { ElectrumClient, withClient }; diff --git a/test/helpers/server.js b/test/helpers/server.js new file mode 100644 index 0000000..fb88787 --- /dev/null +++ b/test/helpers/server.js @@ -0,0 +1,76 @@ +'use strict'; + +// A minimal Electrum-protocol server for tests: newline-delimited JSON-RPC over +// TCP or TLS. `handler(request)` returns the result for a request, or throws +// `{code, message}` to send an error. + +const fs = require('fs'); +const net = require('net'); +const path = require('path'); +const tls = require('tls'); + +const fixtures = path.join(__dirname, '..', 'fixtures'); + +const tlsOptions = { + key: fs.readFileSync(path.join(fixtures, 'key.pem')), + cert: fs.readFileSync(path.join(fixtures, 'cert.pem')), +}; + +function reply(request, handler) { + try { + return { jsonrpc: '2.0', id: request.id, result: handler(request) }; + + } catch (err) { + return { jsonrpc: '2.0', id: request.id, error: { code: err.code || -1, message: err.message || String(err) } }; + } +} + +async function startServer({ useTls = false, handler = req => req.params } = {}) { + const sockets = new Set(); + const received = []; + + const onConnection = socket => { + sockets.add(socket); + socket.on('close', () => sockets.delete(socket)); + socket.on('error', () => {}); + socket.setEncoding('utf8'); + + let buffer = ''; + socket.on('data', chunk => { + buffer += chunk; + + let index; + while ((index = buffer.indexOf('\n')) !== -1) { + const line = buffer.slice(0, index); + buffer = buffer.slice(index + 1); + + const message = JSON.parse(line); + received.push(message); + + const out = Array.isArray(message) + ? message.map(request => reply(request, handler)) + : reply(message, handler); + + socket.write(JSON.stringify(out) + '\n'); + } + }); + }; + + const server = useTls ? tls.createServer(tlsOptions, onConnection) : net.createServer(onConnection); + + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + + return { + port: server.address().port, + received, + sockets, + // send a raw line to every connected client (for notifications and malformed input) + broadcast: line => sockets.forEach(socket => socket.write(line + '\n')), + close: () => new Promise(resolve => { + sockets.forEach(socket => socket.destroy()); + server.close(resolve); + }), + }; +} + +module.exports = { startServer, tlsOptions };