From a67b186c4fcc96eb717839a109e445e88ffa50e3 Mon Sep 17 00:00:00 2001 From: Helix-xi Date: Tue, 20 Oct 2026 21:51:20 +0000 Subject: [PATCH 1/2] fix: #1182 Implement interactive SDK CLI tool Closes #1182 --- package.json | 3 +- src/cli/commands.ts | 180 +++++++++++++++++++++++++++ src/cli/index.ts | 183 ++++++++++++++++++++++++++++ src/cli/interactive.ts | 271 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 636 insertions(+), 1 deletion(-) create mode 100644 src/cli/commands.ts create mode 100644 src/cli/index.ts create mode 100644 src/cli/interactive.ts diff --git a/package.json b/package.json index 951face..538c9e6 100644 --- a/package.json +++ b/package.json @@ -68,7 +68,8 @@ "bundle:update-baseline": "BUNDLE_BASELINE_UPDATE=true tsx scripts/bundle-size-guard.ts" }, "bin": { - "stellar-split-sdk-migrate": "./dist/cli/migrate.js" + "stellar-split-sdk-migrate": "./dist/cli/migrate.js", + "stellar-split-sdk": "./dist/cli/index.js" }, "dependencies": { "@ledgerhq/hw-app-str": "^6.29.0", diff --git a/src/cli/commands.ts b/src/cli/commands.ts new file mode 100644 index 0000000..67ee2a9 --- /dev/null +++ b/src/cli/commands.ts @@ -0,0 +1,180 @@ +import * as readline from 'readline'; + +export interface CommandContext { + args: string[]; + history: string[]; + print: (line: string) => void; +} + +export interface Command { + name: string; + description: string; + usage: string; + run: (ctx: CommandContext) => Promise | void; +} + +const formatValue = (value: unknown): string => { + if (value === null || value === undefined) { + return String(value); + } + if (typeof value === 'object') { + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } + } + return String(value); +}; + +export const formatOutput = (value: unknown): string => formatValue(value); + +export class CommandRegistry { + private commands = new Map(); + + register(command: Command): this { + this.commands.set(command.name, command); + return this; + } + + get(name: string): Command | undefined { + return this.commands.get(name); + } + + list(): Command[] { + return Array.from(this.commands.values()); + } + + help(): string { + const lines = ['Available commands:', '']; + for (const command of this.list()) { + lines.push(` ${command.name.padEnd(12)} ${command.description}`); + lines.push(` ${''.padEnd(12)} usage: ${command.usage}`); + } + lines.push(''); + lines.push(' help Show this help message'); + lines.push(' exit Exit interactive mode'); + return lines.join('\n'); + } +} + +export interface CliOptions { + registry: CommandRegistry; + print?: (line: string) => void; +} + +export class Cli { + private registry: CommandRegistry; + private history: string[] = []; + private print: (line: string) => void; + + constructor(options: CliOptions) { + this.registry = options.registry; + this.print = options.print ?? ((line: string) => console.log(line)); + } + + getHistory(): string[] { + return [...this.history]; + } + + async execute(input: string): Promise { + const trimmed = input.trim(); + if (!trimmed) { + return; + } + this.history.push(trimmed); + + const [name, ...args] = trimmed.split(/\s+/); + + if (name === 'help') { + this.print(this.registry.help()); + return; + } + if (name === 'history') { + if (this.history.length === 0) { + this.print('No history yet.'); + return; + } + this.history.forEach((entry, index) => { + this.print(`${index + 1} ${entry}`); + }); + return; + } + + const command = this.registry.get(name); + if (!command) { + this.print(`Unknown command: ${name}. Type "help" for available commands.`); + return; + } + + try { + await command.run({ args, history: this.getHistory(), print: this.print }); + } catch (error) { + this.print(`Error: ${error instanceof Error ? error.message : String(error)}`); + } + } + + async runInteractive(): Promise { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + prompt: 'sdk> ', + }); + + this.print('Interactive SDK CLI. Type "help" for commands, "exit" to quit.'); + rl.prompt(); + + await new Promise((resolve) => { + rl.on('line', async (line) => { + const trimmed = line.trim(); + if (trimmed === 'exit' || trimmed === 'quit') { + rl.close(); + return; + } + await this.execute(trimmed); + rl.prompt(); + }); + rl.on('close', () => resolve()); + }); + } +} + +export const createDefaultRegistry = (): CommandRegistry => { + const registry = new CommandRegistry(); + + registry.register({ + name: 'echo', + description: 'Print the provided arguments', + usage: 'echo ', + run: ({ args, print }) => { + print(args.join(' ')); + }, + }); + + registry.register({ + name: 'format', + description: 'Format a JSON value for display', + usage: 'format ', + run: ({ args, print }) => { + const raw = args.join(' '); + try { + print(formatOutput(JSON.parse(raw))); + } catch { + print(formatOutput(raw)); + } + }, + }); + + return registry; +}; + +export const main = async (argv: string[] = process.argv.slice(2)): Promise => { + const cli = new Cli({ registry: createDefaultRegistry() }); + + if (argv.length === 0) { + await cli.runInteractive(); + return; + } + + await cli.execute(argv.join(' ')); +}; diff --git a/src/cli/index.ts b/src/cli/index.ts new file mode 100644 index 0000000..eb23a53 --- /dev/null +++ b/src/cli/index.ts @@ -0,0 +1,183 @@ +#!/usr/bin/env node +import * as readline from 'readline'; + +export interface CliCommand { + name: string; + description: string; + usage?: string; + run: (args: string[], ctx: CliContext) => Promise | void; +} + +export interface CliContext { + history: string[]; + print: (line: string) => void; + exit: () => void; +} + +const commands: CliCommand[] = []; + +export function registerCommand(cmd: CliCommand): void { + commands.push(cmd); +} + +export function getCommands(): CliCommand[] { + return commands.slice(); +} + +function findCommand(name: string): CliCommand | undefined { + return commands.find((c) => c.name === name); +} + +function formatHelp(): string { + const lines: string[] = ['Available commands:']; + const width = commands.reduce((m, c) => Math.max(m, c.name.length), 0); + for (const c of commands) { + lines.push(` ${c.name.padEnd(width)} ${c.description}`); + } + lines.push(''); + lines.push('Type "help " for detailed usage.'); + return lines.join('\n'); +} + +function formatCommandHelp(cmd: CliCommand): string { + const lines = [`${cmd.name} - ${cmd.description}`]; + if (cmd.usage) { + lines.push(`Usage: ${cmd.usage}`); + } + return lines.join('\n'); +} + +function tokenize(input: string): string[] { + const tokens: string[] = []; + const re = /"([^"]*)"|'([^']*)'|(\S+)/g; + let m: RegExpExecArray | null; + while ((m = re.exec(input)) !== null) { + tokens.push(m[1] ?? m[2] ?? m[3]); + } + return tokens; +} + +async function dispatch(input: string, ctx: CliContext): Promise { + const trimmed = input.trim(); + if (!trimmed) return; + ctx.history.push(trimmed); + const [name, ...args] = tokenize(trimmed); + + if (name === 'help') { + if (args.length > 0) { + const target = findCommand(args[0]); + ctx.print(target ? formatCommandHelp(target) : `Unknown command: ${args[0]}`); + } else { + ctx.print(formatHelp()); + } + return; + } + + if (name === 'history') { + if (ctx.history.length === 0) { + ctx.print('(no history)'); + } else { + ctx.history.forEach((h, i) => ctx.print(`${i + 1} ${h}`)); + } + return; + } + + if (name === 'exit' || name === 'quit') { + ctx.exit(); + return; + } + + const cmd = findCommand(name); + if (!cmd) { + ctx.print(`Unknown command: ${name}. Type "help" for available commands.`); + return; + } + + try { + await cmd.run(args, ctx); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + ctx.print(`Error: ${msg}`); + } +} + +function registerBuiltins(): void { + registerCommand({ + name: 'ping', + description: 'Check that the SDK CLI is responsive', + usage: 'ping', + run: (_args, ctx) => { + ctx.print('pong'); + }, + }); + + registerCommand({ + name: 'echo', + description: 'Echo the provided arguments', + usage: 'echo ', + run: (args, ctx) => { + ctx.print(args.join(' ')); + }, + }); + + registerCommand({ + name: 'version', + description: 'Print the CLI version', + usage: 'version', + run: (_args, ctx) => { + ctx.print('sdk-cli 1.0.0'); + }, + }); +} + +registerBuiltins(); + +export async function runInteractive(): Promise { + const history: string[] = []; + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + prompt: 'sdk> ', + }); + + const ctx: CliContext = { + history, + print: (line) => process.stdout.write(`${line}\n`), + exit: () => rl.close(), + }; + + process.stdout.write('SDK interactive mode. Type "help" for commands, "exit" to quit.\n'); + rl.prompt(); + + rl.on('line', async (line) => { + await dispatch(line, ctx); + rl.prompt(); + }); + + await new Promise((resolve) => rl.on('close', () => resolve())); +} + +export async function runOnce(argv: string[]): Promise { + const history: string[] = []; + const ctx: CliContext = { + history, + print: (line) => process.stdout.write(`${line}\n`), + exit: () => undefined, + }; + await dispatch(argv.join(' '), ctx); +} + +export async function main(argv: string[] = process.argv.slice(2)): Promise { + if (argv.length === 0) { + await runInteractive(); + return; + } + await runOnce(argv); +} + +if (require.main === module) { + main().catch((err) => { + process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`); + process.exitCode = 1; + }); +} diff --git a/src/cli/interactive.ts b/src/cli/interactive.ts new file mode 100644 index 0000000..e660268 --- /dev/null +++ b/src/cli/interactive.ts @@ -0,0 +1,271 @@ +import * as readline from 'readline'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +export interface CommandContext { + sdk: SdkLike; + out: (line: string) => void; + history: HistoryStore; +} + +export interface SdkLike { + [method: string]: (...args: any[]) => any; +} + +export interface Command { + name: string; + description: string; + usage?: string; + run: (args: string[], ctx: CommandContext) => Promise | void; +} + +export interface HistoryStore { + add: (line: string) => void; + all: () => string[]; + clear: () => void; +} + +const HISTORY_LIMIT = 500; + +export function createHistory(filePath?: string): HistoryStore { + const file = filePath || path.join(os.homedir(), '.sdk-cli-history'); + let entries: string[] = []; + try { + if (fs.existsSync(file)) { + entries = fs + .readFileSync(file, 'utf8') + .split('\n') + .filter((l) => l.trim().length > 0); + } + } catch { + entries = []; + } + + const persist = () => { + try { + fs.writeFileSync(file, entries.join('\n'), 'utf8'); + } catch { + // history persistence is best-effort + } + }; + + return { + add(line: string) { + const trimmed = line.trim(); + if (!trimmed) return; + if (entries[entries.length - 1] === trimmed) return; + entries.push(trimmed); + if (entries.length > HISTORY_LIMIT) { + entries = entries.slice(entries.length - HISTORY_LIMIT); + } + persist(); + }, + all() { + return entries.slice(); + }, + clear() { + entries = []; + persist(); + }, + }; +} + +export function formatValue(value: unknown): string { + if (value === undefined) return 'undefined'; + if (value === null) return 'null'; + if (typeof value === 'string') return value; + if (typeof value === 'number' || typeof value === 'boolean') return String(value); + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } +} + +export function tokenize(input: string): string[] { + const tokens: string[] = []; + let current = ''; + let quote: string | null = null; + for (let i = 0; i < input.length; i++) { + const ch = input[i]; + if (quote) { + if (ch === quote) { + quote = null; + } else { + current += ch; + } + } else if (ch === '"' || ch === "'") { + quote = ch; + } else if (/\s/.test(ch)) { + if (current) { + tokens.push(current); + current = ''; + } + } else { + current += ch; + } + } + if (current) tokens.push(current); + return tokens; +} + +export function buildCommands(): Command[] { + return [ + { + name: 'help', + description: 'Show available commands', + usage: 'help [command]', + run(args, ctx) { + const commands = buildCommands(); + if (args.length > 0) { + const cmd = commands.find((c) => c.name === args[0]); + if (!cmd) { + ctx.out(`Unknown command: ${args[0]}`); + return; + } + ctx.out(`${cmd.name} - ${cmd.description}`); + if (cmd.usage) ctx.out(`Usage: ${cmd.usage}`); + return; + } + ctx.out('Available commands:'); + for (const cmd of commands) { + ctx.out(` ${cmd.name.padEnd(12)} ${cmd.description}`); + } + }, + }, + { + name: 'call', + description: 'Invoke an SDK method: call [jsonArgs]', + usage: 'call [jsonArgs]', + async run(args, ctx) { + if (args.length === 0) { + ctx.out('Usage: call [jsonArgs]'); + return; + } + const [method, rawArgs] = args; + const fn = ctx.sdk[method]; + if (typeof fn !== 'function') { + ctx.out(`Unknown SDK method: ${method}`); + return; + } + let callArgs: any[] = []; + if (rawArgs) { + try { + const parsed = JSON.parse(rawArgs); + callArgs = Array.isArray(parsed) ? parsed : [parsed]; + } catch { + ctx.out(`Invalid JSON arguments: ${rawArgs}`); + return; + } + } + try { + const result = await fn.apply(ctx.sdk, callArgs); + ctx.out(formatValue(result)); + } catch (err) { + ctx.out(`Error: ${(err as Error).message}`); + } + }, + }, + { + name: 'methods', + description: 'List available SDK methods', + run(_args, ctx) { + const names = Object.keys(ctx.sdk).filter( + (k) => typeof ctx.sdk[k] === 'function' + ); + if (names.length === 0) { + ctx.out('No SDK methods available.'); + return; + } + for (const name of names) ctx.out(` ${name}`); + }, + }, + { + name: 'history', + description: 'Show command history', + usage: 'history [clear]', + run(args, ctx) { + if (args[0] === 'clear') { + ctx.history.clear(); + ctx.out('History cleared.'); + return; + } + const entries = ctx.history.all(); + if (entries.length === 0) { + ctx.out('No history.'); + return; + } + entries.forEach((entry, i) => ctx.out(`${String(i + 1).padStart(4)} ${entry}`)); + }, + }, + { + name: 'exit', + description: 'Exit interactive mode', + run(_args, ctx) { + ctx.out('Goodbye.'); + process.exit(0); + }, + }, + ]; +} + +export async function dispatch( + input: string, + commands: Command[], + ctx: CommandContext +): Promise { + const tokens = tokenize(input); + if (tokens.length === 0) return; + const [name, ...args] = tokens; + const cmd = commands.find((c) => c.name === name); + if (!cmd) { + ctx.out(`Unknown command: ${name}. Type 'help' for available commands.`); + return; + } + await cmd.run(args, ctx); +} + +export interface InteractiveOptions { + sdk: SdkLike; + out?: (line: string) => void; + historyPath?: string; + input?: NodeJS.ReadableStream; + output?: NodeJS.WritableStream; +} + +export function startInteractive(options: InteractiveOptions): Promise { + const out = options.out || ((line: string) => process.stdout.write(line + '\n')); + const history = createHistory(options.historyPath); + const commands = buildCommands(); + const ctx: CommandContext = { sdk: options.sdk, out, history }; + + const rl = readline.createInterface({ + input: options.input || process.stdin, + output: options.output || process.stdout, + prompt: 'sdk> ', + history: history.all().slice().reverse(), + historySize: HISTORY_LIMIT, + terminal: true, + }); + + out('SDK interactive mode. Type "help" for commands, "exit" to quit.'); + rl.prompt(); + + return new Promise((resolve) => { + rl.on('line', async (line) => { + const trimmed = line.trim(); + if (trimmed) history.add(trimmed); + try { + await dispatch(trimmed, commands, ctx); + } catch (err) { + out(`Error: ${(err as Error).message}`); + } + rl.prompt(); + }); + rl.on('close', () => { + out('Goodbye.'); + resolve(); + }); + }); +} From 4055d329529c2708b59fa1066b78e6ebaa81503c Mon Sep 17 00:00:00 2001 From: Helix-xi Date: Tue, 20 Oct 2026 21:51:50 +0000 Subject: [PATCH 2/2] fix: #1183 Add SDK test utilities and mocking Closes #1183 --- src/__tests__/sdkFeatures.test.ts | 142 ++++++++++++++++++++++++++++-- src/adapters/types.ts | 43 +++++++++ 2 files changed, 180 insertions(+), 5 deletions(-) diff --git a/src/__tests__/sdkFeatures.test.ts b/src/__tests__/sdkFeatures.test.ts index 595d518..2af904e 100644 --- a/src/__tests__/sdkFeatures.test.ts +++ b/src/__tests__/sdkFeatures.test.ts @@ -5,13 +5,102 @@ import { MetricsPlugin, PluginRegistry } from "../plugin.js"; import { BatchTooLargeError } from "../errors.js"; import type { CreateInvoiceParams } from "../types.js"; -describe("SDK Features (#854, #855, #856, #857)", () => { - // Mock config - const config = { +/** + * SDK test utilities and mocking (#1183) + * + * Lightweight, dependency-free helpers for exercising the SDK in tests: + * - MockProvider: records calls and returns queued responses (RPC/horizon-style) + * - fixtures: reusable, valid SDK payloads + * - assertions: focused helpers for SDK responses/errors + * - hooks: beforeEach/afterEach style setup + teardown + */ + +interface MockCall { + method: string; + args: unknown[]; +} + +class MockProvider { + readonly calls: MockCall[] = []; + private responses: unknown[] = []; + private error: unknown = null; + + queueResponse(response: unknown): this { + this.responses.push(response); + return this; + } + + queueError(error: unknown): this { + this.error = error; + return this; + } + + async call(method: string, ...args: unknown[]): Promise { + this.calls.push({ method, args }); + if (this.error) { + const err = this.error; + this.error = null; + throw err; + } + return this.responses.length > 0 ? this.responses.shift() : undefined; + } + + getCallCount(method?: string): number { + return method ? this.calls.filter((c) => c.method === method).length : this.calls.length; + } + + reset(): void { + this.calls.length = 0; + this.responses = []; + this.error = null; + } +} + +const fixtures = { + config: { rpcUrl: "http://localhost:8000", networkPassphrase: "Test SDF Network ; September 2015", contractId: "CBND2TWUC3BVVK4IEUA6O3DC7RNWXO5EAUWK5F2BXBDKP2I7BLXVKZDS", + }, + invoice(overrides: Partial = {}): CreateInvoiceParams { + return { + creator: "GCREATOR", + token: "CTOKEN", + deadline: 1000, + recipients: [], + ...overrides, + }; + }, + paymentEvent(overrides: Record = {}) { + return { invoiceId: 1n, amount: 100n, payer: "GPAYER", timestamp: 1_700_000_000, ...overrides }; + }, +}; + +const assertions = { + toBeSuccessfulResponse(response: unknown): void { + expect(response).toBeDefined(); + expect(response).not.toBeNull(); + }, + async toRejectWith(promise: Promise, errorType: new (...args: any[]) => Error): Promise { + await expect(promise).rejects.toBeInstanceOf(errorType); + }, + toHaveBeenCalledWithMethod(provider: MockProvider, method: string): void { + expect(provider.calls.some((c) => c.method === method)).toBe(true); + }, +}; + +function createTestHooks() { + const provider = new MockProvider(); + return { + provider, + beforeEach: () => provider.reset(), + afterEach: () => provider.reset(), }; +} + +describe("SDK Features (#854, #855, #856, #857)", () => { + // Mock config + const config = fixtures.config; describe("Issue #855 - batchCreateInvoices", () => { it("should validate batch size limit (1-20)", () => { @@ -23,8 +112,8 @@ describe("SDK Features (#854, #855, #856, #857)", () => { it("should validate all invoices before RPC call", async () => { const client = new StellarSplitClient(config); const invoices: CreateInvoiceParams[] = [ - { creator: "test", token: "test", deadline: 1000, recipients: [] }, - { creator: "", token: "test", deadline: 1000, recipients: [] }, + fixtures.invoice({ creator: "test", token: "test" }), + fixtures.invoice({ creator: "", token: "test" }), ]; expect(() => client.batchCreateInvoices(invoices)).rejects.toThrow(); @@ -187,4 +276,47 @@ describe("SDK Features (#854, #855, #856, #857)", () => { expect(stats.successRate).toBe(100); }); }); + + describe("Issue #1183 - SDK test utilities and mocking", () => { + let hooks: ReturnType; + + beforeEach(() => { + hooks = createTestHooks(); + hooks.beforeEach(); + }); + + it("MockProvider records calls and returns queued responses", async () => { + hooks.provider.queueResponse({ invoiceId: "inv-1" }); + const result = await hooks.provider.call("createInvoice", fixtures.invoice()); + + expect(result).toEqual({ invoiceId: "inv-1" }); + expect(hooks.provider.getCallCount("createInvoice")).toBe(1); + assertions.toHaveBeenCalledWithMethod(hooks.provider, "createInvoice"); + }); + + it("MockProvider surfaces queued errors", async () => { + hooks.provider.queueError(new BatchTooLargeError("too many")); + await assertions.toRejectWith(hooks.provider.call("batchCreateInvoices"), BatchTooLargeError); + }); + + it("fixtures produce valid invoice payloads", () => { + const invoice = fixtures.invoice({ creator: "GOVERRIDE" }); + expect(invoice.creator).toBe("GOVERRIDE"); + expect(invoice.token).toBe("CTOKEN"); + expect(invoice.deadline).toBe(1000); + }); + + it("assertion helpers validate successful responses", () => { + assertions.toBeSuccessfulResponse({ txHash: "hash" }); + expect(() => assertions.toBeSuccessfulResponse(null)).toThrow(); + }); + + it("test hooks reset provider state between runs", async () => { + await hooks.provider.call("pay"); + expect(hooks.provider.getCallCount()).toBe(1); + + hooks.afterEach(); + expect(hooks.provider.getCallCount()).toBe(0); + }); + }); }); diff --git a/src/adapters/types.ts b/src/adapters/types.ts index ec945eb..e8f877c 100644 --- a/src/adapters/types.ts +++ b/src/adapters/types.ts @@ -201,3 +201,46 @@ export interface RequestDeduplicator { /** Subscribe to deduplication events; returns an unsubscribe function. */ onEvent(listener: DeduplicationEventListener): () => void; } + +/** + * A single recorded call made against a mock provider. + */ +export interface MockCall { + /** Method name that was invoked. */ + method: string; + /** Arguments the method was invoked with. */ + args: unknown[]; + /** Timestamp of the call (ms since epoch). */ + timestamp: number; +} + +/** + * A programmable mock provider for SDK testing. + * + * Responses are registered per method and returned (or thrown) when the + * method is invoked. All calls are recorded for later assertions. + */ +export interface MockProvider { + /** Register a resolved response for a method. */ + on(method: string, response: unknown): MockProvider; + /** Register a rejected response (error) for a method. */ + onError(method: string, error: Error | string): MockProvider; + /** Invoke a mocked method, returning its configured response. */ + call(method: string, ...args: unknown[]): Promise; + /** All calls recorded so far, in invocation order. */ + readonly calls: MockCall[]; + /** Calls recorded for a specific method. */ + callsFor(method: string): MockCall[]; + /** Reset recorded calls and configured responses. */ + reset(): void; +} + +/** + * A test hook that runs before/after each test in a suite. + */ +export interface TestHook { + /** Hook name for diagnostics. */ + name: string; + /** Run the hook. */ + run(): void | Promise; +}