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 ) { 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) { // Only need to run a singleFile once - so avoid duplicates singleSpecFound = true runSpecs.push(value) } } // If the runSpecs array is empty, then this must be a new file/array // so add the spec directly to the runSpecs if (runSpecs.length === 0) { runSpecs.push(url.pathToFileURL(spec).href) } // Do not pass the `spec` command line option to `this.run()` 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 | 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 = {}) { 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() } }