@polkadot/dev
Version:
A collection of shared CI scripts and development environment used by @polkadot projects
369 lines (306 loc) โข 9.88 kB
JavaScript
// Copyright 2017-2025 @polkadot/dev authors & contributors
// SPDX-License-Identifier: Apache-2.0
// For Node 18, earliest usable is 18.14:
//
// - node:test added in 18.0,
// - run method exposed in 18.9,
// - mock in 18.13,
// - diagnostics changed in 18.14
//
// Node 16 is not supported:
//
// - node:test added is 16.17,
// - run method exposed in 16.19,
// - mock not available
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import process from 'node:process';
import { run } from 'node:test';
import { isMainThread, parentPort, Worker, workerData } from 'node:worker_threads';
// NOTE error should be defined as "Error", however the @types/node definitions doesn't include all
/** @typedef {{ file?: string; message?: string; }} DiagStat */
/** @typedef {{ details: { type: string; duration_ms: number; error: { message: string; failureType: unknown; stack: string; cause: { code: number; message: string; stack: string; generatedMessage?: any; }; code: number; } }; file?: string; name: string; testNumber: number; nesting: number; }} FailStat */
/** @typedef {{ details: { duration_ms: number }; name: string; }} PassStat */
/** @typedef {{ diag: DiagStat[]; fail: FailStat[]; pass: PassStat[]; skip: unknown[]; todo: unknown[]; total: number; [key: string]: any; }} Stats */
console.time('\t elapsed :');
const WITH_DEBUG = false;
const args = process.argv.slice(2);
/** @type {string[]} */
const files = [];
/** @type {Stats} */
const stats = {
diag: [],
fail: [],
pass: [],
skip: [],
todo: [],
total: 0
};
/** @type {string | null} */
let logFile = null;
/** @type {number} */
let startAt = 0;
/** @type {boolean} */
let bail = false;
/** @type {boolean} */
let toConsole = false;
/** @type {number} */
let progressRowCount = 0;
for (let i = 0; i < args.length; i++) {
if (args[i] === '--bail') {
bail = true;
} else if (args[i] === '--console') {
toConsole = true;
} else if (args[i] === '--logfile') {
logFile = args[++i];
} else {
files.push(args[i]);
}
}
/**
* @internal
*
* Performs an indent of the line (and containing lines) with the specific count
*
* @param {number} count
* @param {string} str
* @param {string} start
* @returns {string}
*/
function indent (count, str = '', start = '') {
let pre = '\n';
switch (count) {
case 0:
break;
case 1:
pre += '\t';
break;
case 2:
pre += '\t\t';
break;
default:
pre += '\t\t\t';
break;
}
pre += ' ';
return `${pre}${start}${
str
.split('\n')
.map((l) => l.trim())
.join(`${pre}${start ? ' '.padStart(start.length, ' ') : ''}`)
}\n`;
}
/**
* @param {FailStat} r
* @return {string | undefined}
*/
function getFilename (r) {
if (r.file?.includes('.spec.') || r.file?.includes('.test.')) {
return r.file;
}
if (r.details.error.cause.stack) {
const stack = r.details.error.cause.stack
.split('\n')
.map((l) => l.trim())
.filter((l) => l.startsWith('at ') && (l.includes('.spec.') || l.includes('.test.')))
.map((l) => l.match(/\(.*:\d\d?:\d\d?\)$/)?.[0])
.map((l) => l?.replace('(', '')?.replace(')', ''));
if (stack.length) {
return stack[0];
}
}
return r.file;
}
function complete () {
process.stdout.write('\n');
let logError = '';
stats.fail.forEach((r) => {
WITH_DEBUG && console.error(JSON.stringify(r, null, 2));
let item = '';
item += indent(1, [getFilename(r), r.name].filter((s) => !!s).join('\n'), 'x ');
item += indent(2, `${r.details.error.failureType} / ${r.details.error.code}${r.details.error.cause.code && r.details.error.cause.code !== r.details.error.code ? ` / ${r.details.error.cause.code}` : ''}`);
if (r.details.error.cause.message) {
item += indent(2, r.details.error.cause.message);
}
logError += item;
if (r.details.error.cause.stack) {
item += indent(2, r.details.error.cause.stack);
}
process.stdout.write(item);
});
if (logFile && logError) {
try {
fs.appendFileSync(path.join(process.cwd(), logFile), logError);
} catch (e) {
console.error(e);
}
}
console.log();
console.log('\t passed ::', stats.pass.length);
console.log('\t failed ::', stats.fail.length);
console.log('\t skipped ::', stats.skip.length);
console.log('\t todo ::', stats.todo.length);
console.log('\t total ::', stats.total);
console.timeEnd('\t elapsed :');
console.log();
// The full error information can be quite useful in the case of overall failures
if ((stats.fail.length || toConsole) && stats.diag.length) {
/** @type {string | undefined} */
let lastFilename = '';
stats.diag.forEach((r) => {
WITH_DEBUG && console.error(JSON.stringify(r, null, 2));
if (typeof r === 'string') {
console.log(r); // Node.js <= 18.14
} else if (r.file && r.file.includes('@polkadot/dev/scripts')) {
// Ignore internal diagnostics
} else {
if (lastFilename !== r.file) {
lastFilename = r.file;
console.log(lastFilename ? `\n${lastFilename}::\n` : '\n');
}
// Edge case: We don't need additional noise that is not useful.
if (!r.message?.split(' ').includes('tests')) {
console.log(`\t${r.message?.split('\n').join('\n\t')}`);
}
}
});
}
if (toConsole) {
stats.pass.forEach((r) => {
console.log(`pass ${r.name} ${r.details.duration_ms} ms`);
});
console.log();
stats.fail.forEach((r) => {
console.log(`fail ${r.name}`);
});
console.log();
}
if (stats.total === 0) {
console.error('FATAL: No tests executed');
console.error();
process.exit(1);
}
process.exit(stats.fail.length);
}
/**
* Prints the progress in real-time as data is passed from the worker.
*
* @param {string} symbol
*/
function printProgress (symbol) {
if (!progressRowCount) {
progressRowCount = 0;
}
if (!startAt) {
startAt = performance.now();
}
// If starting a new row, calculate and print the elapsed time
if (progressRowCount === 0) {
const now = performance.now();
const elapsed = (now - startAt) / 1000;
const minutes = Math.floor(elapsed / 60);
const seconds = elapsed - minutes * 60;
process.stdout.write(
`${`${minutes}:${seconds.toFixed(3).padStart(6, '0')}`.padStart(11)} `
);
}
// Print the symbol with formatting
process.stdout.write(symbol);
progressRowCount++;
// Add spaces for readability
if (progressRowCount % 10 === 0) {
process.stdout.write(' '); // Double space every 10 symbols
} else if (progressRowCount % 5 === 0) {
process.stdout.write(' '); // Single space every 5 symbols
}
// If the row reaches 100 symbols, start a new row
if (progressRowCount >= 100) {
process.stdout.write('\n');
progressRowCount = 0;
}
}
async function runParallel () {
const MAX_WORKERS = Math.min(os.cpus().length, files.length);
const chunks = Math.ceil(files.length / MAX_WORKERS);
try {
// Create and manage worker threads
const results = await Promise.all(
Array.from({ length: MAX_WORKERS }, (_, i) => {
const fileSubset = files.slice(i * chunks, (i + 1) * chunks);
return new Promise((resolve, reject) => {
const worker = new Worker(new URL(import.meta.url), {
workerData: { files: fileSubset }
});
worker.on('message', (message) => {
if (message.type === 'progress') {
printProgress(message.data);
} else if (message.type === 'result') {
resolve(message.data);
}
});
worker.on('error', reject);
worker.on('exit', (code) => {
if (code !== 0) {
reject(new Error(`Worker stopped with exit code ${code}`));
}
});
});
})
);
// Aggregate results from workers
results.forEach((result) => {
Object.keys(stats).forEach((key) => {
if (Array.isArray(stats[key])) {
stats[key] = stats[key].concat(result[key]);
} else if (typeof stats[key] === 'number') {
stats[key] += result[key];
}
});
});
complete();
} catch (err) {
console.error('Error during parallel execution:', err);
process.exit(1);
}
}
if (isMainThread) {
console.time('\tElapsed:');
runParallel().catch((err) => console.error(err));
} else {
run({ files: workerData.files, timeout: 3_600_000 })
.on('data', () => undefined)
.on('end', () => parentPort && parentPort.postMessage(stats))
.on('test:coverage', () => undefined)
.on('test:diagnostic', (/** @type {DiagStat} */data) => {
stats.diag.push(data);
parentPort && parentPort.postMessage({ data: stats, type: 'result' });
})
.on('test:fail', (/** @type {FailStat} */ data) => {
const statFail = structuredClone(data);
if (data.details.error.cause?.stack) {
statFail.details.error.cause.stack = data.details.error.cause.stack;
}
stats.fail.push(statFail);
stats.total++;
parentPort && parentPort.postMessage({ data: 'x', type: 'progress' });
if (bail) {
complete();
}
})
.on('test:pass', (data) => {
const symbol = typeof data.skip !== 'undefined' ? '>' : typeof data.todo !== 'undefined' ? '!' : 'ยท';
if (symbol === '>') {
stats.skip.push(data);
} else if (symbol === '!') {
stats.todo.push(data);
} else {
stats.pass.push(data);
}
stats.total++;
parentPort && parentPort.postMessage({ data: symbol, type: 'progress' });
})
.on('test:plan', () => undefined)
.on('test:start', () => undefined);
}