suitest-js-api
Version:
Suitest is a test automation and device manipulation tool for living room devices and web browsers.
388 lines (333 loc) • 10.9 kB
JavaScript
const ramda = require('ramda');
const path = require('path');
const moment = require('moment');
const cp = require('child_process');
const keypress = require('keypress');
const validation = require('../validation');
const launcherHelper = require('./launcherLoggerHelper');
const {snippets: log} = require('../testLauncher/launcherLogger');
const texts = require('../texts');
const Queue = require('./Queue');
const {registerProcess} = require('../testLauncher/processReaper');
const envVars = require('../constants/environment');
const {version} = require('../../package.json');
const {stripAnsiChars} = require('./stringUtils');
const {TEST_COMMAND} = require('../constants/modes');
const {fetchLatestSuitestVersion, warnNewVersionAvailable} = require('./packageMetadataHelper');
const ipcServer = require('../testLauncher/ipc/server');
const messageId = require('../constants/ipcMessageId');
const suitest = require('../../index');
const {logger} = suitest;
let suitestVersion = '';
/**
* Handle test launcher command error
* @param {Error} error
*/
function handleLauncherError(error) {
if (error.exit) {
error.exit(1, logger);
} else {
logger.error(error);
process.exit(1);
}
}
/**
* Handle test launcher child process result
* @param {boolean} finishedWithErrors
*/
function handleChildResult(finishedWithErrors) {
if (finishedWithErrors) {
process.exit(1);
} else {
process.exit(0);
}
}
/**
* Validate test launcher's args based on session type
* @param {'TEST_COMMAND'|'TOKEN'} type session type
* @param {Object} args test launcher arguments
*/
function validateInput(type, args) {
try {
const msg = type === TEST_COMMAND
? texts['errorType.suitestTestCommand']()
: texts['errorType.suitestCommand']();
validation.validate(
validation.validators[`TEST_LAUNCHER_${type}`],
args, msg,
);
} catch (error) {
log.argsValidationError(error);
return process.exit(1);
}
}
/* istanbul ignore next */
function getLogFilePath(deviceId, deviceName, startTime, logDir) {
const [shortId] = deviceId.split('-');
const shortName = deviceName.replace(/[^a-z0-9]/ig, '-');
const timePrefix = moment(startTime).format('YYYYMMDD-HHmmss');
const fileName = `${timePrefix}-${shortId}-${shortName}.log`;
return `${path.resolve(logDir)}${path.sep}${fileName}`;
}
/**
* If logDir specified creates and return writeStream
* @param {Object} device
* @param {Array} userArgs
* @param {Date} startTime
* @param {string} [logDir]
* @returns {WritableStream}
*/
function getDeviceLogStream(device, userArgs, startTime, logDir) {
let writeStream = null;
const {deviceId} = device;
const deviceName = device.manufacturer + ' ' + device.model;
if (!logDir)
return writeStream;
/* istanbul ignore next */
try {
const logPath = getLogFilePath(deviceId, deviceName, startTime, logDir);
launcherHelper.mkDirByPathSync(logDir);
writeStream = launcherHelper.createWriteStream(logPath);
} catch (e) {
/* istanbul ignore if */
if (e.exit) {
e.exit();
} else {
logger.error(e);
process.exit(1);
}
}
return writeStream;
}
/**
* Check if debug argument presents and return debug option
* @param {Object} ownArgw - implicitly derived launched parameters
* @returns {string | boolean}
*/
function getDebugOption({inspectBrk, inspect}) {
const debuggerType = ramda.toPairs(ramda.pickBy(v => ramda.is(Number, v) || ['true', true].includes(v), {
'--inspect-brk': inspectBrk,
'--inspect': inspect,
}));
return debuggerType[0]
? `${debuggerType[0][0]}${ramda.is(Number, debuggerType[0][1]) ? '=' + debuggerType[0][1] : ''}`
: false;
}
function isDebugMode({inspectBrk, inspect}) {
const hasDebuggerArgument = [inspectBrk, inspect].find(v => ramda.is(Number, v) || ['true', true].includes(v));
const hasDebuggerExecArgv = process.execArgv.find(a => a.includes('--inspect'));
return Boolean(hasDebuggerArgument || hasDebuggerExecArgv);
}
/**
* Throw error and exit process when user wants to run debugging with multiply devices
*/
function throwDebugForManyDevicesError() {
logger.error(texts['tl.inspectOnlyForSingleDevice']());
process.exit(1);
}
/**
* Creates new process instance for the test execution on a device.
* @param {Array} cmdArgv - user test command and it's parameters
* @param {Object} ownArgv - implicitly derived launcher parameters
* @param {Object} options - child process environment options
* @param {Boolean} singleDeviceOnly - overall execution will be ran for single device
* @returns {ChildProcess} - spawned child process
*/
async function launchChild(cmdArgv, ownArgv, options, singleDeviceOnly) {
const [command, ...args] = cmdArgv;
const debugOption = getDebugOption(ownArgv);
if (debugOption) {
args.unshift(debugOption);
}
if (singleDeviceOnly) {
options.stdio = 'pipe';
}
return cp.spawn(
command,
args,
options,
);
}
/**
* Save child output to the file
* @param {ChildProcess} child
* @param {Buffer} chunk
* @param {WritableStream} [logStream]
*/
function logOutput(child, chunk, logStream) {
logStream && logStream.write(stripAnsiChars(chunk.toString()));
}
/**
* When running repl in the spawned subprocess process.stdin.setRawMode(true)
* needs to be called to enable sending terminal codes.
* When repl ends the node process for some reason does not want to take over
* the ownership of the terminal anymore (setRawMode(false) does not help).
* This function manually enables ctrl+c together with the keypress module
* @param {string} ch
* @param {Object} key
*/
function keypressListener(ch, key) {
/* istanbul ignore next */
if (key.name === 'c' && key.ctrl)
process.emit('SIGINT');
}
/**
* Add launcher process IPC message listeners
*/
function addLauncherIpcListeners() {
ipcServer.addListener(messageId.REPL_START, () => {
process.stdin.removeListener('keypress', keypressListener);
if (process.stdout.isTTY) {
process.stdin.setRawMode(true);
} else {
logger.warn(texts.replNotATty());
}
process.stdin.resume();
ipcServer.broadcast(messageId.REPL_INIT, {isTTY: !!process.stdout.isTTY});
});
ipcServer.addListener(messageId.REPL_STOP, () => {
process.stdin.addListener('keypress', keypressListener);
});
}
/**
* @param {ChildProcess} child - the child process instance running the test
* @param {Function} onExit - callback function to call when the process finishes
* @param {WritableStream} logStream - the output stream of the child process.
* @param {Boolean} singleDeviceOnly - overall execution will be ran for single device
*/
function attachIO(child, onExit, logStream, singleDeviceOnly) {
const [childIn, childOut, childErr] = child.stdio;
const logIo = chunk => logOutput(child, chunk, logStream);
registerProcess(child);
// for single device execution pipe stdin so that console is not confused by REPL
if (singleDeviceOnly) {
keypress(process.stdin);
process.stdin.pipe(childIn);
childIn.on('data', logIo);
}
// Always pipe child's out and err io.
childOut.pipe(process.stdout);
childErr.pipe(process.stdout);
childOut.on('data', logIo);
childErr.on('data', logIo);
child.on('exit', onExit);
}
/**
* Initiates test execution on a device
*
* @param {Array} cmdArgv - user test command and it's parameters
* @param {Object} ownArgv - implicitly derived launcher parameters
* @param {Object} device - full device information
* @param {Boolean} singleDeviceOnly - overall execution will be ran for single device
* @param {number} ipcPort
* @returns {Promise<any>}
*/
async function runTestOnDevice(cmdArgv, ownArgv, device, singleDeviceOnly, ipcPort) {
const {logDir} = ownArgv;
const options = getChildOptions(device, ipcPort);
const child = await launchChild(cmdArgv, ownArgv, options, singleDeviceOnly);
const logStream = getDeviceLogStream(device, cmdArgv, new Date(), logDir);
return new Promise(resolve => {
logStream && logStream.write(texts.fileLogSummary(
cmdArgv.join(' '),
ownArgv.orgId,
ownArgv.appConfigId,
new Date(),
));
const onExit = (exitCode, signal) => {
logStream && logStream.write(texts.fileLogCompleted(
exitCode, signal,
));
resolve(exitCode, signal);
};
attachIO(child, onExit, logStream, singleDeviceOnly);
});
}
/**
* Prepares environment for the child process.
*
* @param {string} device - device object. deviceId and config should be provided.
* @param {number} port - ipc port
* @returns {{shell: true, env: Object}}
*/
function getChildOptions(device, port) {
return {
shell: true,
env: {
...process.env,
[envVars.SUITEST_CHILD_PROCESS]: `${device.deviceId}|${device.config}|${port}`,
[envVars.SUITEST_DEVICE_ID]: device.deviceId,
[envVars.SUITEST_PRESET_NAME]: device.presetName,
FORCE_COLOR: true,
NODE_NO_READLINE: 1, // enable repl in advanced consoles
},
};
}
/**
* Runs all devices by spawning subprocesses
*
* @param {Array} cmdArgv - user command and it's arguments
* @param {Object} ownArgv - implicitly derived parameters
* @param {Array} devices - array with items containing full device information
* @param {number} ipcPort - ipc port number
* @returns {Promise<boolean>} - finished with errors or not.
*/
function runAllDevices(cmdArgv, ownArgv, devices, ipcPort) {
const tests = devices.map(device => () => runTestOnDevice(
cmdArgv,
ownArgv,
device,
devices.length === 1,
ipcPort,
));
const queue = new Queue(ownArgv.concurrency, tests);
logger.intro(
texts.launcherSummary,
cmdArgv.join(' '),
ownArgv.logDir,
...devices.map(device => device.displayName),
);
return queue.start().then(result => {
const failedDevices = result.filter(
res => {
const code = res.result.code || res.result;
return res.result.error || code !== 0;
},
);
log.final(failedDevices.length, result.length - failedDevices.length);
warnNewVersionAvailable(logger, version, suitestVersion);
return failedDevices.length !== 0;
});
}
/**
* Get's the version of the latest published suitest-js-api package
* @returns {Promise<String>}
*/
async function getVersion() {
suitestVersion = await fetchLatestSuitestVersion();
return suitestVersion;
}
/**
* Increase event emitter max allowed listeners count by 2 per child thread
* @param {EventEmitter} emitter
* @param {number} deviceCount
* @param {number} concurrency
* @modifies emitter
*/
function increaseMaxListeners(emitter, deviceCount, concurrency= 0) {
const threadCount = concurrency === 0 ? deviceCount : Math.min(concurrency, deviceCount);
emitter.setMaxListeners(emitter.getMaxListeners() + threadCount * 2);
}
module.exports = {
handleLauncherError,
handleChildResult,
validateInput,
runTestOnDevice,
runAllDevices,
getVersion,
isDebugMode,
getDebugOption,
addLauncherIpcListeners,
throwDebugForManyDevicesError,
increaseMaxListeners,
};