import { readFileSync } from "fs"; import { logger, safeJsonStringify, type ApiDeclarations } from "./utils"; import { exit } from "process"; import chalk from "chalk"; export enum Chain { Relay = "Rely", Teyrchain = "Para", } interface IEvent { module: string; event: string; data: any | undefined; } interface IBlock { chain: Chain; number: number; hash: string; events: IEvent[]; weights: any; authorship: IAuthorshipData | null; } /// The on-chain weight consumed in a block, exactly as stored by `frame-system` interface IWeight { normal: { ref_time: bigint; proof_size: bigint; }; operational: { ref_time: bigint; proof_size: bigint; }; mandatory: { ref_time: bigint; proof_size: bigint; }; } /// Information obtained from the collator about authorship of a block. interface IAuthorshipData { /// The header size in PoV in kb. header: number; /// The extrinsics size in PoV in kb. extrinsics: number; /// The storage proof size in PoV in kb. proof: number; /// The compressed PoV size (sum of all the above) in kb. compressed: number; /// The time it took to author the block in ms. time: number; } /// Print an event. function pe(e: IEvent): string { return `${e.module} ${e.event} ${e.data ? safeJsonStringify(e.data) : "no data"}`; } interface IObservableEvent { chain: Chain; module: string; event: string; dataCheck: ((data: any) => boolean) | undefined; byBlock: number | undefined; } export class Observe { e: IObservableEvent; onPass: () => void = () => {}; constructor( chain: Chain, module: string, event: string, dataCheck: ((data: any) => boolean) | undefined = undefined, byBlock: number | undefined = undefined, onPass: () => void = () => {} ) { this.e = { chain, module, event, dataCheck, byBlock }; this.onPass = onPass; } toString(): string { return `Observe(${this.e.chain}, ${this.e.module}, ${this.e.event}, ${ this.e.dataCheck ? "dataCheck" : "no dataCheck" }, ${this.e.byBlock ? this.e.byBlock : "no byBlock"})`; } static on(chain: Chain, mod: string, event: string): ObserveBuilder { return new ObserveBuilder(chain, mod, event); } } export class ObserveBuilder { private chain: Chain; private module: string; private event: string; private dataCheck?: (data: any) => boolean; private byBlockVal?: number; private onPassCallback: () => void = () => {}; constructor(chain: Chain, module: string, event: string) { this.chain = chain; this.module = module; this.event = event; } withDataCheck(check: (data: any) => boolean): ObserveBuilder { this.dataCheck = check; return this; } byBlock(blockNumber: number): ObserveBuilder { this.byBlockVal = blockNumber; return this; } onPass(callback: () => void): ObserveBuilder { this.onPassCallback = callback; return this; } build(): Observe { if (!this.module || !this.event) { throw new Error("Module and event are required"); } return new Observe( this.chain, this.module, this.event, this.dataCheck, this.byBlockVal, this.onPassCallback ); } } export enum EventOutcome { TimedOut = "TimedOut", Done = "Done", } export class TestCase { eventSequence: Observe[]; onKill: () => void; allowPerChainInterleavedEvents: boolean = false; private resolveTestPromise: (outcome: EventOutcome) => void = () => {}; /// See `example.test.ts` for more info. constructor(e: Observe[], interleave: boolean = false, onKill: () => void = () => {}) { this.eventSequence = e; this.onKill = onKill; this.allowPerChainInterleavedEvents = interleave; } setTestPromiseResolvers(resolve: (outcome: EventOutcome) => void) { this.resolveTestPromise = resolve; } match(ours: IObservableEvent, theirs: IEvent, theirsChain: Chain): boolean { const trivialComp = ours.chain === theirsChain && ours.module === theirs.module && ours.event === theirs.event; if (trivialComp) { // note: only run data check if it is defined and all other criteria match const dataComp = ours.dataCheck === undefined ? true : ours.dataCheck!(theirs.data); return trivialComp && dataComp; } else { return false; } } notTimedOut(ours: IObservableEvent, block: number): boolean { return ours.byBlock === undefined ? true : block <= ours.byBlock; } // with thousand separator! wts(num: bigint): string { return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ","); } formatWeight(weight: IWeight): string { const weightPerMs = BigInt(Math.pow(10, 9)); const WeightPerKb = BigInt(1024); const refTime = weight.normal.ref_time + weight.operational.ref_time + weight.mandatory.ref_time; const proofSize = weight.normal.proof_size + weight.operational.proof_size + weight.mandatory.proof_size; return `${this.wts(refTime / weightPerMs)}ms / ${this.wts(proofSize / WeightPerKb)} kb`; } formatAuthorship(authorship: IAuthorshipData): string { return `hd=${authorship.header.toFixed(2)}, xt=${authorship.extrinsics.toFixed( 2 )}, st=${authorship.proof.toFixed(2)}, sum=${( authorship.header + authorship.extrinsics + authorship.proof ).toFixed(2)}, cmp=${authorship.compressed.toFixed(2)}, time=${authorship.time}ms`; } commonLog(blockData: IBlock): string { const number = `#${blockData.number}`; const chain = blockData.chain === Chain.Relay ? chalk.blue(blockData.chain) // Blue for Relay - works well in both modes : chalk.green(blockData.chain); // Green for Teyrchain - works well in both modes const weight = `⛓ ${this.formatWeight(blockData.weights)}`; const authorship = blockData.authorship ? `[✍️ ${this.formatAuthorship(blockData.authorship)}]` : ""; return `[${chain}${number}][${weight}]${authorship}`; } // returns a [`primary`, `maybeSecondary`] event to check. `primary` should always be checked first, and if not secondary is checked. nextEvent(chain: Chain): [Observe, Observe | undefined] { const next = this.eventSequence[0]!; if (this.allowPerChainInterleavedEvents && this.eventSequence.length > 1) { // get the next event in our list that is of type `chain`. const nextOfChain = this.eventSequence.slice(1).find((e) => e.e.chain === chain); return [next, nextOfChain]; } else { return [next, undefined]; } } removeEvent(e: Observe): void { const index = this.eventSequence.findIndex((x) => x.e === e.e); if (index !== -1) { this.eventSequence.splice(index, 1); } else { logger.warn(`Event not found for removal: ${e.toString()}`); exit(1); } } onBlock(blockData: IBlock) { // sort from small to big logger.debug(`${this.commonLog(blockData)} events: ${blockData.events.length}`); const firstTimeOut = this.eventSequence .filter((e) => e.e.byBlock) .sort((x, y) => x.e.byBlock! - y.e.byBlock!); if (firstTimeOut.length > 0 && blockData.number > firstTimeOut[0]!.e.byBlock!) { logger.error( `Block ${blockData.number} is past the first timeout at block ${firstTimeOut[0]}, exiting.` ); this.resolveTestPromise(EventOutcome.TimedOut); } for (const e of blockData.events) { this.onEvent(e, blockData); } } onEvent(e: IEvent, blockData: IBlock) { if (!this.eventSequence.length) { logger.warn(`No events to process for ${blockData.chain}, event: ${pe(e)}`); return; } logger.verbose(`${this.commonLog(blockData)} Processing event: ${pe(e)}`); const [primary, maybeSecondary] = this.nextEvent(blockData.chain); if (this.match(primary.e, e, blockData.chain)) { primary.onPass(); this.removeEvent(primary); logger.info(`Primary event passed`); if (this.eventSequence.length === 0) { logger.info("All events processed."); this.resolveTestPromise(EventOutcome.Done); } else { logger.verbose( `Next expected event: ${this.eventSequence[0]!.toString()}, remaining events: ${ this.eventSequence.length }` ); } } else if (maybeSecondary && this.match(maybeSecondary.e, e, blockData.chain)) { maybeSecondary.onPass(); this.removeEvent(maybeSecondary); logger.info(`Secondary event passed`); // when we check secondary events, we must have at least 2 items in the list, so no // need to check for the end of list. } else { logger.debug(`event not relevant`); } } } // Extract information about the authoring of `block` number from the given `logFile`. This will // work in 3 steps: // 1. After filtering for `[Teyrchain]`, and looking at the log file from end to start, it will find // the line containing `Prepared block for proposing at ${block}`. From this, we extract the // authoring time in ms // 2. Them, we only keep the rest of the log file (optimization). We find the first line thereafter // containing `PoV size header_kb=... extrinsics_kb=... storage_proof_kb=...` and extract the // sizes of the header, extrinsics and storage proof. // 3. Finally, we find the first line thereafter containing `Compressed PoV size: ...kb` and extract // the compressed size. // // Note: `logFile` must always relate to a teyrchain. function extractAuthorshipData(block: number, logFile: string): IAuthorshipData | null { if (block == 0) { return null; } const log = readFileSync(logFile) .toString() .split("\n") .filter((l) => l.includes("[Teyrchain]")) .reverse(); const target = `Prepared block for proposing at ${block}`; const findTime = (log: string[]): { time: number; readStack: string[] } => { const readStack: string[] = []; for (let i = 0; i < log.length; i++) { const line = log[i]; if (!line) { continue; } readStack.push(line); if (line?.includes(target)) { const match = line.match("([0-9]+) ms"); if (match) { return { time: Number(match.at(1)!), readStack }; } } } throw `Could not find authorship line ${target}`; }; const findProofs = ( readStack: string[] ): { header: number; extrinsics: number; proof: number } => { for (let i = 0; i < readStack.length; i++) { const line = readStack[i]; const match = line?.match( "PoV size header_kb=([0-9]+.[0-9]+) extrinsics_kb=([0-9]+.[0-9]+) storage_proof_kb=([0-9]+.[0-9]+)" ); if (match) { return { header: Number(match[1]!), extrinsics: Number(match[2]!), proof: Number(match[3])!, }; } } throw "Could not find the expected PoV data in log file."; }; const findCompressed = (readStack: string[]): number => { for (let i = 0; i < readStack.length; i++) { const line = readStack[i]; const match = line?.match("Compressed PoV size: ([0-9]+.[0-9]+)kb"); if (match) { return Number(match[1]!); } } throw "Could not find the expected compressed data in log file."; }; const { time, readStack } = findTime(log); // reverse the read stack again, as we want the first proof related prints after we `findTime`. readStack.reverse(); const { header, extrinsics, proof } = findProofs(readStack); const compressed = findCompressed(readStack); return { time, header, extrinsics, proof, compressed }; } export async function runTest( test: TestCase, apis: ApiDeclarations, paraLog: string | null ): Promise { const { rcClient, paraClient, rcApi, paraApi } = apis; let completionPromise: Promise = new Promise((resolve, _) => { // Pass the resolve/reject functions to the TestCase instance test.setTestPromiseResolvers(resolve); rcClient.finalizedBlock$.subscribe(async (block) => { const events = await rcApi.query.System.Events.getValue({ at: block.hash }); const weights = await rcApi.query.System.BlockWeight.getValue({ at: block.hash }); const interested = events .filter( (e) => e.event.type === "Session" || e.event.type === "RootOffences" || e.event.type === "StakingAhClient" ) .map((e) => ({ module: e.event.type, event: e.event.value.type, data: e.event.value.value, })); test.onBlock({ chain: Chain.Relay, number: block.number, hash: block.hash, events: interested, weights: weights, authorship: null, }); }); paraClient.finalizedBlock$.subscribe(async (block) => { const events = await paraApi.query.System.Events.getValue({ at: block.hash }); const weights = await paraApi.query.System.BlockWeight.getValue({ at: block.hash }); const interested = events .filter( (e) => e.event.type == "Staking" || e.event.type == "MultiBlockElection" || e.event.type == "MultiBlockElectionSigned" || e.event.type == "MultiBlockElectionVerifier" || e.event.type == "StakingRcClient" ) .map((e) => ({ module: e.event.type, event: e.event.value.type, data: e.event.value.value, })); test.onBlock({ chain: Chain.Teyrchain, number: block.number, hash: block.hash, events: interested, weights: weights, authorship: paraLog ? extractAuthorshipData(block.number, paraLog!) : null, }); }); }); // Handle graceful exit on SIGINT process.on("SIGINT", () => { console.log("Exiting on Ctrl+C..."); rcClient.destroy(); paraClient.destroy(); test.onKill(); process.exit(0); }); // Wait for the completionPromise to resolve/reject const finalOutcome = await completionPromise; rcClient.destroy(); paraClient.destroy(); logger.info(`Test completed with outcome: ${finalOutcome}, calling onKill...`); test.onKill(); return finalOutcome; }