Tests. Autocomplete. Few Fixes. Mocks for Electrum Service. Template-to-Json parser. Fix global paths. Use IO Dependency injection for logging from cli. Additional commands in CLI.

This commit is contained in:
2026-04-20 10:30:38 +00:00
parent df4f438f6d
commit ff2fe126c6
44 changed files with 8220 additions and 1503 deletions

162
tests/cli/mocks/command.ts Normal file
View File

@@ -0,0 +1,162 @@
import { vi, expect, type Mock } from "vitest";
import type {
BaseCommandDependencies,
CommandDependencies,
CommandIO,
CommandPaths,
} from "../../../src/cli/commands/types";
import type { AppService } from "../../../src/services/app";
/**
* Captured CLI IO buffers used by tests.
*/
export type MockIOCapture = {
out: string[];
err: string[];
verbose: string[];
};
/**
* Spy functions for each IO channel.
*/
export type MockIOSpies = {
out: Mock;
err: Mock;
verbose: Mock;
};
/**
* Complete mock IO result including the IO adapter, capture buffers, and spies.
*/
export type MockIO = {
io: CommandIO;
capture: MockIOCapture;
spies: MockIOSpies;
};
/**
* Defines an expected log message for assertion.
* At least one of out, err, or verbose should be specified.
*/
export type LogExpectation = {
/** Expected substring (or exact match if exact=true) in io.out */
out?: string;
/** Expected substring (or exact match if exact=true) in io.err */
err?: string;
/** Expected substring (or exact match if exact=true) in io.verbose */
verbose?: string;
/** If true, match the string exactly instead of using contains (default: false) */
exact?: boolean;
};
/**
* Creates a command IO adapter that records every message using vi.fn() spies.
* This enables vitest's built-in matchers like toHaveBeenCalledWith.
*/
export const createMockIO = (): MockIO => {
const capture: MockIOCapture = {
out: [],
err: [],
verbose: [],
};
const outSpy = vi.fn((message: string) => {
capture.out.push(message);
});
const errSpy = vi.fn((message: string) => {
capture.err.push(message);
});
const verboseSpy = vi.fn((message: string) => {
capture.verbose.push(message);
});
const io: CommandIO = {
out: outSpy,
err: errSpy,
verbose: verboseSpy,
};
return {
io,
capture,
spies: {
out: outSpy,
err: errSpy,
verbose: verboseSpy,
},
};
};
/**
* Asserts that the expected log messages were printed to the appropriate IO channels.
* @param spies - The mock IO spies from createMockIO
* @param logs - Array of log expectations to validate
*/
export const expectLogs = (spies: MockIOSpies, logs: LogExpectation[]): void => {
for (const log of logs) {
if (log.out !== undefined) {
if (log.exact) {
expect(spies.out).toHaveBeenCalledWith(log.out);
} else {
expect(spies.out).toHaveBeenCalledWith(expect.stringContaining(log.out));
}
}
if (log.err !== undefined) {
if (log.exact) {
expect(spies.err).toHaveBeenCalledWith(log.err);
} else {
expect(spies.err).toHaveBeenCalledWith(expect.stringContaining(log.err));
}
}
if (log.verbose !== undefined) {
if (log.exact) {
expect(spies.verbose).toHaveBeenCalledWith(log.verbose);
} else {
expect(spies.verbose).toHaveBeenCalledWith(expect.stringContaining(log.verbose));
}
}
}
};
/**
* Creates mock paths for testing.
* @param tempDir - Optional temp directory to use as base for all paths
*/
export const createMockPaths = (tempDir?: string): CommandPaths => {
const base = tempDir ?? "/tmp/xo-cli-test";
return {
mnemonicsDir: base,
dataDir: base,
walletConfigPath: `${base}/.wallet`,
workingDir: base,
};
};
/**
* Creates base command dependencies for commands that do not require the app.
* @param io - Command IO adapter
* @param paths - Optional custom paths (defaults to mock paths)
*/
export const createBaseCommandDeps = (
io: CommandIO,
paths?: CommandPaths,
): BaseCommandDependencies => ({
io,
paths: paths ?? createMockPaths(),
});
/**
* Creates command dependencies for app-backed command handlers.
* @param app - App service instance
* @param io - Command IO adapter
* @param paths - Optional custom paths (defaults to mock paths)
*/
export const createCommandDeps = (
app: AppService,
io: CommandIO,
paths?: CommandPaths,
): CommandDependencies => ({
app,
io,
paths: paths ?? createMockPaths(),
});

View File

@@ -0,0 +1,7 @@
export class MockElectrumService {
constructor() {}
async hasSeenTransaction(transactionHash: string): Promise<boolean> {
return true;
}
}

164
tests/cli/mocks/engine.ts Normal file
View File

