diff --git a/README.md b/README.md index 0c6c675d..99c786f8 100644 --- a/README.md +++ b/README.md @@ -186,6 +186,120 @@ return { } ``` +## Testing with @acala-network/chopsticks-testing + +The `@acala-network/chopsticks-testing` package provides powerful utilities for testing blockchain data, making it easier to write and maintain tests for your Substrate-based chain. It offers features like data redaction, event filtering, snapshot testing, and XCM message checking. + +### Installation + +```bash +npm install --save-dev @acala-network/chopsticks-testing +``` + +### Basic Usage + +```typescript +import { withExpect, setupContext } from '@acala-network/chopsticks-testing'; +import { expect } from 'vitest'; // or jest, or other test runners + +// Create testing utilities with your test runner's expect function +const { check, checkEvents, checkSystemEvents, checkUmp, checkHrmp } = withExpect(expect); + +describe('My Chain Tests', () => { + it('should process events correctly', async () => { + const network = setupContext({ endpoint: 'wss://polkadot-rpc.dwellir.com' }); + // Check and redact system events + await checkSystemEvents(network) + .redact({ number: 2, hash: true }) + .toMatchSnapshot('system events'); + + // Filter specific events + await checkSystemEvents(network, 'balances', { section: 'system', method: 'ExtrinsicSuccess' }) + .toMatchSnapshot('filtered events'); + }); +}); +``` + +### Data Redaction + +The testing package provides powerful redaction capabilities to make your tests more stable and focused on what matters: + +```typescript +await check(someData) + .redact({ + number: 2, // Redact numbers with 2 decimal precision + hash: true, // Redact 32-byte hex values + hex: true, // Redact any hex values + address: true, // Redact base58 addresses + redactKeys: /hash/, // Redact values of keys matching regex + removeKeys: /time/ // Remove keys matching regex entirely + }) + .toMatchSnapshot('redacted data'); +``` + +### Event Filtering + +Filter and check specific blockchain events: + +```typescript +// Check all balances events +await checkSystemEvents(api, 'balances') + .toMatchSnapshot('balances events'); + +// Check specific event type +await checkSystemEvents(api, { section: 'system', method: 'ExtrinsicSuccess' }) + .toMatchSnapshot('successful extrinsics'); + +// Multiple filters +await checkSystemEvents(api, + 'balances', + { section: 'system', method: 'ExtrinsicSuccess' } +) +.toMatchSnapshot('filtered events'); +``` + +### XCM Testing + +Test XCM (Cross-Chain Message) functionality: + +```typescript +// Check UMP (Upward Message Passing) messages +await checkUmp(api) + .redact() + .toMatchSnapshot('upward messages'); + +// Check HRMP (Horizontal Relay-routed Message Passing) messages +await checkHrmp(api) + .redact() + .toMatchSnapshot('horizontal messages'); +``` + +### Data Format Conversion + +Convert data to different formats for testing: + +```typescript +// Convert to human-readable format +await check(data).toHuman().toMatchSnapshot('human readable'); + +// Convert to hex format +await check(data).toHex().toMatchSnapshot('hex format'); + +// Convert to JSON format (default) +await check(data).toJson().toMatchSnapshot('json format'); +``` + +### Custom Transformations + +Apply custom transformations to your data: + +```typescript +await check(data) + .map(value => value.filter(item => item.amount > 1000)) + .redact() + .toMatchSnapshot('filtered and redacted'); +``` + ## Testing big migrations When testing migrations with lots of keys, you may want to fetch and cache some storages. diff --git a/packages/testing/src/check.ts b/packages/testing/src/check.ts index d1c1aff1..577f5406 100644 --- a/packages/testing/src/check.ts +++ b/packages/testing/src/check.ts @@ -3,30 +3,70 @@ import { Codec } from '@polkadot/types/types' type CodecOrArray = Codec | Codec[] +/** + * Processes a Codec or array of Codecs with a given transformation function + * @param codec - Single Codec or array of Codecs to process + * @param fn - Transformation function to apply to each Codec + * @returns Processed value(s) + */ const processCodecOrArray = (codec: CodecOrArray, fn: (c: Codec) => any) => Array.isArray(codec) ? codec.map(fn) : fn(codec) +/** + * Converts Codec data to human-readable format + * @param codec - Codec data to convert + */ const toHuman = (codec: CodecOrArray) => processCodecOrArray(codec, (c) => c?.toHuman?.() ?? c) + +/** + * Converts Codec data to hexadecimal format + * @param codec - Codec data to convert + */ const toHex = (codec: CodecOrArray) => processCodecOrArray(codec, (c) => c?.toHex?.() ?? c) + +/** + * Converts Codec data to JSON format + * @param codec - Codec data to convert + */ const toJson = (codec: CodecOrArray) => processCodecOrArray(codec, (c) => c?.toJSON?.() ?? c) +/** + * Defines a filter for blockchain events + * Can be either a string (section name) or an object with method and section + */ export type EventFilter = string | { method: string; section: string } +/** + * Configuration options for data redaction + */ export type RedactOptions = { - number?: boolean | number // precision - hash?: boolean // 32 byte hex - hex?: boolean // any hex with 0x prefix - address?: boolean // base58 address - redactKeys?: RegExp // redact value for keys matching regex - removeKeys?: RegExp // filter out keys matching regex + /** Redact numbers with optional precision */ + number?: boolean | number + /** Redact 32-byte hex values */ + hash?: boolean + /** Redact any hex values with 0x prefix */ + hex?: boolean + /** Redact base58 addresses */ + address?: boolean + /** Regex pattern for keys whose values should be redacted */ + redactKeys?: RegExp + /** Regex pattern for keys that should be removed */ + removeKeys?: RegExp } +/** + * Function type for test assertions + */ export type ExpectFn = (value: any) => { toMatchSnapshot: (msg?: string) => void toMatch(value: any, msg?: string): void toMatchObject(value: any, msg?: string): void } +/** + * Main class for checking and validating blockchain data + * Provides a fluent interface for data transformation, filtering, and assertion + */ export class Checker { readonly #expectFn: ExpectFn readonly #value: any @@ -36,32 +76,49 @@ export class Checker { #message: string | undefined #redactOptions: RedactOptions | undefined + /** + * Creates a new Checker instance + * @param expectFn - Function for making test assertions + * @param value - Value to check + * @param message - Optional message for assertions + */ constructor(expectFn: ExpectFn, value: any, message?: string) { this.#expectFn = expectFn this.#value = value this.#message = message } + /** Convert the checked value to human-readable format */ toHuman() { this.#format = 'human' return this } + /** Convert the checked value to hexadecimal format */ toHex() { this.#format = 'hex' return this } + /** Convert the checked value to JSON format */ toJson() { this.#format = 'json' return this } + /** + * Set a message for test assertions + * @param message - Message to use in assertions + */ message(message: string) { this.#message = message return this } + /** + * Filter blockchain events based on provided filters + * @param filters - Event filters to apply + */ filterEvents(...filters: EventFilter[]) { this.toHuman() this.#pipeline.push((value) => { @@ -83,6 +140,10 @@ export class Checker { return this } + /** + * Apply redaction rules to the checked value + * @param options - Redaction options + */ redact(options: RedactOptions = { number: 2, hash: true }) { this.#redactOptions = { ...this.#redactOptions, @@ -171,15 +232,27 @@ export class Checker { return process(value) } + /** + * Add a transformation function to the processing pipeline + * @param fn - Transformation function + */ map(fn: (value: any) => any) { this.#pipeline.push(fn) return this } + /** + * Apply a function to the current Checker instance + * @param fn - Function to apply + */ pipe(fn?: (value: Checker) => Checker) { return fn ? fn(this) : this } + /** + * Get the final processed value + * @returns Processed value after applying all transformations + */ async value() { let value = await this.#value @@ -204,20 +277,44 @@ export class Checker { return value } + /** + * Assert that the value matches a snapshot + * @param msg - Optional message for the assertion + */ async toMatchSnapshot(msg?: string) { return this.#expectFn(await this.value()).toMatchSnapshot(msg ?? this.#message) } + /** + * Assert that the value matches an expected value + * @param value - Expected value + * @param msg - Optional message for the assertion + */ async toMatch(value: any, msg?: string) { return this.#expectFn(await this.value()).toMatch(value, msg ?? this.#message) } + /** + * Assert that the value matches an expected object structure + * @param value - Expected object structure + * @param msg - Optional message for the assertion + */ async toMatchObject(value: any, msg?: string) { return this.#expectFn(await this.value()).toMatchObject(value, msg ?? this.#message) } } +/** + * Creates a set of checking utilities with a provided assertion function + * @param expectFn - Function for making test assertions + * @returns Object containing various checking utilities + */ export const withExpect = (expectFn: ExpectFn) => { + /** + * Create a new Checker instance + * @param value - Value to check + * @param msg - Optional message for assertions + */ const check = (value: any, msg?: string) => { if (value instanceof Checker) { if (msg) { @@ -230,21 +327,39 @@ export const withExpect = (expectFn: ExpectFn) => { type Api = { api: ApiPromise } + /** + * Check blockchain events with filtering and redaction + * @param events - Events to check + * @param filters - Event filters to apply + */ const checkEvents = ({ events }: { events: Promise }, ...filters: EventFilter[]) => check(events, 'events') .filterEvents(...filters) .redact() + /** + * Check system events with filtering and redaction + * @param api - Polkadot API instance + * @param filters - Event filters to apply + */ const checkSystemEvents = ({ api }: Api, ...filters: EventFilter[]) => check(api.query.system.events(), 'system events') .filterEvents(...filters) .redact() + /** + * Check Upward Message Passing (UMP) messages + * @param api - Polkadot API instance + */ const checkUmp = ({ api }: Api) => check(api.query.parachainSystem.upwardMessages(), 'ump').map((value) => api.createType('Vec', value).toJSON(), ) + /** + * Check HRMP (Horizontal Relay-routed Message Passing) messages + * @param api - Polkadot API instance + */ const checkHrmp = ({ api }: Api) => check(api.query.parachainSystem.hrmpOutboundMessages(), 'hrmp').map((value) => (value as any[]).map(({ recipient, data }) => ({ @@ -253,6 +368,11 @@ export const withExpect = (expectFn: ExpectFn) => { })), ) + /** + * Check a value in hexadecimal format + * @param value - Value to check + * @param msg - Optional message for assertions + */ const checkHex = (value: any, msg?: string) => check(value, msg).toHex() return { diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index aed7792c..e74356db 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -20,26 +20,51 @@ const logger = defaultLogger.child({ name: 'utils' }) export * from './signFake.js' +/** + * Configuration options for setting up a blockchain network instance + */ export type SetupOption = { + /** WebSocket endpoint(s) for connecting to the network */ endpoint: string | string[] + /** Specific block number to start from */ blockNumber?: number + /** Specific block hash to start from */ blockHash?: HexString + /** Path to override WASM runtime */ wasmOverride?: string + /** Path to database file */ db?: string + /** Connection timeout in milliseconds */ timeout?: number + /** Host address to bind the server to */ host?: string + /** Port number to bind the server to */ port?: number + /** Maximum number of blocks to keep in memory */ maxMemoryBlockCount?: number + /** Resume from a previous state (block hash or number) */ resume?: boolean | HexString | number + /** Runtime log level (0-5) */ runtimeLogLevel?: number + /** Allow unresolved imports in runtime */ allowUnresolvedImports?: boolean + /** Process queued XCM messages */ processQueuedMessages?: boolean } +/** + * Extended configuration type that includes timeout + */ export type SetupConfig = Config & { + /** Connection timeout in milliseconds */ timeout?: number } +/** + * Creates a configuration object from setup options + * @param options - Setup options for the network + * @returns Configuration object compatible with chopsticks + */ export const createConfig = ({ endpoint, blockNumber, @@ -76,10 +101,20 @@ export const createConfig = ({ return config } +/** + * Sets up a blockchain network context using provided options + * @param option - Setup options for the network + * @returns Network context including API, WebSocket provider, and utility functions + */ export const setupContext = async (option: SetupOption) => { return setupContextWithConfig(createConfig(option)) } +/** + * Sets up a blockchain network context using a configuration object + * @param config - Configuration object for the network + * @returns Network context including API, WebSocket provider, and utility functions + */ export const setupContextWithConfig = async ({ timeout, ...config }: SetupConfig) => { const { chain, addr, close } = await setupWithServer(config) @@ -96,23 +131,29 @@ export const setupContextWithConfig = async ({ timeout, ...config }: SetupConfig ws, api, dev: { + /** Creates a new block with optional parameters */ newBlock: (param?: Partial): Promise => { return ws.send('dev_newBlock', [param]) }, + /** Sets storage values at a specific block */ setStorage: (values: StorageValues, blockHash?: string) => { return ws.send('dev_setStorage', [values, blockHash]) }, + /** Moves blockchain time to a specific timestamp */ timeTravel: (date: string | number) => { return ws.send('dev_timeTravel', [date]) }, + /** Sets the chain head to a specific block */ setHead: (hashOrNumber: string | number) => { return ws.send('dev_setHead', [hashOrNumber]) }, }, + /** Cleans up resources and closes connections */ async teardown() { await api.disconnect() await close() }, + /** Pauses execution and enables manual interaction through Polkadot.js apps */ async pause() { await ws.send('dev_setBlockBuildMode', [BuildBlockMode.Instant]) @@ -124,8 +165,14 @@ export const setupContextWithConfig = async ({ timeout, ...config }: SetupConfig } } +/** Type alias for the network context returned by setupContext */ export type NetworkContext = Awaited> +/** + * Sets up multiple blockchain networks and establishes connections between them + * @param networkOptions - Configuration options for each network + * @returns Record of network contexts indexed by network name + */ export const setupNetworks = async (networkOptions: Partial>) => { const ret = {} as Record @@ -165,6 +212,10 @@ export const setupNetworks = async (networkOptions: Partial() { const deferred = {} as { resolve: (value: any) => void; reject: (reason: any) => void; promise: Promise } deferred.promise = new Promise((resolve, reject) => { @@ -174,6 +225,11 @@ export function defer() { return deferred } +/** + * Sends a transaction and waits for it to be included in a block + * @param tx - Promise of a submittable extrinsic + * @returns Promise that resolves with transaction events + */ export const sendTransaction = async (tx: Promise>) => { const signed = await tx const deferred = defer() @@ -192,6 +248,12 @@ export const sendTransaction = async (tx: Promise { const keyringEth = createTestKeyring({ type: 'ethereum' }) // default to ed25519 because sr25519 signature is non-deterministic