@breeztech/breez-sdk-spark 0.19.0 → 0.20.0-dev1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/breez-sdk-spark.tgz +0 -0
- package/bundler/breez_sdk_spark_wasm.d.ts +150 -2
- package/bundler/breez_sdk_spark_wasm.js +1 -1
- package/bundler/breez_sdk_spark_wasm_bg.js +182 -13
- package/bundler/breez_sdk_spark_wasm_bg.wasm +0 -0
- package/bundler/breez_sdk_spark_wasm_bg.wasm.d.ts +10 -0
- package/deno/breez_sdk_spark_wasm.d.ts +150 -2
- package/deno/breez_sdk_spark_wasm.js +182 -13
- package/deno/breez_sdk_spark_wasm_bg.wasm +0 -0
- package/deno/breez_sdk_spark_wasm_bg.wasm.d.ts +10 -0
- package/itest/.gitignore +2 -0
- package/itest/helpers/assert.test.js +70 -0
- package/itest/helpers/faucet.js +68 -0
- package/itest/helpers/lnurl-fixture.js +229 -0
- package/itest/helpers/scenario.js +444 -0
- package/itest/package-lock.json +519 -0
- package/itest/package.json +18 -0
- package/itest/scenarios.test.js +47 -0
- package/itest/smoke.test.js +184 -0
- package/nodejs/breez_sdk_spark_wasm.d.ts +150 -2
- package/nodejs/breez_sdk_spark_wasm.js +184 -13
- package/nodejs/breez_sdk_spark_wasm_bg.wasm +0 -0
- package/nodejs/breez_sdk_spark_wasm_bg.wasm.d.ts +10 -0
- package/nodejs/index.mjs +2 -0
- package/package.json +1 -1
- package/ssr/index.js +12 -0
- package/web/breez_sdk_spark_wasm.d.ts +160 -2
- package/web/breez_sdk_spark_wasm.js +182 -13
- package/web/breez_sdk_spark_wasm_bg.wasm +0 -0
- package/web/breez_sdk_spark_wasm_bg.wasm.d.ts +10 -0
- package/web/passkey-prf-provider/index.d.ts +8 -0
- package/web/passkey-prf-provider/index.js +60 -10
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// LNURL server fixture driven through the docker CLI, mirroring the Rust
|
|
4
|
+
// harness (crates/breez-sdk/cli/tests/harness/lnurl.rs). Shares the
|
|
5
|
+
// breez-lnurl-built image tag: whichever runner builds it first wins. To
|
|
6
|
+
// force a rebuild: `docker image rm breez-lnurl-built:latest`.
|
|
7
|
+
|
|
8
|
+
const net = require('net')
|
|
9
|
+
const path = require('path')
|
|
10
|
+
const { execFile, execFileSync } = require('child_process')
|
|
11
|
+
const { promisify } = require('util')
|
|
12
|
+
|
|
13
|
+
const execFileAsync = promisify(execFile)
|
|
14
|
+
|
|
15
|
+
const IMAGE = 'breez-lnurl-built:latest'
|
|
16
|
+
const POSTGRES_IMAGE = 'postgres:16-alpine'
|
|
17
|
+
const HTTP_PORT = 8080
|
|
18
|
+
const POSTGRES_PORT = 5432
|
|
19
|
+
const START_TIMEOUT_MS = 120_000
|
|
20
|
+
const WORKSPACE_ROOT = path.join(__dirname, '../../../..')
|
|
21
|
+
|
|
22
|
+
/** @returns {boolean} whether the docker daemon is reachable */
|
|
23
|
+
function dockerAvailable() {
|
|
24
|
+
try {
|
|
25
|
+
execFileSync('docker', ['version'], { stdio: 'ignore' })
|
|
26
|
+
return true
|
|
27
|
+
} catch {
|
|
28
|
+
return false
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function docker(args, opts = {}) {
|
|
33
|
+
const { stdout } = await execFileAsync('docker', args, { maxBuffer: 64 * 1024 * 1024, ...opts })
|
|
34
|
+
return stdout
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function buildImageIfMissing() {
|
|
38
|
+
try {
|
|
39
|
+
await docker(['image', 'inspect', IMAGE])
|
|
40
|
+
return
|
|
41
|
+
} catch {
|
|
42
|
+
// Image missing: build it.
|
|
43
|
+
}
|
|
44
|
+
console.error(`building ${IMAGE} (first run only, this can take several minutes)`)
|
|
45
|
+
await docker([
|
|
46
|
+
'build',
|
|
47
|
+
'-f',
|
|
48
|
+
path.join(WORKSPACE_ROOT, 'crates/breez-sdk/lnurl/Dockerfile'),
|
|
49
|
+
'-t',
|
|
50
|
+
IMAGE,
|
|
51
|
+
'--build-arg',
|
|
52
|
+
'CARGO_FEATURES=dev',
|
|
53
|
+
WORKSPACE_ROOT
|
|
54
|
+
])
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** @returns {Promise<number>} host port the container port was published to */
|
|
58
|
+
async function publishedPort(containerId, containerPort) {
|
|
59
|
+
const portOutput = await docker(['port', containerId, `${containerPort}/tcp`])
|
|
60
|
+
return Number(portOutput.split('\n')[0].trim().split(':').pop())
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Resolves once something answers HTTP on `port`. Any status answers the
|
|
65
|
+
* question, so this only checks that a response came back. HTTP/1.0 so the
|
|
66
|
+
* server closes the connection when it is done.
|
|
67
|
+
*/
|
|
68
|
+
function httpResponds(port) {
|
|
69
|
+
return new Promise((resolve, reject) => {
|
|
70
|
+
const socket = net.connect(port, '127.0.0.1')
|
|
71
|
+
let response = ''
|
|
72
|
+
socket.setTimeout(5000)
|
|
73
|
+
socket.on('connect', () => socket.write('GET /health HTTP/1.0\r\nHost: localhost\r\n\r\n'))
|
|
74
|
+
socket.on('data', (chunk) => {
|
|
75
|
+
response += chunk
|
|
76
|
+
})
|
|
77
|
+
socket.on('end', () =>
|
|
78
|
+
response.startsWith('HTTP/') ? resolve() : reject(new Error('no HTTP response'))
|
|
79
|
+
)
|
|
80
|
+
socket.on('timeout', () => socket.destroy(new Error('timed out')))
|
|
81
|
+
socket.on('error', reject)
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
class LnurlFixture {
|
|
86
|
+
/** @param {string} containerId @param {string} httpUrl @param {string} postgresContainerId */
|
|
87
|
+
constructor(containerId, httpUrl, postgresContainerId) {
|
|
88
|
+
this.containerId = containerId
|
|
89
|
+
this.httpUrl = httpUrl
|
|
90
|
+
// Backing database. Postgres is the server's only supported backend.
|
|
91
|
+
this.postgresContainerId = postgresContainerId
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
static async start() {
|
|
95
|
+
await buildImageIfMissing()
|
|
96
|
+
const fixture = new LnurlFixture('', '', '')
|
|
97
|
+
try {
|
|
98
|
+
const postgresOutput = await docker([
|
|
99
|
+
'run',
|
|
100
|
+
'-d',
|
|
101
|
+
// All interfaces rather than 127.0.0.1: the lnurl server reaches this
|
|
102
|
+
// from inside its own container through the host gateway, which on
|
|
103
|
+
// Linux is the bridge address and not loopback.
|
|
104
|
+
'-p',
|
|
105
|
+
'0.0.0.0:0:5432',
|
|
106
|
+
'-e', 'POSTGRES_PASSWORD=postgres',
|
|
107
|
+
POSTGRES_IMAGE
|
|
108
|
+
])
|
|
109
|
+
fixture.postgresContainerId = postgresOutput.trim()
|
|
110
|
+
await fixture.waitForPostgres()
|
|
111
|
+
const dbPort = await publishedPort(fixture.postgresContainerId, POSTGRES_PORT)
|
|
112
|
+
|
|
113
|
+
const runOutput = await docker([
|
|
114
|
+
'run',
|
|
115
|
+
'-d',
|
|
116
|
+
'-p',
|
|
117
|
+
'127.0.0.1:0:8080',
|
|
118
|
+
'--add-host',
|
|
119
|
+
'host.docker.internal:host-gateway',
|
|
120
|
+
'-e', 'BREEZ_LNURL_NETWORK=regtest',
|
|
121
|
+
'-e', 'BREEZ_LNURL_AUTO_MIGRATE=true',
|
|
122
|
+
'-e', `BREEZ_LNURL_DB_URL=postgres://postgres:postgres@host.docker.internal:${dbPort}/postgres`,
|
|
123
|
+
'-e', 'BREEZ_LNURL_LOG_LEVEL=lnurl=trace,info',
|
|
124
|
+
'-e', 'BREEZ_LNURL_DOMAINS=',
|
|
125
|
+
'-e', 'BREEZ_LNURL_SCHEME=http',
|
|
126
|
+
'-e', 'BREEZ_LNURL_NSEC=nsec1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqsmhltgl',
|
|
127
|
+
'-e', 'BREEZ_LNURL_MIN_SENDABLE=1000',
|
|
128
|
+
'-e', 'BREEZ_LNURL_MAX_SENDABLE=1000000000',
|
|
129
|
+
'-e', 'BREEZ_LNURL_DEV_DONT_USE_LNURL_INCLUDE_SPARK_ADDRESS=false',
|
|
130
|
+
IMAGE
|
|
131
|
+
])
|
|
132
|
+
fixture.containerId = runOutput.trim()
|
|
133
|
+
|
|
134
|
+
const port = await publishedPort(fixture.containerId, HTTP_PORT)
|
|
135
|
+
await fixture.waitForHttp(port)
|
|
136
|
+
fixture.httpUrl = `http://127.0.0.1:${port}`
|
|
137
|
+
return fixture
|
|
138
|
+
} catch (e) {
|
|
139
|
+
fixture.stop()
|
|
140
|
+
throw e
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Readiness cannot be taken from the logs: the server's startup line is
|
|
146
|
+
* printed before it migrates the database and binds, and it logs nothing
|
|
147
|
+
* afterwards. Nor is reaching the port enough, since docker's proxy accepts
|
|
148
|
+
* connections there from the moment the container starts and closes them
|
|
149
|
+
* again while nothing is listening inside.
|
|
150
|
+
*/
|
|
151
|
+
async waitForHttp(port) {
|
|
152
|
+
const deadline = Date.now() + START_TIMEOUT_MS
|
|
153
|
+
for (;;) {
|
|
154
|
+
try {
|
|
155
|
+
await httpResponds(port)
|
|
156
|
+
return
|
|
157
|
+
} catch {
|
|
158
|
+
// Not serving yet.
|
|
159
|
+
}
|
|
160
|
+
await this.ensureRunning(this.containerId, 'lnurl server')
|
|
161
|
+
if (Date.now() >= deadline) {
|
|
162
|
+
const logs = await this.logs(this.containerId)
|
|
163
|
+
throw new Error(`lnurl server did not serve HTTP within ${START_TIMEOUT_MS}ms:\n${logs}`)
|
|
164
|
+
}
|
|
165
|
+
await new Promise((resolve) => setTimeout(resolve, 250))
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async waitForPostgres() {
|
|
170
|
+
const deadline = Date.now() + START_TIMEOUT_MS
|
|
171
|
+
for (;;) {
|
|
172
|
+
try {
|
|
173
|
+
// Probe TCP explicitly: while initialising, the image's entrypoint
|
|
174
|
+
// runs a temporary socket-only server that the default `pg_isready`
|
|
175
|
+
// check would accept as ready.
|
|
176
|
+
await docker([
|
|
177
|
+
'exec', this.postgresContainerId,
|
|
178
|
+
'pg_isready', '-h', '127.0.0.1', '-U', 'postgres'
|
|
179
|
+
])
|
|
180
|
+
return
|
|
181
|
+
} catch {
|
|
182
|
+
// Not accepting connections yet.
|
|
183
|
+
}
|
|
184
|
+
await this.ensureRunning(this.postgresContainerId, 'postgres')
|
|
185
|
+
if (Date.now() >= deadline) {
|
|
186
|
+
throw new Error(`postgres did not accept connections within ${START_TIMEOUT_MS}ms`)
|
|
187
|
+
}
|
|
188
|
+
await new Promise((resolve) => setTimeout(resolve, 250))
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Fail with the container's logs once it has exited. A container that dies
|
|
194
|
+
* on startup otherwise surfaces only as a readiness timeout, or as an
|
|
195
|
+
* unrelated docker error, neither of which names the real cause.
|
|
196
|
+
*/
|
|
197
|
+
async ensureRunning(containerId, name) {
|
|
198
|
+
const state = await docker(['inspect', '-f', '{{.State.Running}}', containerId])
|
|
199
|
+
if (state.trim() === 'true') {
|
|
200
|
+
return
|
|
201
|
+
}
|
|
202
|
+
throw new Error(`${name} container exited before it was ready:\n${await this.logs(containerId)}`)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async logs(containerId) {
|
|
206
|
+
try {
|
|
207
|
+
// docker logs writes to both stdout and stderr; check both.
|
|
208
|
+
const { stdout, stderr } = await execFileAsync('docker', ['logs', containerId])
|
|
209
|
+
return stdout + stderr
|
|
210
|
+
} catch (e) {
|
|
211
|
+
return `failed to read container logs: ${e.message}`
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
stop() {
|
|
216
|
+
for (const containerId of [this.containerId, this.postgresContainerId]) {
|
|
217
|
+
if (!containerId) {
|
|
218
|
+
continue
|
|
219
|
+
}
|
|
220
|
+
try {
|
|
221
|
+
execFileSync('docker', ['rm', '-f', containerId], { stdio: 'ignore' })
|
|
222
|
+
} catch {
|
|
223
|
+
// Container may already be gone.
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
module.exports = { LnurlFixture, dockerAvailable }
|
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// JS runner for the shared behavioral scenarios in
|
|
4
|
+
// crates/breez-sdk/cli/tests/scenarios/. It drives the wasm CLI port (which
|
|
5
|
+
// consumes the locally built npm package) the same way the Rust harness
|
|
6
|
+
// drives the Rust CLI: piped stdin, a bogus-command marker after every step,
|
|
7
|
+
// and casing/tag-tolerant JSON assertions. Mirror of
|
|
8
|
+
// crates/breez-sdk/cli/tests/harness/{session,assert,engine}.rs.
|
|
9
|
+
|
|
10
|
+
const fs = require('fs')
|
|
11
|
+
const os = require('os')
|
|
12
|
+
const path = require('path')
|
|
13
|
+
const { spawn } = require('child_process')
|
|
14
|
+
|
|
15
|
+
const { fundAddress } = require('./faucet')
|
|
16
|
+
const { LnurlFixture } = require('./lnurl-fixture')
|
|
17
|
+
|
|
18
|
+
const WASM_CLI_DIR = path.join(
|
|
19
|
+
__dirname,
|
|
20
|
+
'../../../../crates/breez-sdk/bindings/examples/cli/langs/wasm'
|
|
21
|
+
)
|
|
22
|
+
const SCENARIOS_DIR = path.join(__dirname, '../../../../crates/breez-sdk/cli/tests/scenarios')
|
|
23
|
+
|
|
24
|
+
// Marker-wait ceiling for a single command; longer waits use scenario-level
|
|
25
|
+
// retry. Same values as the Rust harness.
|
|
26
|
+
const STEP_TIMEOUT_MS = 120_000
|
|
27
|
+
const QUIESCE_MS = 200
|
|
28
|
+
const POLL_MS = 100
|
|
29
|
+
|
|
30
|
+
function sleep(ms) {
|
|
31
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// --------------------------------------------------------------------------
|
|
35
|
+
// Tolerant JSON assertions (mirror of harness/assert.rs)
|
|
36
|
+
// --------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Strip echoed REPL prompts from line starts (mirror of the Rust harness;
|
|
40
|
+
* some CLI ports echo the prompt with no trailing newline).
|
|
41
|
+
*
|
|
42
|
+
* @param {string} chunk
|
|
43
|
+
* @returns {string}
|
|
44
|
+
*/
|
|
45
|
+
function stripPrompts(chunk) {
|
|
46
|
+
return chunk
|
|
47
|
+
.split('\n')
|
|
48
|
+
.map((line) => {
|
|
49
|
+
for (;;) {
|
|
50
|
+
const match = /^breez-spark-cli \[(regtest|mainnet)\]> /.exec(line)
|
|
51
|
+
if (!match) {
|
|
52
|
+
return line
|
|
53
|
+
}
|
|
54
|
+
line = line.slice(match[0].length)
|
|
55
|
+
}
|
|
56
|
+
})
|
|
57
|
+
.join('\n')
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Extract the JSON documents a transcript chunk contains. A document starts
|
|
62
|
+
* at a line whose first column is `{` or `[` and ends when the accumulated
|
|
63
|
+
* lines parse.
|
|
64
|
+
*
|
|
65
|
+
* @param {string} chunk
|
|
66
|
+
* @returns {any[]}
|
|
67
|
+
*/
|
|
68
|
+
function extractJsonDocs(chunk) {
|
|
69
|
+
const docs = []
|
|
70
|
+
let acc = null
|
|
71
|
+
for (const line of chunk.split('\n')) {
|
|
72
|
+
if (acc !== null) {
|
|
73
|
+
acc += line
|
|
74
|
+
try {
|
|
75
|
+
docs.push(JSON.parse(acc))
|
|
76
|
+
acc = null
|
|
77
|
+
} catch {
|
|
78
|
+
acc += '\n'
|
|
79
|
+
}
|
|
80
|
+
} else if (line.startsWith('{') || line.startsWith('[')) {
|
|
81
|
+
try {
|
|
82
|
+
docs.push(JSON.parse(line))
|
|
83
|
+
} catch {
|
|
84
|
+
acc = line + '\n'
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return docs
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Lowercase and strip underscores so all casings compare equal. */
|
|
92
|
+
function normalize(s) {
|
|
93
|
+
return s.toLowerCase().replaceAll('_', '')
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Resolve a dot-separated path against a JSON value. Object keys match after
|
|
98
|
+
* normalization; numeric segments index arrays. An enum-tag segment matches
|
|
99
|
+
* either an externally tagged wrapper key (descends) or a `type` field on
|
|
100
|
+
* the current object (stays in place).
|
|
101
|
+
*
|
|
102
|
+
* @param {any} root
|
|
103
|
+
* @param {string} pathExpr - dot-separated; the empty path addresses the
|
|
104
|
+
* document itself
|
|
105
|
+
* @returns {any} the value, or undefined when the path does not resolve
|
|
106
|
+
*/
|
|
107
|
+
function lookupPath(root, pathExpr) {
|
|
108
|
+
if (pathExpr === '') {
|
|
109
|
+
return root
|
|
110
|
+
}
|
|
111
|
+
let current = root
|
|
112
|
+
for (const segment of pathExpr.split('.')) {
|
|
113
|
+
const wanted = normalize(segment)
|
|
114
|
+
if (Array.isArray(current)) {
|
|
115
|
+
const index = Number.parseInt(segment, 10)
|
|
116
|
+
if (Number.isNaN(index) || index >= current.length) {
|
|
117
|
+
return undefined
|
|
118
|
+
}
|
|
119
|
+
current = current[index]
|
|
120
|
+
} else if (current !== null && typeof current === 'object') {
|
|
121
|
+
const key = Object.keys(current).find((k) => normalize(k) === wanted)
|
|
122
|
+
if (key !== undefined) {
|
|
123
|
+
current = current[key]
|
|
124
|
+
} else if (typeof current.type === 'string' && normalize(current.type) === wanted) {
|
|
125
|
+
// Tag-style enum: the segment names the variant of the object
|
|
126
|
+
// itself; the fields live alongside the tag.
|
|
127
|
+
} else {
|
|
128
|
+
return undefined
|
|
129
|
+
}
|
|
130
|
+
} else {
|
|
131
|
+
return undefined
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return current
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Render a JSON value the way a scenario writes it. */
|
|
138
|
+
function valueToString(value) {
|
|
139
|
+
return typeof value === 'string' ? value : JSON.stringify(value)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function asNumber(value) {
|
|
143
|
+
if (typeof value === 'number') {
|
|
144
|
+
return value
|
|
145
|
+
}
|
|
146
|
+
if (typeof value === 'string' && value.trim() !== '') {
|
|
147
|
+
const n = Number(value)
|
|
148
|
+
return Number.isNaN(n) ? undefined : n
|
|
149
|
+
}
|
|
150
|
+
return undefined
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Check one expect_json matcher against the value found at its path.
|
|
155
|
+
* Matcher forms: bare value (tolerant equality), {gte: n}, {exists: bool}.
|
|
156
|
+
*
|
|
157
|
+
* @param {any} matcher
|
|
158
|
+
* @param {any} found - undefined when the path did not resolve
|
|
159
|
+
*/
|
|
160
|
+
function checkMatcher(matcher, found) {
|
|
161
|
+
if (matcher !== null && typeof matcher === 'object' && !Array.isArray(matcher)) {
|
|
162
|
+
if (typeof matcher.exists === 'boolean') {
|
|
163
|
+
const exists = found !== undefined && found !== null
|
|
164
|
+
if (exists !== matcher.exists) {
|
|
165
|
+
throw new Error(`expected exists=${matcher.exists}, value was ${JSON.stringify(found)}`)
|
|
166
|
+
}
|
|
167
|
+
return
|
|
168
|
+
}
|
|
169
|
+
if (matcher.gte !== undefined) {
|
|
170
|
+
const floor = asNumber(matcher.gte)
|
|
171
|
+
if (floor === undefined) {
|
|
172
|
+
throw new Error(`gte bound is not numeric: ${matcher.gte}`)
|
|
173
|
+
}
|
|
174
|
+
const actual = asNumber(found)
|
|
175
|
+
if (actual === undefined) {
|
|
176
|
+
throw new Error(`expected a number >= ${floor}, value was ${JSON.stringify(found)}`)
|
|
177
|
+
}
|
|
178
|
+
if (actual < floor) {
|
|
179
|
+
throw new Error(`expected >= ${floor}, got ${actual}`)
|
|
180
|
+
}
|
|
181
|
+
return
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (found === undefined) {
|
|
186
|
+
throw new Error('path not found in output')
|
|
187
|
+
}
|
|
188
|
+
const expected = valueToString(matcher).toLowerCase()
|
|
189
|
+
const actual = valueToString(found).toLowerCase()
|
|
190
|
+
if (expected !== actual) {
|
|
191
|
+
throw new Error(`expected '${expected}', got '${actual}'`)
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// --------------------------------------------------------------------------
|
|
196
|
+
// Variable interpolation (mirror of harness/mod.rs)
|
|
197
|
+
// --------------------------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
/** Replace every ${name} in input; unknown variables fail loudly. */
|
|
200
|
+
function interpolate(input, vars) {
|
|
201
|
+
return input.replaceAll(/\$\{([^}]*)\}/g, (_, name) => {
|
|
202
|
+
if (!(name in vars)) {
|
|
203
|
+
throw new Error(`unknown variable '\${${name}}' in '${input}'`)
|
|
204
|
+
}
|
|
205
|
+
return vars[name]
|
|
206
|
+
})
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// --------------------------------------------------------------------------
|
|
210
|
+
// CLI session (mirror of harness/session.rs)
|
|
211
|
+
// --------------------------------------------------------------------------
|
|
212
|
+
|
|
213
|
+
class CliSession {
|
|
214
|
+
/**
|
|
215
|
+
* @param {string} dataDir
|
|
216
|
+
* @param {string[]} extraArgs
|
|
217
|
+
*/
|
|
218
|
+
constructor(dataDir, extraArgs) {
|
|
219
|
+
this.child = spawn(
|
|
220
|
+
process.execPath,
|
|
221
|
+
['src/main.js', '--data-dir', dataDir, ...extraArgs],
|
|
222
|
+
{ cwd: WASM_CLI_DIR, stdio: ['pipe', 'pipe', 'pipe'] }
|
|
223
|
+
)
|
|
224
|
+
this.transcript = ''
|
|
225
|
+
this.cursor = 0
|
|
226
|
+
this.stepCounter = 0
|
|
227
|
+
for (const stream of [this.child.stdout, this.child.stderr]) {
|
|
228
|
+
stream.setEncoding('utf8')
|
|
229
|
+
stream.on('data', (data) => {
|
|
230
|
+
this.transcript += data
|
|
231
|
+
})
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Run one command with its scripted stdin answers and return the
|
|
237
|
+
* transcript chunk it produced.
|
|
238
|
+
*
|
|
239
|
+
* @param {string} cmd
|
|
240
|
+
* @param {string[]} stdinLines
|
|
241
|
+
* @returns {Promise<string>}
|
|
242
|
+
*/
|
|
243
|
+
async runStep(cmd, stdinLines) {
|
|
244
|
+
this.stepCounter += 1
|
|
245
|
+
const marker = `__step_end_${this.stepCounter}__`
|
|
246
|
+
const input = [cmd, ...stdinLines, marker].join('\n') + '\n'
|
|
247
|
+
this.child.stdin.write(input)
|
|
248
|
+
|
|
249
|
+
const deadline = Date.now() + STEP_TIMEOUT_MS
|
|
250
|
+
let markerPos
|
|
251
|
+
for (;;) {
|
|
252
|
+
markerPos = this.transcript.indexOf(marker, this.cursor)
|
|
253
|
+
if (markerPos !== -1) {
|
|
254
|
+
break
|
|
255
|
+
}
|
|
256
|
+
if (Date.now() >= deadline) {
|
|
257
|
+
throw new Error(
|
|
258
|
+
`timed out after ${STEP_TIMEOUT_MS}ms waiting for step to finish; ` +
|
|
259
|
+
`output so far:\n${this.transcript.slice(this.cursor)}`
|
|
260
|
+
)
|
|
261
|
+
}
|
|
262
|
+
await sleep(POLL_MS)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Wait for the marker error's trailing lines to land, then consume
|
|
266
|
+
// everything up to the current end of transcript.
|
|
267
|
+
let stableLength = this.transcript.length
|
|
268
|
+
for (;;) {
|
|
269
|
+
await sleep(QUIESCE_MS)
|
|
270
|
+
if (this.transcript.length === stableLength) {
|
|
271
|
+
break
|
|
272
|
+
}
|
|
273
|
+
stableLength = this.transcript.length
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// The chunk ends at the start of the line carrying the marker error.
|
|
277
|
+
const chunkEnd = this.transcript.lastIndexOf('\n', markerPos)
|
|
278
|
+
const chunk = this.transcript.slice(this.cursor, Math.max(chunkEnd, this.cursor))
|
|
279
|
+
this.cursor = stableLength
|
|
280
|
+
return chunk
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Ask the CLI to exit and wait for the process to finish. */
|
|
284
|
+
async close() {
|
|
285
|
+
this.child.stdin.write('exit\n')
|
|
286
|
+
const exited = await Promise.race([
|
|
287
|
+
new Promise((resolve) => this.child.once('exit', () => resolve(true))),
|
|
288
|
+
sleep(30_000).then(() => false)
|
|
289
|
+
])
|
|
290
|
+
if (!exited) {
|
|
291
|
+
this.child.kill('SIGKILL')
|
|
292
|
+
throw new Error("CLI did not exit within 30s of 'exit'")
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
kill() {
|
|
297
|
+
this.child.kill('SIGKILL')
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// --------------------------------------------------------------------------
|
|
302
|
+
// Scenario engine (mirror of harness/engine.rs)
|
|
303
|
+
// --------------------------------------------------------------------------
|
|
304
|
+
|
|
305
|
+
/** Check a step's expectations against its chunk; returns captured vars. */
|
|
306
|
+
function evaluate(chunk, step, vars) {
|
|
307
|
+
chunk = stripPrompts(chunk)
|
|
308
|
+
const docs = extractJsonDocs(chunk)
|
|
309
|
+
const last = docs.length > 0 ? docs[docs.length - 1] : undefined
|
|
310
|
+
|
|
311
|
+
for (const [pathExpr, matcher] of Object.entries(step.expect_json ?? {})) {
|
|
312
|
+
const resolved = typeof matcher === 'string' ? interpolate(matcher, vars) : matcher
|
|
313
|
+
try {
|
|
314
|
+
checkMatcher(resolved, last === undefined ? undefined : lookupPath(last, pathExpr))
|
|
315
|
+
} catch (e) {
|
|
316
|
+
throw new Error(`expect_json '${pathExpr}' failed: ${e.message}`)
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
for (const needle of step.expect_contains ?? []) {
|
|
321
|
+
const resolved = interpolate(needle, vars)
|
|
322
|
+
if (!chunk.includes(resolved)) {
|
|
323
|
+
throw new Error(`expect_contains '${resolved}' not found in step output`)
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const captured = {}
|
|
328
|
+
for (const [name, pathExpr] of Object.entries(step.capture ?? {})) {
|
|
329
|
+
const value = last === undefined ? undefined : lookupPath(last, pathExpr)
|
|
330
|
+
if (value === undefined) {
|
|
331
|
+
throw new Error(`capture '${name}': path '${pathExpr}' not found`)
|
|
332
|
+
}
|
|
333
|
+
captured[name] = valueToString(value)
|
|
334
|
+
}
|
|
335
|
+
return captured
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
async function runCmdStep(cli, step, vars) {
|
|
339
|
+
const cmd = interpolate(step.cmd, vars)
|
|
340
|
+
const stdinLines = (step.stdin ?? []).map((l) => interpolate(l, vars))
|
|
341
|
+
const deadline = step.retry ? Date.now() + step.retry.timeout_secs * 1000 : undefined
|
|
342
|
+
const intervalMs = (step.retry?.interval_secs ?? 5) * 1000
|
|
343
|
+
|
|
344
|
+
for (;;) {
|
|
345
|
+
const chunk = await cli.runStep(cmd, stdinLines)
|
|
346
|
+
try {
|
|
347
|
+
Object.assign(vars, evaluate(chunk, step, vars))
|
|
348
|
+
return
|
|
349
|
+
} catch (e) {
|
|
350
|
+
if (deadline !== undefined && Date.now() < deadline) {
|
|
351
|
+
console.error(`step '${cmd}' not satisfied yet (${e.message}), retrying`)
|
|
352
|
+
await sleep(intervalMs)
|
|
353
|
+
} else {
|
|
354
|
+
throw new Error(`step '${cmd}' failed: ${e.message}\nstep output:\n${chunk}`)
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Run one scenario JSON (already parsed). Fixture and requirement gating is
|
|
362
|
+
* the caller's job; this assumes they are met.
|
|
363
|
+
*
|
|
364
|
+
* @param {object} scenario
|
|
365
|
+
*/
|
|
366
|
+
async function runScenario(scenario) {
|
|
367
|
+
const vars = {}
|
|
368
|
+
const fixtures = []
|
|
369
|
+
const walletDirs = new Map()
|
|
370
|
+
try {
|
|
371
|
+
for (const fixture of scenario.fixtures ?? []) {
|
|
372
|
+
if (fixture === 'lnurl') {
|
|
373
|
+
const lnurl = await LnurlFixture.start()
|
|
374
|
+
vars.lnurl_url = lnurl.httpUrl
|
|
375
|
+
fixtures.push(lnurl)
|
|
376
|
+
} else {
|
|
377
|
+
throw new Error(`unknown fixture '${fixture}'`)
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
for (const [sessionIndex, session] of scenario.sessions.entries()) {
|
|
382
|
+
let dir = walletDirs.get(session.wallet)
|
|
383
|
+
if (dir === undefined) {
|
|
384
|
+
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'breez-cli-scenario-'))
|
|
385
|
+
walletDirs.set(session.wallet, dir)
|
|
386
|
+
}
|
|
387
|
+
const extraArgs = (session.extra_args ?? []).map((a) => interpolate(a, vars))
|
|
388
|
+
const cli = new CliSession(dir, extraArgs)
|
|
389
|
+
try {
|
|
390
|
+
for (const [stepIndex, step] of session.steps.entries()) {
|
|
391
|
+
try {
|
|
392
|
+
if (step.faucet_fund) {
|
|
393
|
+
const address = interpolate(step.faucet_fund.address, vars)
|
|
394
|
+
const txid = await fundAddress(address, step.faucet_fund.amount_sats)
|
|
395
|
+
console.error(
|
|
396
|
+
`faucet funded ${address} with ${step.faucet_fund.amount_sats} sats: ${txid}`
|
|
397
|
+
)
|
|
398
|
+
} else {
|
|
399
|
+
await runCmdStep(cli, step, vars)
|
|
400
|
+
}
|
|
401
|
+
} catch (e) {
|
|
402
|
+
e.message =
|
|
403
|
+
`scenario '${scenario.name}', session ${sessionIndex} ` +
|
|
404
|
+
`(wallet '${session.wallet}'), step ${stepIndex}: ${e.message}`
|
|
405
|
+
throw e
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
} catch (e) {
|
|
409
|
+
cli.kill()
|
|
410
|
+
throw e
|
|
411
|
+
}
|
|
412
|
+
await cli.close()
|
|
413
|
+
}
|
|
414
|
+
} finally {
|
|
415
|
+
for (const fixture of fixtures) {
|
|
416
|
+
fixture.stop()
|
|
417
|
+
}
|
|
418
|
+
for (const dir of walletDirs.values()) {
|
|
419
|
+
fs.rmSync(dir, { recursive: true, force: true })
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** List scenario files as [name, parsed] pairs, sorted by name. */
|
|
425
|
+
function loadScenarios() {
|
|
426
|
+
return fs
|
|
427
|
+
.readdirSync(SCENARIOS_DIR)
|
|
428
|
+
.filter((f) => f.endsWith('.json'))
|
|
429
|
+
.sort()
|
|
430
|
+
.map((f) => [
|
|
431
|
+
path.basename(f, '.json'),
|
|
432
|
+
JSON.parse(fs.readFileSync(path.join(SCENARIOS_DIR, f), 'utf8'))
|
|
433
|
+
])
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
module.exports = {
|
|
437
|
+
WASM_CLI_DIR,
|
|
438
|
+
checkMatcher,
|
|
439
|
+
extractJsonDocs,
|
|
440
|
+
interpolate,
|
|
441
|
+
loadScenarios,
|
|
442
|
+
lookupPath,
|
|
443
|
+
runScenario
|
|
444
|
+
}
|