446 lines
13 KiB
JavaScript
446 lines
13 KiB
JavaScript
import path from 'path'
|
|
import fs from 'fs'
|
|
import fsPromise from 'fs/promises'
|
|
import cluster from 'cluster'
|
|
import child_process from 'child_process'
|
|
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.importer = overrides.importer
|
|
|
|
// 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.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.worker.kill()
|
|
}
|
|
}
|
|
|
|
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) {
|
|
let before = msg || ''
|
|
console.error('')
|
|
console.error('\x1b[31m '
|
|
+ before + err.toString()
|
|
+ '\x1b[0m\n \x1b[90m'
|
|
+ (err.stack || '').replace(err.toString(), ''))
|
|
console.error('\x1b[0m')
|
|
}
|