UNPKG

metatests

Version:

Simple to use test engine for Metarhia technology stack

481 lines (451 loc) 15 kB
#!/usr/bin/env node 'use strict'; const yargs = require('yargs'); const common = require('@metarhia/common'); const yaml = require('tap-yaml'); const path = require('path'); const fs = require('fs'); const util = require('util'); const { spawn } = require('child_process'); const metatests = require('..'); const { resultToCsv, makeTotalResults, aggregateResults, } = require('../lib/speed'); const { roundTo } = require('../lib/utils'); const COMPARE_R_PATH = path.join(__dirname, '..', 'benchmarks', 'compare.R'); const NS_PER_SEC = 1e9; const DEFAULT_EXIT_TIMEOUT = 5; const runner = metatests.runner.instance; const [semverMajor, semverMinor] = process.version .slice(1) .split('.') .map(Number); const supportsESM = (semverMajor === 12 && semverMinor >= 17) || (semverMajor === 13 && semverMinor >= 2) || semverMajor >= 14; const logLevels = { quiet: 0, default: 1, error: 1, warn: 2, info: 3, debug: 4, }; const isLogAtLeast = (level1, level2 = 'default') => logLevels[level1] >= logLevels[level2]; const printIfLog = (config, log, ...msg) => { if (isLogAtLeast(config.logLevel, log)) { runner.reporter.log(...msg); } }; const printIfLogComment = (config, log, ...msg) => { if (isLogAtLeast(config.logLevel, log)) { runner.reporter.logComment(...msg); } }; const merge = (arr1 = [], arr2 = []) => common.merge(arr1, arr2); const exclude = (files, filterArr) => filterArr .map((p) => p .replace('/', path.sep) .replace(/[.+^${}()|[\]\\]/g, '\\$&') .replace('*', '.+') .replace('?', '.'), ) .map((p) => new RegExp(p)) .reduce( (files, regexp) => files.filter((file) => !regexp.test(file)), files, ); const parseFile = (file) => { const data = fs.readFileSync(path.resolve(file), 'utf8'); switch (common.fileExt(file)) { case 'json': return JSON.parse(data); case 'yml': case 'yaml': return yaml.parse(data); default: return {}; } }; const loadFiles = (files) => { const result = []; common .iter(files) .map((file) => { if (fs.existsSync(file)) { return file; } else if (fs.existsSync(file + '.js')) { return file + '.js'; } else if (fs.existsSync(file + '.cjs')) { return file + '.cjs'; } else if (fs.existsSync(file + '.mjs')) { return file + '.mjs'; } else { console.error('File does not exist:', file); process.exit(1); return ''; } }) .forEach((file) => { if (fs.statSync(file).isDirectory()) { const subfiles = fs.readdirSync(file).map((f) => path.join(file, f)); result.push(...loadFiles(subfiles)); } else if (['js', 'mjs', 'cjs'].includes(common.fileExt(file))) { result.push(file); } }); return result; }; const getConfig = (args) => { const config = args.config ? parseFile(args.config) : {}; config.exclude = merge(config.exclude, args.exclude); config.files = loadFiles(merge(config.files, args._)); config.files = exclude(config.files, config.exclude); config.logLevel = args.logLevel || config.logLevel || 'default'; config.reporter = args.reporter || config.reporter || 'tap'; config.runTodo = args.runTodo || config.runTodo; config.exitTimeout = args.exitTimeout || config.exitTimeout || DEFAULT_EXIT_TIMEOUT; return config; }; const runNode = (config, cb) => { if (config.logLevel === 'quiet') { runner.removeReporter(); } else if (config.reporter.startsWith('tap')) { runner.setReporter( new metatests.reporters.TapReporter({ type: config.reporter.split('-')[1] || (process.stdout.isTTY ? 'classic' : 'tap'), }), ); } else if (config.reporter === 'concise') { runner.setReporter(new metatests.reporters.ConciseReporter()); } if (config.runTodo) runner.runTodo(); runner.on('finish', (hasFailures) => { const msg = 'Tests finished. Waiting for process to finish.\n'; printIfLogComment(config, 'default', msg); if (hasFailures) { cb(1); return; } setTimeout(() => { const msg = `Process didn't finish within timeout (${ config.exitTimeout * 5000 }), exiting.`; printIfLog(config, 'default', msg); cb(1); }, config.exitTimeout * 5000).unref(); }); const msg = `\nNode ${process.version} (v8 ${process.versions.v8}):`; printIfLog(config, 'default', msg); merge(config.files, config.nodeOnly).forEach((name) => { const file = path.isAbsolute(name) ? name : path.join(process.cwd(), name); if (file.endsWith('mjs')) { if (supportsESM) { import('file://' + file); } else { console.warn( `Warning: ignoring '${file}', current Node.js version doesn't ` + 'support dynamic import', ); } } else { require(file); } }); }; const runTests = (args) => { const config = getConfig(args); const onExit = (code) => { printIfLogComment(config, 'default', 'Metatests finished with code', code); process.exit(code); }; const msg = `Metatests final config:\n${JSON.stringify(config, null, 2)}\n`; printIfLogComment(config, 'debug', msg); if (!config.files.length) { console.error('No test files were specified\n'); yargs.showHelp(); onExit(1); } runNode(config, onExit); }; const handleBenchTarget = (args) => { const res = require(path.join(process.cwd(), args.file)); let target = args.target ? common.getByPath(res, args.target) : res; if (!Array.isArray(target)) { if (target && typeof target === 'object') target = Object.values(target); else if (typeof target === 'function') target = [target]; } if (!target) { console.error(`File doesn't export correct target`); yargs.showHelp(); process.exit(1); } return target; }; const runSpeed = (args) => { const target = handleBenchTarget(args); if (!target) return; metatests.speed(args.caption, args.count, target); }; function measureTarget(target, args) { const options = { defaultCount: args.count, runs: args.runs, preflight: args.preflight, preflightCount: args.preflightCount, listener: { cycle: (name, r) => { let out; if (args.csv) { out = resultToCsv(r); } else if (args.verbose) { let args = r.args && r.args.map(util.inspect).join(' '); if (args) args = '\targs=' + args; const ops = roundTo(r.count / (r.time / NS_PER_SEC), 2); out = `${name}\tn=${r.count}${args}:\t\t${r.time}ns, ${ops}ops/s`; } if (out) console.log(out); }, done: (caseName, caseConf, results) => { if (args.csv) return; const agg = aggregateResults(results)[0]; const name = agg.name.padEnd(15, ' '); const ops = roundTo(agg.ops, 2) + ' ops/s '; const rme = '±' + roundTo(agg.stats.relativeMarginOfError, 2) + '%'; console.log(name + ops + rme); }, }, }; if (args.csv) { console.log( ['name', 'configuration', 'rate', 'time'].map(JSON.stringify).join(', '), ); } return metatests.measure(target, options); } const runMeasure = (args) => { if ((args.new && !args.old) || (args.old && !args.new)) { console.error('Measure --old --new must always be used together.'); process.exit(1); } if (args.new && args.old) { let res = require(path.join(process.cwd(), args.file)); if (args.target) res = common.getByPath(res, args.target); const oldTarget = common.getByPath(res, args.old); const oldCases = Array.isArray(oldTarget) ? oldTarget : [{ name: args.name, fn: oldTarget }]; const newTarget = common.getByPath(res, args.new); const newCases = Array.isArray(newTarget) ? newTarget : [{ name: args.name, fn: newTarget }]; console.log('Measuring old target'); const oldResults = measureTarget(oldCases, args); console.log('Measuring new target'); const newResults = measureTarget(newCases, args); const r = spawn('Rscript', [COMPARE_R_PATH], { stdio: ['pipe', 'inherit', 'inherit'], }); console.log(); r.stdin.write(makeTotalResults(oldResults, newResults)); r.stdin.end(); } else { const target = handleBenchTarget(args); if (!target) return; measureTarget(target, args); } }; yargs .parserConfiguration({ 'duplicate-arguments-array': false, }) .command( '*', 'tests', (y) => { y.usage('$0 [options] file.js [file.js...]') .usage('$0 [options] --config config.json') .option('exclude', { global: false, array: true, type: 'string', describe: 'Exclude tests patterns', }) .option('reporter', { global: false, type: 'string', describe: 'Reporter name', }) .option('log-level', { global: false, choices: Object.keys(logLevels), type: 'string', describe: 'Log level', }) .option('run-todo', { global: false, type: 'boolean', describe: 'Run todo tests', }) .option('exit-timeout', { global: false, type: 'number', describe: 'Seconds to wait after tests finished', }) .option('config', { global: false, alias: 'c', type: 'string', describe: 'Path to config file', }) // This has to be a subcommand in order to have its help // usage printed by yargs. .command( 'speed [options] <file>', 'Perform simple benchmarking. The file should either export a ' + 'case/function, an array of cases/functions or an object with ' + 'properties. --target option can be used to get nested paths ' + 'from exported object', (y) => y .example( '$0 speed bench.js', 'Run simple benchmarks for every function exported from ' + '"bench.js"', ) .example( '$0 speed -n 1e7 bench.js', 'Run simple benchmarks with custom run count for every ' + 'function exported from "bench.js"', ) .option('caption', { type: 'string', describe: 'Caption of the speed test', default: 'Speed test', }) .option('count', { alias: 'n', type: 'number', describe: 'Number of runs', default: 1e7, }) .option('target', { alias: 't', type: 'string', describe: 'Name of exported property to use for speed test', }), runSpeed, ) .command( 'measure [options] <file>', 'Perform comprehensive benchmarks with extensive customization. ' + 'The file should either export a case/function, an array of ' + 'cases/functions or an object with properties. --target option ' + 'can be used to get nested paths from exported object. ' + 'Two implementations can be compared with the use of ' + '--new and --old options', (y) => y .example( '$0 measure --csv bench.js', 'Run benchmarks for all exported functions from "bench.js" ' + 'file and output them in csv format', ) .example( '$0 measure --csv --preflight 5 --runs 20 -n 1e7 bench.js', 'Run benchmarks for all exported functions from "bench.js" ' + 'file using custom options and output them in csv format', ) .example( '$0 measure --old oldImpl --new newImpl --name compare ' + 'bench.js', 'Compare performance of "oldImpl" function to "newImpl" one ' + 'exported from "bench.js" file', ) .example( '$0 measure --old oldImpl --new newImpl --name ' + 'compare --target nested.props bench.js', 'Compare performance of "nested.props.oldImpl" function to ' + '"nested.props.newImpl" one exported from "bench.js" file', ) .option('aggregate', { alias: 'agg', type: 'boolean', conflicts: ['csv'], describe: 'Aggregate multiple results with the same name. ' + 'True by default and incompatible with --csv', }) .option('count', { alias: 'n', type: 'number', describe: 'Default number of function runs', default: 1e6, }) .option('runs', { alias: 'r', type: 'number', describe: 'Number of runs of each case', default: 20, }) .option('csv', { type: 'boolean', describe: 'Output results as CSV', }) .option('preflight', { alias: 'p', type: 'number', describe: 'Number of preflight runs of each case', default: 10, }) .option('preflightCount', { alias: 'pc', type: 'number', describe: 'Number of preflight function runs', default: 1e7, }) .option('target', { alias: 't', type: 'string', describe: 'Path of exported property to use for speed test', }) .option('name', { type: 'string', describe: 'Name to use for test function if --new --old is used', default: 'bench', }) .option('new', { type: 'string', describe: 'Path of exported property to use as new in comparison. ' + 'Must always be used with --old.', }) .option('old', { type: 'string', describe: 'Path of exported property to use as old in comparison. ' + 'Must always be used with --new.', }) .option('verbose', { alias: 'v', type: 'boolean', describe: 'Output every result during the benchmark run', default: false, }), runMeasure, ); }, runTests, ) .help() .alias('help', 'h').argv;