Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,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",
Expand Down
99 changes: 94 additions & 5 deletions src/__tests__/sdkFeatures.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,102 @@ import {
inspectState,
} from "../debug.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<unknown> {
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> = {}): CreateInvoiceParams {
return {
creator: "GCREATOR",
token: "CTOKEN",
deadline: 1000,
recipients: [],
...overrides,
};
},
paymentEvent(overrides: Record<string, unknown> = {}) {
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<unknown>, errorType: new (...args: any[]) => Error): Promise<void> {
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)", async () => {
Expand Down Expand Up @@ -52,8 +141,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" }),
];

await expect(client.batchCreateInvoices(invoices)).rejects.toThrow();
Expand Down
180 changes: 180 additions & 0 deletions src/cli/commands.ts
Original file line number Diff line number Diff line change
@@ -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> | 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<string, Command>();

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<void> {
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<void> {
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<void>((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 <text...>',
run: ({ args, print }) => {
print(args.join(' '));
},
});

registry.register({
name: 'format',
description: 'Format a JSON value for display',
usage: 'format <json>',
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<void> => {
const cli = new Cli({ registry: createDefaultRegistry() });

if (argv.length === 0) {
await cli.runInteractive();
return;
}

await cli.execute(argv.join(' '));
};
Loading
Loading