@breeztech/breez-sdk-spark 0.19.2 → 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.
@@ -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
+ }