@spark-ui/cli-utils
Version:
Spark CLI utils
179 lines (148 loc) • 4.92 kB
JavaScript
/* eslint-disable no-console */
/**
* CLI Integration Test Helper
* @author Andrés Zorro <zorrodg@gmail.com>
* @see https://gist.github.com/zorrodg/c349cf54a3f6d0a9ba62e0f4066f31cb
*/
import concat from 'concat-stream'
import spawn from 'cross-spawn'
import { existsSync } from 'fs'
import { constants } from 'os'
const PATH = process.env.PATH
/**
* Format CMD response as a JSON object
*/
function getReportFromData(data) {
const regex = /({[\s\S]*})/ // Regular expression to match object type
const match = data.match(regex)
const messages = data
.slice(0, match?.index ?? undefined)
.trim()
.split('\n')
.map(line => line.trim())
.filter(line => line)
const json = match ? JSON.parse(match[0]) : undefined
return [...messages, json]
}
/**
* Creates a child process with script path
* @param {string} processPath Path of the process to execute
* @param {Array} args Arguments to the command
* @param {Object} env (optional) Environment variables
*/
function createProcess(processPath, args = [], env = null) {
// Ensure that path exists
if (!processPath || !existsSync(processPath)) {
throw new Error('Invalid process path')
}
args = [processPath].concat(args)
// This works for node based CLIs, but can easily be adjusted to
// any other process installed in the system
return spawn('node', args, {
env: Object.assign(
{
NODE_ENV: 'test',
preventAutoStart: false,
PATH, // This is needed in order to get all the binaries in your current terminal
},
env
),
stdio: [null, null, null, 'ipc'], // This enables interprocess communication (IPC)
})
}
/**
* Creates a command and executes inputs (user responses) to the stdin
* Returns a promise that resolves when all inputs are sent
* Rejects the promise if any error
* @param {string} processPath Path of the process to execute
* @param {Array} args Arguments to the command
* @param {Array} inputs (Optional) Array of inputs (user responses)
* @param {Object} opts (optional) Environment variables
*/
function executeWithInput(processPath, args = [], inputs = [], opts = {}) {
if (!Array.isArray(inputs)) {
opts = inputs
inputs = []
}
const { env = null, timeout = 250, maxTimeout = 10000 } = opts
const childProcess = createProcess(processPath, args, env)
childProcess.stdin.setEncoding('utf-8')
let currentInputTimeout, killIOTimeout
// Creates a loop to feed user inputs to the child process in order to get results from the tool
// This code is heavily inspired (if not blantantly copied) from inquirer-test:
// https://github.com/ewnd9/inquirer-test/blob/6e2c40bbd39a061d3e52a8b1ee52cdac88f8d7f7/index.js#L14
const loop = inputs => {
if (killIOTimeout) {
clearTimeout(killIOTimeout)
}
if (!inputs.length) {
childProcess.stdin.end()
// Set a timeout to wait for CLI response. If CLI takes longer than
// maxTimeout to respond, kill the childProcess and notify user
killIOTimeout = setTimeout(() => {
console.error('Error: Reached I/O timeout')
childProcess.kill(constants.signals.SIGTERM)
}, maxTimeout)
return
}
currentInputTimeout = setTimeout(() => {
childProcess.stdin.write(inputs[0])
// Log debug I/O statements on tests
if (env && env.DEBUG) {
console.log('input:', inputs[0])
}
loop(inputs.slice(1))
}, timeout)
}
const promise = new Promise((resolve, reject) => {
// Get errors from CLI
childProcess.stderr.on('data', data => {
// Log debug I/O statements on tests
if (env && env.DEBUG) {
console.log('error:', data.toString())
}
})
// Get output from CLI
childProcess.stdout.on('data', data => {
// Log debug I/O statements on tests
if (env && env.DEBUG) {
console.log('output:', data.toString())
}
})
childProcess.stderr.once('data', err => {
childProcess.stdin.end()
if (currentInputTimeout) {
clearTimeout(currentInputTimeout)
inputs = []
}
reject(err.toString())
})
childProcess.on('error', reject)
// Kick off the process
loop(inputs)
childProcess.stdout.pipe(
concat(result => {
if (killIOTimeout) {
clearTimeout(killIOTimeout)
}
resolve(getReportFromData(result.toString()))
})
)
})
// Appending the process to the promise, in order to
// add additional parameters or behavior (such as IPC communication)
promise.attachedProcess = childProcess
return promise
}
export default {
create: processPath => {
const fn = (...args) => executeWithInput(processPath, ...args)
return {
execute: fn,
}
},
}
export const DOWN = '\x1B\x5B\x42'
export const UP = '\x1B\x5B\x41'
export const ENTER = '\x0D'
export const SPACE = '\x20'