@fiber-dev-kit/test-client 0.1.0-rc.0

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,38 @@
1
+ # Contributing to @fiber-dev-kit/test-client
2
+
3
+ Thanks for helping improve the Fiber Dev Kit test client.
4
+
5
+ ## Scope
6
+
7
+ This package provides programmable test helpers for Fiber payment and channel flows. It should make local testnets and CI checks easier without hiding important Fiber behavior.
8
+
9
+ Typical changes include:
10
+
11
+ - Multi-node test orchestration.
12
+ - Payment and channel assertions.
13
+ - Polling utilities.
14
+ - Route confidence checks.
15
+ - Terminal output improvements.
16
+
17
+ ## Local Development
18
+
19
+ From the workspace root:
20
+
21
+ ```bash
22
+ npm install
23
+ npm run build --workspace @fiber-dev-kit/test-client
24
+ npm run typecheck --workspace @fiber-dev-kit/test-client
25
+ npm test --workspace @fiber-dev-kit/test-client
26
+ ```
27
+
28
+ ## Guidelines
29
+
30
+ - Keep assertions deterministic and useful for failing Fiber payment tests.
31
+ - Do not swallow failures silently. Return structured diagnostics where possible.
32
+ - Keep terminal formatting readable in both local terminals and CI logs.
33
+ - Add tests for new polling behavior, assertions, and failure paths.
34
+ - Use `@fiber-dev-kit/core` for shared RPC, diagnostics, amount, and route logic instead of duplicating it here.
35
+
36
+ ## Release Notes
37
+
38
+ When changing test behavior, document any assertion semantics that changed. Include examples for new helpers.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Fiber Dev Kit contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,52 @@
1
+ # @fiber-dev-kit/test-client
2
+
3
+ Programmatic test helpers for Fiber payment, channel, and routing flows.
4
+
5
+ The test client builds on `@fiber-dev-kit/core` and is intended for local testnets, CI smoke tests, hackathon demos, and repeatable Fiber payment experiments.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install @fiber-dev-kit/test-client
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ ```ts
16
+ import { FiberNetwork } from "@fiber-dev-kit/test-client";
17
+
18
+ const network = new FiberNetwork({
19
+ nodes: {
20
+ a: "http://127.0.0.1:8227",
21
+ b: "http://127.0.0.1:8237",
22
+ },
23
+ });
24
+
25
+ await network.expectNodeHealthy("a");
26
+
27
+ const report = await network.canPay({
28
+ from: "a",
29
+ to: "b",
30
+ amount: "1",
31
+ });
32
+
33
+ console.log(report);
34
+ ```
35
+
36
+ ## What it provides
37
+
38
+ - Multi-node test wrapper around Fiber RPC clients.
39
+ - Payment and channel assertions.
40
+ - Polling utilities for async Fiber state transitions.
41
+ - Pretty terminal output for local test runs.
42
+ - Route confidence checks backed by `@fiber-dev-kit/core`.
43
+
44
+ ## Requirements
45
+
46
+ - Node.js 18 or newer.
47
+ - Published `@fiber-dev-kit/core` matching this package version.
48
+ - One or more reachable Fiber node JSON-RPC endpoints.
49
+
50
+ ## License
51
+
52
+ MIT
@@ -0,0 +1,10 @@
1
+ export { FiberTestClient } from "./test-client";
2
+ export type { TestClientConfig, PayParams, RouteConfidenceReport } from "./test-client";
3
+ export { FiberNetwork } from "./network";
4
+ export type { FiberNetworkConfig, NetworkNodeConfig, NetworkTopology, SimulationResult } from "./network";
5
+ export { pollUntilResolved, sleep } from "./poller";
6
+ export type { PollOptions } from "./poller";
7
+ export { formatDiagnosis, formatRouteConfidenceReport, formatSimulationResult } from "./terminal";
8
+ export type { TerminalFormatOptions } from "./terminal";
9
+ export { diagnose, diagnosePayment, FiberError } from "@fiber-dev-kit/core";
10
+ export type { Diagnosis, DiagnosisCode, PaymentResult, HexString } from "@fiber-dev-kit/core";
package/dist/index.js ADDED
@@ -0,0 +1,421 @@
1
+ // src/test-client.ts
2
+ import { FiberClient, FiberEventClient, diagnose, diagnosePayment, FiberError, shannonHexToCkb } from "@fiber-dev-kit/core";
3
+
4
+ // src/poller.ts
5
+ function sleep(ms) {
6
+ return new Promise((resolve) => setTimeout(resolve, ms));
7
+ }
8
+ async function pollUntilResolved(fetchValue, isResolved, options = {}) {
9
+ const intervalMs = options.intervalMs ?? 500;
10
+ const timeoutMs = options.timeoutMs ?? 1e4;
11
+ const deadline = Date.now() + timeoutMs;
12
+ let value = await fetchValue();
13
+ while (!isResolved(value)) {
14
+ if (Date.now() >= deadline) {
15
+ throw new Error(`pollUntilResolved: condition not met within ${timeoutMs}ms`);
16
+ }
17
+ await sleep(intervalMs);
18
+ value = await fetchValue();
19
+ }
20
+ return value;
21
+ }
22
+
23
+ // src/test-client.ts
24
+ var TERMINAL_PAYMENT_STATUSES = /* @__PURE__ */ new Set(["Success", "Failed"]);
25
+ var FiberTestClient = class {
26
+ rpc;
27
+ events;
28
+ pollIntervalMs;
29
+ timeoutMs;
30
+ constructor(config) {
31
+ if (config.network === "mainnet") {
32
+ throw new Error('FiberTestClient refuses network: "mainnet" \u2014 it exists for devnet/testnet testing only.');
33
+ }
34
+ this.pollIntervalMs = config.pollIntervalMs ?? 500;
35
+ this.timeoutMs = config.timeoutMs ?? 1e4;
36
+ this.rpc = new FiberClient({ nodeUrl: config.nodeUrl, network: config.network ?? "devnet" });
37
+ this.events = new FiberEventClient({ client: this.rpc, pollIntervalMs: this.pollIntervalMs });
38
+ }
39
+ /** Pays an invoice, or keysends directly to a pubkey with an explicit amount. */
40
+ pay(params) {
41
+ if ("invoice" in params) {
42
+ return this.rpc.payInvoice(params.invoice);
43
+ }
44
+ return this.rpc.sendPayment({ targetPubkey: params.to, amount: params.amount, keysend: true });
45
+ }
46
+ /** Dry-runs a payment to check routability/fees without sending. Returns `null` if it wouldn't succeed. */
47
+ async canPay(params) {
48
+ try {
49
+ if ("invoice" in params) {
50
+ return await this.rpc.sendPayment({ invoice: params.invoice, dryRun: true });
51
+ }
52
+ return await this.rpc.sendPayment({ targetPubkey: params.to, amount: params.amount, keysend: true, dryRun: true });
53
+ } catch {
54
+ return null;
55
+ }
56
+ }
57
+ /**
58
+ * Produces a small route-confidence report from local health signals plus FNN's dry-run
59
+ * payment result. It is a practical preflight check, not a full network graph score.
60
+ */
61
+ async routeConfidence(params) {
62
+ const reasons = [];
63
+ const suggestions = [];
64
+ let score = 100;
65
+ const [peers, channels] = await Promise.all([
66
+ this.rpc.listPeers().catch(() => []),
67
+ this.rpc.listChannels({ includeClosed: true }).catch(() => [])
68
+ ]);
69
+ const readyChannels = channels.filter((channel) => channel.state.state_name === "ChannelReady");
70
+ if (peers.length === 0) {
71
+ score -= 30;
72
+ reasons.push("Node has no connected peers.");
73
+ suggestions.push("Connect to at least one reachable peer before testing routed payments.");
74
+ }
75
+ if (readyChannels.length === 0) {
76
+ score -= 40;
77
+ reasons.push("Node has no ChannelReady channels.");
78
+ suggestions.push("Open a channel and wait for ChannelReady before sending payments.");
79
+ }
80
+ if ("to" in params) {
81
+ score = applyKeysendLiquiditySignal(score, params, readyChannels, reasons, suggestions);
82
+ }
83
+ let dryRunPayment = null;
84
+ let diagnosis = null;
85
+ try {
86
+ dryRunPayment = "invoice" in params ? await this.rpc.sendPayment({ invoice: params.invoice, dryRun: true }) : await this.rpc.sendPayment({ targetPubkey: params.to, amount: params.amount, keysend: true, dryRun: true });
87
+ reasons.push("FNN dry-run accepted the payment.");
88
+ score = Math.max(score, 90);
89
+ } catch (err) {
90
+ score = Math.min(score - 30, 40);
91
+ if (FiberError.is(err)) {
92
+ diagnosis = diagnose(err);
93
+ reasons.push(diagnosis.summary);
94
+ suggestions.push(diagnosis.suggestion);
95
+ } else {
96
+ reasons.push(`Dry-run failed: ${err.message}`);
97
+ suggestions.push("Inspect the thrown error and retry after correcting the route or invoice.");
98
+ }
99
+ }
100
+ const normalizedScore = Math.max(0, Math.min(100, score));
101
+ return {
102
+ canPay: dryRunPayment !== null,
103
+ score: normalizedScore,
104
+ level: confidenceLevel(normalizedScore),
105
+ reasons,
106
+ suggestions: [...new Set(suggestions)],
107
+ dryRunPayment,
108
+ diagnosis
109
+ };
110
+ }
111
+ waitForPayment(paymentHash) {
112
+ return pollUntilResolved(
113
+ () => this.rpc.getPayment(paymentHash),
114
+ (payment) => TERMINAL_PAYMENT_STATUSES.has(payment.status),
115
+ { intervalMs: this.pollIntervalMs, timeoutMs: this.timeoutMs }
116
+ );
117
+ }
118
+ async assertPaid(paymentHash) {
119
+ const payment = await this.waitForPayment(paymentHash);
120
+ if (payment.status !== "Success") {
121
+ const diagnosis = diagnosePayment(payment);
122
+ throw new Error(
123
+ `Expected payment ${paymentHash} to succeed, got status "${payment.status}"` + (diagnosis ? ` \u2014 ${diagnosis.summary} (${diagnosis.suggestion})` : "")
124
+ );
125
+ }
126
+ return payment;
127
+ }
128
+ async assertFailed(paymentHash) {
129
+ const payment = await this.waitForPayment(paymentHash);
130
+ if (payment.status !== "Failed") {
131
+ throw new Error(`Expected payment ${paymentHash} to fail, got status "${payment.status}"`);
132
+ }
133
+ return payment;
134
+ }
135
+ /** Asserts a payment failed for a specific, structured reason (see `diagnose()` in core). */
136
+ async assertError(paymentHash, expectedCode) {
137
+ const payment = await this.assertFailed(paymentHash);
138
+ const diagnosis = diagnosePayment(payment);
139
+ if (!diagnosis) {
140
+ throw new Error(
141
+ `Payment ${paymentHash} failed, but FNN reported no failure reason (failed_error was empty) \u2014 cannot verify it matches "${expectedCode}".`
142
+ );
143
+ }
144
+ if (diagnosis.code !== expectedCode) {
145
+ throw new Error(
146
+ `Expected payment ${paymentHash} to fail with "${expectedCode}", got "${diagnosis.code}": ${diagnosis.summary}`
147
+ );
148
+ }
149
+ return diagnosis;
150
+ }
151
+ async getChannelBalance(channelId) {
152
+ const channels = await this.rpc.listChannels({ includeClosed: true });
153
+ const channel = channels.find((c) => c.channel_id === channelId);
154
+ if (!channel) {
155
+ throw new Error(`getChannelBalance(): no channel with id ${channelId} found on this node.`);
156
+ }
157
+ return { local: shannonHexToCkb(channel.local_balance), remote: shannonHexToCkb(channel.remote_balance) };
158
+ }
159
+ /** Pays `to` repeatedly until this channel's outbound capacity is exhausted (minus 1 CKB for fees). */
160
+ async drainChannel(channelId, to) {
161
+ const { local } = await this.getChannelBalance(channelId);
162
+ const amount = Math.floor(local) - 1;
163
+ if (amount <= 0) return;
164
+ const result = await this.pay({ to, amount });
165
+ await this.waitForPayment(result.payment_hash);
166
+ }
167
+ };
168
+ function applyKeysendLiquiditySignal(score, params, readyChannels, reasons, suggestions) {
169
+ const candidateChannels = readyChannels.filter((channel) => !channel.peer_id || channel.peer_id === params.to);
170
+ if (candidateChannels.length === 0) return score;
171
+ const bestLocalBalance = Math.max(...candidateChannels.map((channel) => shannonHexToCkb(channel.local_balance)));
172
+ if (bestLocalBalance < params.amount) {
173
+ reasons.push(`Best matching channel has ${bestLocalBalance} CKB outbound balance, below the ${params.amount} CKB payment.`);
174
+ suggestions.push("Reduce the payment amount, rebalance the channel, or open more outbound liquidity.");
175
+ return score - 25;
176
+ }
177
+ reasons.push(`A ready channel has enough local balance for ${params.amount} CKB before fees.`);
178
+ return score;
179
+ }
180
+ function confidenceLevel(score) {
181
+ if (score >= 80) return "high";
182
+ if (score >= 50) return "medium";
183
+ return "low";
184
+ }
185
+
186
+ // src/network.ts
187
+ import { diagnose as diagnose2, diagnosePayment as diagnosePayment2, FiberError as FiberError2 } from "@fiber-dev-kit/core";
188
+ var FiberNetwork = class {
189
+ clients = /* @__PURE__ */ new Map();
190
+ pubkeys = /* @__PURE__ */ new Map();
191
+ pollIntervalMs;
192
+ timeoutMs;
193
+ simulate;
194
+ constructor(config) {
195
+ this.pollIntervalMs = config.pollIntervalMs ?? 1e3;
196
+ this.timeoutMs = config.timeoutMs ?? 6e4;
197
+ for (const [alias, nodeConfig] of Object.entries(config.nodes)) {
198
+ const resolved = typeof nodeConfig === "string" ? { nodeUrl: nodeConfig } : nodeConfig;
199
+ this.clients.set(
200
+ alias,
201
+ new FiberTestClient({ ...resolved, pollIntervalMs: this.pollIntervalMs, timeoutMs: this.timeoutMs })
202
+ );
203
+ }
204
+ this.simulate = new NetworkSimulations(this);
205
+ }
206
+ node(alias) {
207
+ const client = this.clients.get(alias);
208
+ if (!client) {
209
+ throw new Error(`FiberNetwork: no node registered under alias "${alias}". Known aliases: ${[...this.clients.keys()].join(", ") || "(none)"}`);
210
+ }
211
+ return client;
212
+ }
213
+ /** Waits until every configured node's RPC is reachable. Does not start any process. */
214
+ async start() {
215
+ await Promise.all(
216
+ [...this.clients.values()].map(
217
+ (client) => pollUntilResolved(
218
+ () => client.rpc.info().then(() => true, () => false),
219
+ (ok) => ok,
220
+ { intervalMs: this.pollIntervalMs, timeoutMs: this.timeoutMs }
221
+ )
222
+ )
223
+ );
224
+ }
225
+ async pubkeyOf(alias) {
226
+ let pubkey = this.pubkeys.get(alias);
227
+ if (!pubkey) {
228
+ const info = await this.node(alias).rpc.info();
229
+ pubkey = info.pubkey;
230
+ this.pubkeys.set(alias, pubkey);
231
+ }
232
+ return pubkey;
233
+ }
234
+ /** Connects `fromAlias` to `toAlias` by pubkey/address, unless already peered. */
235
+ async connect(fromAlias, toAlias) {
236
+ const from = this.node(fromAlias);
237
+ const toInfo = await this.node(toAlias).rpc.info();
238
+ const alreadyConnected = (await from.rpc.listPeers()).some((peer) => peer.pubkey === toInfo.pubkey);
239
+ if (alreadyConnected) return;
240
+ const address = toInfo.addresses[0];
241
+ if (!address) {
242
+ throw new Error(
243
+ `connect(): node "${toAlias}" has no advertised address in info().addresses. It may not be listening publicly \u2014 connect it manually with a known multiaddr instead.`
244
+ );
245
+ }
246
+ await from.rpc.connectPeer({ address });
247
+ }
248
+ /** Connects (if needed), opens a channel, and waits until it's `ChannelReady` on the funder's side. */
249
+ async openChannel(fromAlias, toAlias, capacityCkb) {
250
+ await this.connect(fromAlias, toAlias);
251
+ const from = this.node(fromAlias);
252
+ const toPubkey = await this.pubkeyOf(toAlias);
253
+ await from.rpc.openChannel({ pubkey: toPubkey, fundingAmount: capacityCkb });
254
+ const ready = await pollUntilResolved(
255
+ () => from.rpc.listChannels({ pubkey: toPubkey }),
256
+ (channels) => channels.some((c) => c.state.state_name === "ChannelReady"),
257
+ { intervalMs: this.pollIntervalMs, timeoutMs: this.timeoutMs }
258
+ );
259
+ return ready.find((c) => c.state.state_name === "ChannelReady").channel_id;
260
+ }
261
+ /** Creates an invoice on `toAlias` and pays it from `fromAlias`. */
262
+ async pay(fromAlias, toAlias, amountCkb, opts = {}) {
263
+ const to = this.node(toAlias);
264
+ const from = this.node(fromAlias);
265
+ const { invoice_address } = await to.rpc.createInvoice({
266
+ amount: amountCkb,
267
+ description: opts.description ?? `${fromAlias} -> ${toAlias}`
268
+ });
269
+ return from.pay({ invoice: invoice_address });
270
+ }
271
+ };
272
+ var NetworkSimulations = class {
273
+ constructor(network) {
274
+ this.network = network;
275
+ }
276
+ network;
277
+ /** Drains `channelId`'s outbound capacity from `fromAlias`, then overpays past it. */
278
+ async insufficientLiquidity(fromAlias, toAlias, channelId) {
279
+ const from = this.network.node(fromAlias);
280
+ const toPubkey = await this.network.pubkeyOf(toAlias);
281
+ const { local } = await from.getChannelBalance(channelId);
282
+ if (local > 1) {
283
+ await from.drainChannel(channelId, toPubkey);
284
+ }
285
+ return this.attempt(() => this.network.pay(fromAlias, toAlias, local + 1));
286
+ }
287
+ /** Pays a node with no route to it (never connected, no channel path). */
288
+ async unreachablePeer(fromAlias, targetAlias, amountCkb) {
289
+ const from = this.network.node(fromAlias);
290
+ const targetPubkey = await this.network.pubkeyOf(targetAlias);
291
+ return this.attempt(() => from.pay({ to: targetPubkey, amount: amountCkb }));
292
+ }
293
+ /** Creates a 1-second-expiry invoice, waits it out, then tries to pay it. */
294
+ async expiredInvoice(fromAlias, toAlias, amountCkb) {
295
+ const to = this.network.node(toAlias);
296
+ const from = this.network.node(fromAlias);
297
+ const { invoice_address } = await to.rpc.createInvoice({
298
+ amount: amountCkb,
299
+ expiry: 1,
300
+ description: "simulate.expiredInvoice"
301
+ });
302
+ await sleep(1500);
303
+ return this.attempt(() => from.pay({ invoice: invoice_address }));
304
+ }
305
+ async attempt(send) {
306
+ try {
307
+ const payment = await send();
308
+ return { payment, diagnosis: diagnosePayment2(payment) };
309
+ } catch (err) {
310
+ if (FiberError2.is(err)) {
311
+ return { payment: null, diagnosis: diagnose2(err) };
312
+ }
313
+ throw err;
314
+ }
315
+ }
316
+ };
317
+
318
+ // src/terminal.ts
319
+ import { Chalk } from "chalk";
320
+ var DEFAULT_WIDTH = 72;
321
+ function formatRouteConfidenceReport(report, options = {}) {
322
+ const out = new TerminalFormatter(options);
323
+ out.section("Route Confidence");
324
+ out.rows([
325
+ ["Can pay", report.canPay ? "yes" : "no"],
326
+ ["Score", `${report.score}/100`],
327
+ ["Level", report.level],
328
+ ["Dry-run", report.dryRunPayment ? `accepted (${report.dryRunPayment.status})` : "rejected"]
329
+ ]);
330
+ out.list("Reasons", report.reasons);
331
+ out.list("Suggestions", report.suggestions);
332
+ if (report.diagnosis) out.diagnosis(report.diagnosis);
333
+ return out.toString();
334
+ }
335
+ function formatSimulationResult(title, result, options = {}) {
336
+ const out = new TerminalFormatter(options);
337
+ out.section(title);
338
+ out.status(result.payment ? "INFO" : "FAIL", result.payment ? "Payment attempt returned a payment record." : "Payment attempt failed before submission.");
339
+ if (result.payment) {
340
+ out.rows([
341
+ ["Payment", result.payment.payment_hash],
342
+ ["Status", result.payment.status],
343
+ ["Fee", result.payment.fee]
344
+ ]);
345
+ }
346
+ if (result.diagnosis) out.diagnosis(result.diagnosis);
347
+ return out.toString();
348
+ }
349
+ function formatDiagnosis(diagnosis, options = {}) {
350
+ const out = new TerminalFormatter(options);
351
+ out.diagnosis(diagnosis);
352
+ return out.toString();
353
+ }
354
+ var TerminalFormatter = class {
355
+ chalk;
356
+ width;
357
+ lines = [];
358
+ constructor(options) {
359
+ const color = options.color ?? Boolean(process.stdout.isTTY && !process.env.NO_COLOR);
360
+ this.chalk = new Chalk({ level: color ? 1 : 0 });
361
+ this.width = options.width ?? DEFAULT_WIDTH;
362
+ }
363
+ section(title) {
364
+ this.lines.push("");
365
+ this.lines.push(this.chalk.bold(title));
366
+ this.lines.push(this.chalk.dim("-".repeat(Math.min(title.length, this.width))));
367
+ }
368
+ status(kind, message) {
369
+ this.lines.push(` ${this.badge(kind)} ${message}`);
370
+ }
371
+ rows(rows, labelWidth = 14) {
372
+ for (const [label, value] of rows) {
373
+ this.lines.push(` ${this.chalk.dim(label.padEnd(labelWidth))} ${value}`);
374
+ }
375
+ }
376
+ list(title, items) {
377
+ if (items.length === 0) return;
378
+ this.lines.push("");
379
+ this.lines.push(` ${this.chalk.bold(title)}`);
380
+ for (const item of items) {
381
+ this.lines.push(` - ${item}`);
382
+ }
383
+ }
384
+ diagnosis(diagnosis) {
385
+ this.lines.push("");
386
+ this.lines.push(` ${this.chalk.bold("Diagnosis")}`);
387
+ this.rows(
388
+ [
389
+ ["Code", this.chalk.bold(diagnosis.code)],
390
+ ["Summary", diagnosis.summary],
391
+ ["Suggestion", diagnosis.suggestion]
392
+ ],
393
+ 12
394
+ );
395
+ }
396
+ toString() {
397
+ return this.lines.join("\n");
398
+ }
399
+ badge(kind) {
400
+ const text = `[${kind}]`;
401
+ if (kind === "PASS") return this.chalk.green(text);
402
+ if (kind === "FAIL") return this.chalk.red(text);
403
+ if (kind === "SKIP") return this.chalk.yellow(text);
404
+ return this.chalk.cyan(text);
405
+ }
406
+ };
407
+
408
+ // src/index.ts
409
+ import { diagnose as diagnose3, diagnosePayment as diagnosePayment3, FiberError as FiberError3 } from "@fiber-dev-kit/core";
410
+ export {
411
+ FiberError3 as FiberError,
412
+ FiberNetwork,
413
+ FiberTestClient,
414
+ diagnose3 as diagnose,
415
+ diagnosePayment3 as diagnosePayment,
416
+ formatDiagnosis,
417
+ formatRouteConfidenceReport,
418
+ formatSimulationResult,
419
+ pollUntilResolved,
420
+ sleep
421
+ };
@@ -0,0 +1,55 @@
1
+ import type { Diagnosis, HexString, NetworkMode, PaymentResult } from "@fiber-dev-kit/core";
2
+ import { FiberTestClient } from "./test-client";
3
+ export interface NetworkNodeConfig {
4
+ nodeUrl: string;
5
+ network?: Exclude<NetworkMode, "mainnet">;
6
+ }
7
+ /** Alias (e.g. `"a"`, `"b"`) → an already-running node's RPC URL or config. */
8
+ export type NetworkTopology = Record<string, string | NetworkNodeConfig>;
9
+ export interface FiberNetworkConfig {
10
+ nodes: NetworkTopology;
11
+ pollIntervalMs?: number;
12
+ timeoutMs?: number;
13
+ }
14
+ export interface SimulationResult {
15
+ payment: PaymentResult | null;
16
+ diagnosis: Diagnosis | null;
17
+ }
18
+ /**
19
+ * Multi-node test orchestration. Wraps one `FiberTestClient` per alias and never spawns or
20
+ * manages `fnn` processes — point it at nodes that are already running (via the fiber-devkit
21
+ * CLI, manually, or however your team's launcher of the day works).
22
+ */
23
+ export declare class FiberNetwork {
24
+ private readonly clients;
25
+ private readonly pubkeys;
26
+ private readonly pollIntervalMs;
27
+ private readonly timeoutMs;
28
+ readonly simulate: NetworkSimulations;
29
+ constructor(config: FiberNetworkConfig);
30
+ node(alias: string): FiberTestClient;
31
+ /** Waits until every configured node's RPC is reachable. Does not start any process. */
32
+ start(): Promise<void>;
33
+ pubkeyOf(alias: string): Promise<HexString>;
34
+ /** Connects `fromAlias` to `toAlias` by pubkey/address, unless already peered. */
35
+ connect(fromAlias: string, toAlias: string): Promise<void>;
36
+ /** Connects (if needed), opens a channel, and waits until it's `ChannelReady` on the funder's side. */
37
+ openChannel(fromAlias: string, toAlias: string, capacityCkb: number): Promise<HexString>;
38
+ /** Creates an invoice on `toAlias` and pays it from `fromAlias`. */
39
+ pay(fromAlias: string, toAlias: string, amountCkb: number, opts?: {
40
+ description?: string;
41
+ }): Promise<PaymentResult>;
42
+ }
43
+ /** Named failure-scenario helpers, built from primitives already in `core`. */
44
+ declare class NetworkSimulations {
45
+ private readonly network;
46
+ constructor(network: FiberNetwork);
47
+ /** Drains `channelId`'s outbound capacity from `fromAlias`, then overpays past it. */
48
+ insufficientLiquidity(fromAlias: string, toAlias: string, channelId: HexString): Promise<SimulationResult>;
49
+ /** Pays a node with no route to it (never connected, no channel path). */
50
+ unreachablePeer(fromAlias: string, targetAlias: string, amountCkb: number): Promise<SimulationResult>;
51
+ /** Creates a 1-second-expiry invoice, waits it out, then tries to pay it. */
52
+ expiredInvoice(fromAlias: string, toAlias: string, amountCkb: number): Promise<SimulationResult>;
53
+ private attempt;
54
+ }
55
+ export {};
@@ -0,0 +1,7 @@
1
+ export interface PollOptions {
2
+ intervalMs?: number;
3
+ timeoutMs?: number;
4
+ }
5
+ export declare function sleep(ms: number): Promise<void>;
6
+ /** Polls `fetch()` until `isResolved()` accepts a value, or throws once `timeoutMs` elapses. */
7
+ export declare function pollUntilResolved<T>(fetchValue: () => Promise<T>, isResolved: (value: T) => boolean, options?: PollOptions): Promise<T>;
@@ -0,0 +1,10 @@
1
+ import type { Diagnosis } from "@fiber-dev-kit/core";
2
+ import type { RouteConfidenceReport } from "./test-client";
3
+ import type { SimulationResult } from "./network";
4
+ export interface TerminalFormatOptions {
5
+ color?: boolean;
6
+ width?: number;
7
+ }
8
+ export declare function formatRouteConfidenceReport(report: RouteConfidenceReport, options?: TerminalFormatOptions): string;
9
+ export declare function formatSimulationResult(title: string, result: SimulationResult, options?: TerminalFormatOptions): string;
10
+ export declare function formatDiagnosis(diagnosis: Diagnosis, options?: TerminalFormatOptions): string;
@@ -0,0 +1,64 @@
1
+ import { FiberClient, FiberEventClient } from "@fiber-dev-kit/core";
2
+ import type { Diagnosis, DiagnosisCode, HexString, NetworkMode, PaymentResult } from "@fiber-dev-kit/core";
3
+ export interface TestClientConfig {
4
+ nodeUrl: string;
5
+ /**
6
+ * Mainnet is refused outright at runtime (throws in the constructor) — this client sends
7
+ * real test/keysend payments and drains channels. Typed as the full `NetworkMode` (rather
8
+ * than excluding `"mainnet"`) so that check isn't silently optimized away as dead code by
9
+ * callers who do have `"mainnet"` in a variable's inferred type.
10
+ */
11
+ network?: NetworkMode;
12
+ /** Default poll interval for `waitForPayment()`. Default: 500ms. */
13
+ pollIntervalMs?: number;
14
+ /** Default timeout for `waitForPayment()`. Default: 10000ms. */
15
+ timeoutMs?: number;
16
+ }
17
+ export type PayParams = {
18
+ invoice: string;
19
+ amount?: never;
20
+ } | {
21
+ to: HexString;
22
+ amount: number;
23
+ };
24
+ export interface RouteConfidenceReport {
25
+ canPay: boolean;
26
+ score: number;
27
+ level: "high" | "medium" | "low";
28
+ reasons: string[];
29
+ suggestions: string[];
30
+ dryRunPayment: PaymentResult | null;
31
+ diagnosis: Diagnosis | null;
32
+ }
33
+ /**
34
+ * Single-node test API. Built entirely on `@fiber-dev-kit/core` — no RPC calls of its own.
35
+ * Assumes the node at `nodeUrl` is already running; this package never spawns or manages
36
+ * `fnn` processes (that's the CLI's job).
37
+ */
38
+ export declare class FiberTestClient {
39
+ readonly rpc: FiberClient;
40
+ readonly events: FiberEventClient;
41
+ private readonly pollIntervalMs;
42
+ private readonly timeoutMs;
43
+ constructor(config: TestClientConfig);
44
+ /** Pays an invoice, or keysends directly to a pubkey with an explicit amount. */
45
+ pay(params: PayParams): Promise<PaymentResult>;
46
+ /** Dry-runs a payment to check routability/fees without sending. Returns `null` if it wouldn't succeed. */
47
+ canPay(params: PayParams): Promise<PaymentResult | null>;
48
+ /**
49
+ * Produces a small route-confidence report from local health signals plus FNN's dry-run
50
+ * payment result. It is a practical preflight check, not a full network graph score.
51
+ */
52
+ routeConfidence(params: PayParams): Promise<RouteConfidenceReport>;
53
+ waitForPayment(paymentHash: HexString): Promise<PaymentResult>;
54
+ assertPaid(paymentHash: HexString): Promise<PaymentResult>;
55
+ assertFailed(paymentHash: HexString): Promise<PaymentResult>;
56
+ /** Asserts a payment failed for a specific, structured reason (see `diagnose()` in core). */
57
+ assertError(paymentHash: HexString, expectedCode: DiagnosisCode): Promise<Diagnosis>;
58
+ getChannelBalance(channelId: HexString): Promise<{
59
+ local: number;
60
+ remote: number;
61
+ }>;
62
+ /** Pays `to` repeatedly until this channel's outbound capacity is exhausted (minus 1 CKB for fees). */
63
+ drainChannel(channelId: HexString, to: HexString): Promise<void>;
64
+ }
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "@fiber-dev-kit/test-client",
3
+ "version": "0.1.0-rc.0",
4
+ "description": "Programmatic TypeScript test API for Fiber payment and channel flows",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+ssh://git@github.com/scisamir/fiber-dev-kit.git",
10
+ "directory": "test-client"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/scisamir/fiber-dev-kit/issues"
14
+ },
15
+ "homepage": "https://github.com/scisamir/fiber-dev-kit#readme",
16
+ "keywords": [
17
+ "ckb",
18
+ "fiber-network",
19
+ "nervos",
20
+ "payments",
21
+ "testing"
22
+ ],
23
+ "publishConfig": {
24
+ "access": "public",
25
+ "tag": "rc"
26
+ },
27
+ "main": "./dist/index.js",
28
+ "types": "./dist/index.d.ts",
29
+ "exports": {
30
+ ".": {
31
+ "types": "./dist/index.d.ts",
32
+ "import": "./dist/index.js"
33
+ }
34
+ },
35
+ "files": [
36
+ "dist",
37
+ "CONTRIBUTION.md"
38
+ ],
39
+ "scripts": {
40
+ "build": "tsup src/index.ts --format esm --clean && tsc --emitDeclarationOnly --declaration",
41
+ "dev": "tsup src/index.ts --format esm --watch",
42
+ "typecheck": "tsc --noEmit",
43
+ "test": "vitest run"
44
+ },
45
+ "dependencies": {
46
+ "@fiber-dev-kit/core": "0.1.0-rc.0",
47
+ "chalk": "^5.6.2"
48
+ },
49
+ "devDependencies": {
50
+ "@types/node": "^26.1.1",
51
+ "tsup": "^8.0.1",
52
+ "typescript": "^5.4.0",
53
+ "vitest": "^1.4.0"
54
+ },
55
+ "engines": {
56
+ "node": ">=18.0.0"
57
+ }
58
+ }