@@ -0,0 +1,164 @@
import { BlockchainMonitor, Engine } from "@xo-cash/engine";
import { createStorageAdapter, State, StorageType, type UnspentOutputData } from "@xo-cash/state";
import { InMemoryBlockchainProvider } from "@xo-cash/engine";
import { convertMnemonicToSeedBytes } from "@xo-cash/crypto";
import { binToHex, sha256 } from "@bitauth/libauth";
import { AppService } from "../../../src/services/app";
import { InMemoryStorage } from "../../../src/services/storage";
import { MockElectrumService } from "./electrum-service";
export const DEFAULT_SEED = "page pencil stock planet limb cluster assault speak off joke private pioneer";
/**
* Options for creating a fake resource (UTXO) in tests.
*/
export type FakeResourceOptions = {
/** Transaction hash of the outpoint. Auto-generated if not provided. */
outpointTransactionHash?: string;
/** Index of the outpoint in the transaction. Defaults to 0. */
outpointIndex?: number;
/** Value in satoshis. Defaults to 10000. */
valueSatoshis?: number;
/** Template identifier. Defaults to "test-template". */
templateIdentifier?: string;
/** Output identifier from the template. Defaults to "receiveOutput". */
outputIdentifier?: string;
/** Locking bytecode for this output. Defaults to a placeholder. */
lockingBytecode?: string;
/** Block height where the UTXO was mined. Defaults to 800000. */
minedAtHeight?: number;
/** Invitation identifier that reserves this output. Undefined means unreserved. */
reservedBy?: string;
};
/**
* Generates a random 64-character hex string representing a transaction hash.
*/
export const randomTxHash = (): string => {
const bytes = new Uint8Array(32);
crypto.getRandomValues(bytes);
return Array.from(bytes).map(b => b.toString(16).padStart(2, "0")).join("");
};
/**
* Adds a fake resource (UTXO) to the engine's state for testing purposes.
* @param engine - The engine instance to add the resource to.
* @param options - Options for the fake resource. All fields have sensible defaults.
* @returns The created UnspentOutputData object.
*/
export const addFakeResource = async (
engine: Engine,
options: FakeResourceOptions = {},
): Promise<UnspentOutputData> => {
const resource: UnspentOutputData = {
status: "confirmed",
selectable: true,
privacy: false,
templateIdentifier: options.templateIdentifier ?? "test-template",
outputIdentifier: options.outputIdentifier ?? "receiveOutput",
outpointIndex: options.outpointIndex ?? 0,
outpointTransactionHash: options.outpointTransactionHash ?? randomTxHash(),
minedAtHeight: options.minedAtHeight ?? 800000,
valueSatoshis: options.valueSatoshis ?? 10000,
lockingBytecode: options.lockingBytecode ?? "76a914000000000000000000000000000000000000000088ac",
reservedBy: options.reservedBy,
};
await engine.state.storeUnspentOutputData(resource);
return resource;
};
/**
* Reserves a resource for a specific invitation.
* @param engine - The engine instance.
* @param outpointTransactionHash - The transaction hash of the UTXO to reserve.
* @param outpointIndex - The output index of the UTXO to reserve.
* @param invitationIdentifier - The invitation identifier to reserve for.
*/
export const reserveResource = async (
engine: Engine,
outpointTransactionHash: string,
outpointIndex: number,
invitationIdentifier: string,
): Promise<void> => {
await engine.state.executeBulkUnspentOutputReservation(
[{ outpointTransactionHash, outpointIndex }],
true,
invitationIdentifier,
);
};
/**
* Unreserves a resource from a specific invitation.
* @param engine - The engine instance.
* @param outpointTransactionHash - The transaction hash of the UTXO to unreserve.
* @param outpointIndex - The output index of the UTXO to unreserve.
* @param invitationIdentifier - The invitation identifier to unreserve from.
*/
export const unreserveResource = async (
engine: Engine,
outpointTransactionHash: string,
outpointIndex: number,
invitationIdentifier: string,
): Promise<void> => {
await engine.state.executeBulkUnspentOutputReservation(
[{ outpointTransactionHash, outpointIndex }],
false,
invitationIdentifier,
);
};
/**
* Create a mock engine instance with a given seed. Uses the in-memory storage and blockchain provider.
* @param seed - The seed to use for the engine.
* @returns A mock engine instance.
*/
export const createMockEngine = async (seed: string) => {
// Create the in-memory storage adapter.
const storage = await createStorageAdapter({
storageType: StorageType.INMEMORY,
accountHash: binToHex(sha256.hash(convertMnemonicToSeedBytes(seed))),
});
// Initialize the storage adapter.
await storage.initialize();
// Create the state instance.
const state = new State(storage);
// Create the in-memory blockchain provider.
const blockchainProvider = new InMemoryBlockchainProvider();
await blockchainProvider.initialize({
applicationIdentifier: "xo-cli-tests",
electrumOptions: {},
});
// Create the blockchain monitor instance.
const blockchainMonitor = new BlockchainMonitor(state, blockchainProvider);
await blockchainMonitor.initializeEventListeners();
// Create the engine instance.
const engine = new Engine(seed, state, blockchainMonitor, blockchainProvider);
await engine.initializeStateSync();
return engine;
};
export const createMockAppService = async (engine: Engine) => {
const storage = await InMemoryStorage.create();
const electrum = new MockElectrumService();
const config = {
syncServerUrl: "http://localhost:3000",
engineConfig: {
databasePath: "test-data",
databaseFilename: "xo-wallet.db",
},
invitationStoragePath: "test-invitations.db",
};
return new AppService(engine, storage, config, electrum);
};

File diff suppressed because it is too large Load Diff