lib/RunOptions.js

const path = require('node:path');
const {readFileSync} = require('node:fs');
const mri = require('mri');
const NanoTime = require('./NanoTime.js');

module.exports = RunOptions;

/**
 * @private
 */
const MILLI2NANO = BigInt(1000000); // eslint-disable-line no-magic-numbers

/**
 * @private
 */
const NUMBER_OF_IGNORED_CLI_ARGS = 2;

/**
 * @example
 * const RunOptions = require('pete/lib/RunOptions');
 * var options = new RunOptions();
 *
 * @alias module:pete/lib/RunOptions
 * @class
 */
function RunOptions () {
	/**
	 * ID of test.
	 *
	 * @see {module:pete/lib/createId}
	 * @type {string}
	 */
	this.id = '';
	/**
	 * Name of test.
	 *
	 * @type {string}
	 */
	this.name = '';
	/**
	 * Run type to use for the test.
	 * To use custom runner, pass full path to JS file that exports factory function.
	 *
	 * @type {string}
	 * @default 'auto'
	 * @see {@link module:pete/lib/runs/auto.TYPES} for supported type names.
	 */
	this.runType = 'auto';
	/**
	 * Limit total time (in milliseconds) that target function can take when run multiple times.
	 *
	 * @type {number}
	 * @default Infinity
	 */
	this.limitCPUTime = Infinity;
	/**
	 * Limit total time (in milliseconds) that test run can take.
	 *
	 * @type {number}
	 * @default 3000
	 */
	this.limitRealTime = 3000; // eslint-disable-line no-magic-numbers
	/**
	 * Limit how many times target function should be called when tested.
	 *
	 * @type {number}
	 * @default Infinity
	 */
	this.limitSamples = Infinity;
	/**
	 * Same as `limitSamples`, only for "warmup" phase of test run.
	 * Set to 0 to skip "warmup" phase.
	 *
	 * @type {number}
	 * @default 3000
	 */
	this.warmupSamples = 3000; // eslint-disable-line no-magic-numbers
	/**
	 * Set to `true` to stop all tests when there is an error.
	 *
	 * @type {boolean}
	 * @default true
	 */
	this.exitOnError = true;
	/**
	 * Array of arguments to pass to target function.
	 * In case of `callback` type function, test run will provide one additional argument,
	 * and pass it as the last one.
	 *
	 * @example
	 * options.args = ['foo', 2]; // eslint-disable-line no-magic-numbers
	 *
	 * @type {any[]}
	 * @default []
	 */
	this.args = [];
	/**
	 * ID of the only test that should be run in current process.
	 * Empty string means "no filtering" - all tests will be run.
	 *
	 * @type {string|string[]}
	 * @default ''
	 */
	this.runOnlyIf = '';
	/**
	 * List of percentiles that should be reported back.
	 *
	 * @type {number[]}
	 * @default [50, 75, 90, 95, 97.5, 99, 99.95, 99.99]
	 */
	this.percentiles = [50, 75, 90, 95, 97.5, 99, 99.95, 99.99]; // eslint-disable-line no-magic-numbers
	/**
	 * List of reporters that should receive report data.
	 *
	 * @example
	 * options.reporters = ['tap?out=./report/test.tap', 'mico'];
	 *
	 * @type {string[]}
	 * @default []
	 */
	this.reporters = [];
}

/**
 * Parse command line arguments and apply them as options.
 *
 * @return {module:pete/lib/RunOptions} `this`
 */
RunOptions.prototype.setFromArgv = function setFromArgv () {
	const argv = mri(process.argv.slice(NUMBER_OF_IGNORED_CLI_ARGS), {
		boolean: 'exitOnError',
		// WARNING: Do not specify `default`, to not overwrite stuff from opts!
		alias  : {
			name         : ['n'],
			runType      : ['t', 'type'], // 'auto', 'sync', 'generator', 'callback', 'async', 'fork', 'skip'
			limitCPUTime : ['c', 'maxCpuTime'],
			limitRealTime: ['r', 'maxRealTime'],
			limitSamples : ['s', 'maxSamples'],
			warmupSamples: ['w', 'maxWarmup'],
			percentiles  : ['p', 'percentile'],
			reporters    : ['o', 'output', 'report'],
			exitOnError  : ['b', 'bail'],
			runOnlyIf    : ['j', 'just', 'only'],
			args         : ['a', 'arg']
		}
	});

	return this.setFromOptions(argv);
};

/**
 * Pick properties from target options object if their name matches supported option.
 *
 * @param {object} options
 * @return {module:pete/lib/RunOptions} `this`
 */
RunOptions.prototype.setFromOptions = function setFromOptions (options = {}) {
	Object.keys(this).forEach(key => {
		this[key] = Reflect.has(options, key) ? options[key] : this[key];
	});

	return this.sanitize();
};

/**
 * Make sure that option values are of correct type, converting them when needed.
 *
 * return {module:pete/lib/RunOptions} `this`
 */
