import url from 'node:url'
import path from 'node:path'
import chokidar from 'chokidar'
import pickBy from 'lodash.pickby'
import flattenDeep from 'lodash.flattendeep'
import union from 'lodash.union'
import logger from '@wdio/logger'
import { FileSystemPathService } from '@wdio/config/node'
import type { Capabilities, Workers } from '@wdio/types'
import Launcher from './launcher.js'
import type { RunCommandArguments, ValueKeyIteratee } from './types.js'
const log = logger('@wdio/cli:watch')
type Spec = string | string[]
export default class Watcher {
private _launcher: Launcher
private _specs: Spec[] = []
constructor (
private _configFile: string,
private _args: Omit<RunCommandArguments, 'configPath'>
) {
log.info('Starting launcher in watch mode')
this._launcher = new Launcher(this._configFile, this._args, true)
}
async watch () {
await this._launcher.initialize()
const specs = this._launcher.configParser.getSpecs()
const capSpecs = this._launcher.isMultiremote
? []
: union(flattenDeep(
(this._launcher.configParser.getCapabilities() as Capabilities.RequestedStandaloneCapabilities[]).map(cap => (
'alwaysMatch' in cap ? cap.alwaysMatch['wdio:specs'] : cap['wdio:specs'] || []
) as string[])
))
this._specs = [...specs, ...capSpecs]
* listen on spec changes and rerun specific spec file
*/
const flattenedSpecs = flattenDeep(this._specs).map((fileUrl) => url.fileURLToPath(fileUrl))
chokidar.watch(flattenedSpecs, { ignoreInitial: true })
.on('add', this.getFileListener())
.on('change', this.getFileListener())
* listen on filesToWatch changes an rerun complete suite
*/
const { filesToWatch } = this._launcher.configParser.getConfig()
if (filesToWatch.length) {
const pathService = new FileSystemPathService()
const rootDir = path.dirname(path.resolve(process.cwd(), this._configFile))
const globbedFilesToWatch = filesToWatch.map((file) => pathService.ensureAbsolutePath(file, rootDir))
chokidar.watch(globbedFilesToWatch, { ignoreInitial: true })
.on('add', this.getFileListener(false))
.on('change', this.getFileListener(false))
}
* run initial test suite
*/
await this._launcher.run()
* clean interface once all worker finish
*/
const workers = this.getWorkers()
Object.values(workers).forEach((worker) => worker.on('exit', () => {
* check if all workers have finished
*/
if (Object.values(workers).find((w) => w.isBusy)) {
return
}
this._launcher.interface?.finalise()
}))
}
* return file listener callback that calls `run` method
* @param {Boolean} [passOnFile=true] if true pass on file change as parameter
* @return {Function} chokidar event callback
*/
getFileListener (passOnFile = true) {
return (spec: string) => {
const runSpecs: Spec[] = []
let singleSpecFound: boolean = false
for (let index = 0, length = this._specs.length; index < length; index += 1) {
const value = this._specs[index]
if (Array.isArray(value) && value.indexOf(spec) > -1) {
runSpecs.push(value)
} else if ( !singleSpecFound && spec === value) {
singleSpecFound = true
runSpecs.push(value)
}
}
if (runSpecs.length === 0) {
runSpecs.push(url.pathToFileURL(spec).href)
}
const { spec: _, ...args } = this._args
return runSpecs.map((spec) => {
return this.run({
...args,
...(passOnFile ? { spec: [spec] as string[] } : {})
})
})
}
}
* helper method to get workers from worker pool of wdio runner
* @param predicate filter by property value (see lodash.pickBy)
* @param includeBusyWorker don't filter out busy worker (default: false)
* @return Object with workers, e.g. {'0-0': { ... }}
*/
getWorkers (predicate?: ValueKeyIteratee<Workers.Worker> | null | undefined, includeBusyWorker?: boolean): Workers.WorkerPool {
if (!this._launcher.runner) {
throw new Error('Internal Error: no runner initialized, call run() first')
}
let workers = this._launcher.runner.workerPool
if (typeof predicate === 'function') {
workers = pickBy(workers, predicate)
}
* filter out busy workers, only skip if explicitly desired
*/
if (!includeBusyWorker) {
workers = pickBy(workers, (worker) => !worker.isBusy)
}
return workers
}
* run workers with params
* @param params parameters to run the worker with
*/
run (params: Partial<RunCommandArguments> = {}) {
const workers = this.getWorkers(
(params.spec
? (worker) => Boolean(worker.specs.find((s) => params.spec?.includes(s)))
: undefined)
)
* don't do anything if no worker was found
*/
if (Object.keys(workers).length === 0 || !this._launcher.interface) {
return
}
* update total worker count interface
* ToDo: this should have a cleaner solution
*/
this._launcher.interface.totalWorkerCnt = Object.entries(workers).length
* clean up interface
*/
this.cleanUp()
* trigger new run for non busy worker
*/
for (const [, worker] of Object.entries(workers)) {
const { cid, capabilities, specs, sessionId } = worker
const { hostname, path, port, protocol, automationProtocol } = worker.config
const args = Object.assign({ sessionId, baseUrl: worker.config.baseUrl, hostname, path, port, protocol, automationProtocol }, params)
worker.postMessage('run', args)
this._launcher.interface.emit('job:start', { cid, caps: capabilities, specs })
}
}
cleanUp () {
this._launcher.interface?.setup()
}
}