import path from 'path' import fs from 'fs' import fsPromise from 'fs/promises' import cluster from 'cluster' import child_process from 'child_process' import kill from './kill.mjs' import Watcher, { EVENT_REMOVE, EVENT_UPDATE } from './watch/index.mjs' export const MESSAGE_FILES_REQUEST = 'message_files_request' export const MESSAGE_FILES_PAYLOAD = 'message_files_payload' export const MESSAGE_RUN_FINISHED = 'message_run_finished' export function createMessage(type, data = null) { return { messageType: type, data: data, } } const RegexCache = new Map() function targetToRegex(target) { let regex = RegexCache.get(target) if (!regex) { let parsed = target.startsWith('./') ? target.slice(2) : target parsed = parsed.endsWith('/') ? parsed + '*' : parsed RegexCache.set(target, regex = new RegExp('^' + parsed.replace(/\./g, '\\.') .replace(/\*\*\/?/g, '&&&&&&&&&&&&&') .replace(/\*/g, '[^/]+') .replace(/&&&&&&&&&&&&&/g, '.*') + '$' )) } return regex } export function CLI(e, overrides = {}) { this.e = e this.ac = new AbortController() this.cluster = overrides.cluster || cluster this.logger = overrides.logger || console this.child_process = overrides.child_process || child_process this.process = overrides.process || process this.kill = overrides.kill || kill this.importer = overrides.importer this.loadDefaults() } CLI.prototype.loadDefaults = function() { // Eltro specific options this.reporter = 'list' this.ignoreOnly = false this.timeout = 2000 // Cli specific options this.watch = null this.watcher = null this.worker = null this.run = 'test' this.isSlave = this.cluster.isWorker || false this.targets = ['test/**'] this.files = [] } CLI.prototype.fileMatchesTarget = function(path) { for (let target of this.targets) { if (targetToRegex(target).test(path)) { return true } } return false } CLI.prototype.parseOptions = function(args) { if (!args || !args.length) { this.targets.push('test/**') return Promise.resolve() } this.targets.splice(0, this.targets.length) for (let i = 0; i < args.length; i++) { if (args[i] === '-r' || args[i] === '--reporter') { if (!args[i + 1] || (args[i + 1] !== 'list' && args[i + 1] !== 'dot')) { return Promise.reject(new Error('Reporter was missing or invalid. Only "list" and "dot" are supported.')) } this.reporter = args[i + 1] i++ } else if (args[i] === '-t' || args[i] === '--timeout') { if (!args[i + 1] || isNaN(Number(args[i + 1]))) { return Promise.reject(new Error('Timeout was missing or invalid')) } this.timeout = Number(args[i + 1]) i++ } else if (args[i] === '-w' || args[i] === '--watch') { if (!args[i + 1] || args[i + 1][0] === '-') { return Promise.reject(new Error('Watch was missing or invalid')) } this.watch = args[i + 1] i++ } else if (args[i] === '-n' || args[i] === '--npm') { if (!args[i + 1] || args[i + 1][0] === '-') { return Promise.reject(new Error('Npm was missing or invalid')) } this.run = args[i + 1] i++ } else if (args[i] === '--ignore-only') { this.ignoreOnly = true } else if (args[i][0] === '-') { return Promise.reject(new Error(`Unknown option ${args[i]}`)) } else { this.targets.push(args[i]) } } if (!this.targets.length && this.run === 'test') { this.targets.push('test/**') } return Promise.resolve() } CLI.prototype.startWatcher = async function() { if (!this.watch || this.isSlave) { return Promise.resolve() } let packageJson try { packageJson = JSON.parse(await fsPromise.readFile('package.json')) } catch (err) { throw new Error(`package.json was missing or invalid JSON: ${err.message}`) } let currentGroup = packageJson.watch && packageJson.watch[this.watch] if (!currentGroup || !currentGroup.patterns) { throw new Error(`package.json was missing watch property or missing ${this.watch} in watch or missing pattern`) } if (!currentGroup.extensions) { currentGroup.extensions = 'js,mjs' } else if (!currentGroup.extensions.match(/^([a-zA-Z]{2,3})(,[a-zA-Z]{2,3})*$/)) { throw new Error(`package.json watch ${this.watch} extension "${currentGroup.extensions}" was invalid`) } return new Promise((res, rej) => { this.watcher = new Watcher(currentGroup.patterns, { quickNativeCheck: true, delay: currentGroup.delay || 200, skip: function(name) { return name.indexOf('node_modules') >= 0 }, filter: new RegExp(currentGroup.extensions.split(',').map(x => `(\\.${x}$)`).join('|')) }) this.watcher.once('error', rej) this.watcher.once('ready', () => { this.watcher.off('error', rej) res() }) }) } CLI.prototype.getFiles = function() { if (this.isSlave) { return this._askMasterForFiles() } else { return this._processTargets() } } CLI.prototype._askMasterForFiles = function() { return new Promise(res => { const handler = (payload) => { if (isMessageInvalid(payload, MESSAGE_FILES_PAYLOAD)) return this.process.off('message', handler) res(this.files = payload.data) } this.process.on('message', handler) this.process.send(createMessage(MESSAGE_FILES_REQUEST)) }) } CLI.prototype._processTargets = function() { this.files.splice(0, this.files.length) if (!this.targets.length) { return Promise.resolve() } return Promise.all(this.targets.map((target) => { return getFilesFromTarget(this.files, target) })).then(() => { if (!this.files.length) { this.errored = 'empty' } return this.files }) } CLI.prototype.loadFiles = async function() { if (!this.isSlave && this.watch) { return Promise.resolve() } this.e.begin() let cwd = this.process.cwd() for (let i = 0; i < this.files.length; i++) { if (this.files[i].endsWith('.mjs') || this.files[i].endsWith('.js')) { try { this.e.setFilename(this.files[i]) await this.import('file:///' + path.join(cwd, this.files[i])) this.e.resetFilename() } catch (e) { let newError = new Error(`Error while loading ${this.files[i]}`) newError.inner = e throw newError } } } } CLI.prototype.beginRun = async function() { if (this.watcher) { return this._runWorker() } else { return this._runTests() } } CLI.prototype._runTests = function() { this.e.reporter = this.reporter this.e.ignoreOnly = this.ignoreOnly this.e.__timeout = this.timeout return this.e.run().then((stats) => { if (this.isSlave) { this.process.send(createMessage(MESSAGE_RUN_FINISHED, { stats: stats })) } return stats }) } CLI.prototype._runWorker = function() { let lastStats = null const messageHandler = (payload) => { if (isMessageInvalid(payload, MESSAGE_RUN_FINISHED)) return lastStats = payload.data.stats } const changeHandler = (evt, name) => { if (evt === EVENT_UPDATE && !this.files.includes(name) && this.fileMatchesTarget(name)) { this.files.push(name) } else if (evt === EVENT_REMOVE) { let index = this.files.indexOf(name) if (index >= 0) { this.files.splice(index, 1) } } } const changedHandler = () => { this.runProgram() } return new Promise(res => { const cleanup = () => { this.process.off('message', messageHandler) this.watcher.off('change', changeHandler) this.watcher.off('changed', changedHandler) res(lastStats) } this.process.on('message', messageHandler) this.watcher.on('change', changeHandler) this.watcher.on('changed', changedHandler) this.ac.signal.addEventListener('abort', cleanup, { once: true }); changedHandler() }) } CLI.prototype.runProgram = function() { let runningTest = this.run === 'test' if (this.worker) { if (runningTest) { return } else { this.kill(this.worker.pid) } } let worker if (runningTest) { worker = this.worker = this.cluster.fork() } else { worker = this.worker = this.child_process.spawn('npm', ['run', this.run]) } worker.once('exit', (exitCode) => { if (this.worker !== worker) return let currentTime = new Date().toISOString().split('T')[1].split('.')[0] if (!runningTest) { console.log() } if (exitCode > 0) { console.error(`\x1b[31m[${this.watch}] ${currentTime}: Exited with error code ${exitCode}. Waiting for file changes before running again...\x1b[0m`) } else { console.error(`\x1b[32m[${this.watch}] ${currentTime}: Ran successfully. Waiting for file changes before running again...\x1b[0m`) } this.worker = null }) if (runningTest) { worker.on('message', (msg) => { if (isMessageValid(msg, MESSAGE_FILES_REQUEST)) { worker.send(createMessage(MESSAGE_FILES_PAYLOAD, this.files)) } }) } else { worker.stdout.on('data', (d) => { this.process.stdout.write(d.toString()) }) worker.stderr.on('data', (d) => { this.process.stderr.write(d.toString()) }) } } CLI.prototype.import = function(path) { if (this.importer) { return this.importer(path) } return import(path) } function isMessageInvalid(payload, messageType) { if (!payload || typeof(payload) !== 'object' || typeof(payload.messageType) !== 'string' || payload.messageType !== messageType || (payload.data != null && typeof(payload.data) !== 'object')) { return true } return false } function isMessageValid(payload, messageType) { return !isMessageInvalid(payload, messageType) } function traverseFolder(files, curr, match, insidePath, grabAll, insideStar, includeFiles) { return new Promise(function(resolve, reject) { return fs.readdir(curr, function(err, data) { if (err) return reject(new Error('unable to read directory ' + curr + ': ' + err.message)) resolve(Promise.all(data.map(function(file) { return new Promise(function(res, rej) { fs.lstat(path.join(curr, file), function(err, stat) { if (err) return rej(new Error('unable to read file or directory ' + path.join(curr, file) + ': ' + err.message)) if ((includeFiles || grabAll) && stat.isFile()) { if (!match || fileMatches(file, match)) { files.push(path.join(insidePath, file).replace(/\\/g, '/')) return res(files) } } if (stat.isDirectory() && grabAll) { return res(traverseFolder(files, path.join(curr, file), match, path.join(insidePath, file), grabAll, insideStar, includeFiles)) } else if (stat.isDirectory() && match) { return res(getFilesFromTarget(files, match, path.join(insidePath, file), grabAll, insideStar)) } res(null) }) }) })).then(function() { return files })) }) }) } export function fileMatches(filename, match) { return Boolean(filename.match(new RegExp(match.replace(/\./, '\\.').replace(/\*/, '.*')))) } export function getFilesFromTarget(files, match, insidePath, grabAll, insideStar) { let isGrabbingAll = grabAll || false let isStarred = insideStar || false let cwd = process.cwd() let currPath = insidePath || '' let curr = path.join(cwd, currPath || '') return new Promise(function(res, rej) { let start = 0 let splitted = match.split('/') if (splitted[start] === '.') { start++ } if (splitted[splitted.length - 1] === '') { splitted[splitted.length - 1] === '*' } let first = splitted[start] if (splitted.length > start + 1) { if (first === '**') { isGrabbingAll = isStarred = true return traverseFolder(files, curr, splitted.slice(start + 1).join('/'), currPath, isGrabbingAll, isStarred, false) .then(res, rej) } else if (first === '*') { isStarred = true return traverseFolder(files, curr, splitted.slice(start + 1).join('/'), currPath, isGrabbingAll, isStarred, false) .then(res, rej) } return getFilesFromTarget(files, splitted.slice(start + 1).join('/'), path.join(currPath, first), grabAll, isStarred) .then(res, rej) } else if (first.indexOf('*') >= 0) { if (first === '**') { isGrabbingAll = isStarred = true } return traverseFolder(files, curr, first === '*' || first === '**'? '' : first, currPath, isGrabbingAll, isStarred, true) .then(res, rej) } fs.lstat(path.join(curr, first), function(err, stat) { if (err) { // If we're inside a star, we ignore files we cannot find if (isStarred) { return res(files) } return rej(new Error('file ' + path.join(insidePath, first) + ' could not be found: ' + err.message)) } if (stat.isDirectory()) { return traverseFolder(files, path.join(curr, first), '', path.join(currPath, first), true, true, true) .then(res, rej) } files.push(path.join(currPath, match).replace(/\\/g, '/')) return res(files) }) }) } export function printError(err, msg, clean = false) { let before = msg || '' if (!clean) console.error('') console.error('\x1b[31m ' + before + err.toString() + '\x1b[0m\n \x1b[90m' + (err.stack || '').replace(err.toString(), '')) console.error('\x1b[0m') }