RunOptions.prototype.sanitize = function sanitize () {
	if (typeof this.percentiles === 'string') {
		this.percentiles = this.percentiles.split(',').map(Number);
	}
	else if (typeof this.percentiles === 'number') {
		this.percentiles = [this.percentiles];
	}

	if (typeof this.reporters === 'string') {
		this.reporters = [this.reporters];
	}

	if (Array.isArray(this.name)) {
		this.name = this.name.pop();
		console.error(`WARNING: only single \`name\` value is supported. Using last one: ${this.name}`);
	}
	if (Array.isArray(this.runType)) {
		this.runType = this.runType.pop();
		console.error(`WARNING: only single \`runType\` value is supported. Using last one: ${this.runType}`);
	}
	if (Array.isArray(this.limitCPUTime)) {
		this.limitCPUTime = this.limitCPUTime.pop();
		console.error(`WARNING: only single \`limitCPUTime\` value is supported. Using last one: ${this.limitCPUTime}`);
	}
	if (Array.isArray(this.limitRealTime)) {
		this.limitRealTime = this.limitRealTime.pop();
		console.error(`WARNING: only single \`limitRealTime\` value is supported. Using last one: ${this.limitRealTime}`);
	}
	if (Array.isArray(this.limitSamples)) {
		this.limitSamples = this.limitSamples.pop();
		console.error(`WARNING: only single \`limitSamples\` value is supported. Using last one: ${this.limitSamples}`);
	}
	if (Array.isArray(this.warmupSamples)) {
		this.warmupSamples = this.warmupSamples.pop();
		console.error(`WARNING: only single \`warmupSamples\` value is supported. Using last one: ${this.warmupSamples}`);
	}

	this.name = this.name || '';
	this.runType = this.runType || 'auto';
	this.limitCPUTime = toNanoseconds(this.limitCPUTime) || Infinity;
	this.limitRealTime = toNanoseconds(this.limitRealTime) || Infinity;
	this.limitSamples = Number(this.limitSamples) || Infinity;
	this.warmupSamples = Number(this.warmupSamples) || Infinity;

	return this;
};

/**
 * Return `null` if options are valid and test can run.
 *
 * @return {Error|null}
 */
RunOptions.prototype.areInvalid = function areInvalid () {
	var err;

	/*
	 * Used mainly internally, when running tests through fork function,
	 * but it is ok if user finds correct ID and uses it manually :).
	 */
	if (this.runOnlyIf) {
		if (typeof this.runOnlyIf === 'string' && this.runOnlyIf !== this.id) {
			err = new Error('`id` does not match `runOnlyIf`');
			err.code = 'ID_MISMATCH';
			return err;
		}
		if (Array.isArray(this.runOnlyIf) && this.runOnlyIf.indexOf(this.id) < 0) {
			err = new Error('`id` does not match any of `runOnlyIf`');
			err.code = 'ID_MISMATCH';
			return err;
		}
	}

	const ONE = BigInt(1);
	if (this.limitCPUTime < ONE && this.limitRealTime < ONE) {
		err = new Error('One of `limitCPUTime` or `limitRealTime` has to be set to non-zero value');
		err.code = 'VALUE_MISSING';
		return err;
	}

	if (this.warmupSamples === Infinity) {
		err = new Error('`warmupSamples` cannot be set to Infinity, or warmup phase would never end');
		err.code = 'VALUE_INVALID';
		return err;
	}

	if (this.limitCPUTime === Infinity && this.limitRealTime === Infinity && this.limitSamples === Infinity) {
		err = new Error('Limits cannot be all set to Infinity at the same time, or test would never end');
		err.code = 'VALUE_INVALID';
		return err;
	}

	return null;
};

/**
 * Return a "help text", suitable to output in CLI environment.
 *
 * @return {string}
 */
RunOptions.prototype.helpText = function helpText () {
	const name = path.basename(require.main.filename);
	const readmePath = path.join(path.dirname(__dirname), 'README.md');
	const readme = readFileSync(readmePath, 'utf8');
	const readmeCLI = readme.match(/[#]+ Usage \(CLI\)\n(?<usage>[\w\W]+?)\n+(?:[#]+|$)/);
	const usage = (readmeCLI && readmeCLI.groups.usage)
		|| `Read ${readmePath} for more information.`;

	return `Usage: ${name} [-c X] [-r X] [-s X] [-w X] [-p X] [-o X] [-b] file1.js ... fileN.js

${usage}`;
};

/**
 * Convert number to BigInt, unless it is Infinity or already a BigInt.
 *
 * @private
 * @param {number|bigint|Infinity} milliseconds
 * @return {bigint|Infinity}
 */
function toNanoseconds (milliseconds) {
	if (milliseconds === Infinity || typeof milliseconds === 'bigint') {
		return milliseconds;
	}
	else if (milliseconds instanceof NanoTime) {
		return milliseconds.value;
	}

	try {
		return BigInt(milliseconds) * MILLI2NANO;
	}
	catch (e) {
		if (!(e instanceof SyntaxError)) {
			console.error(e);
		}
		return NaN;
	}
}