UNPKG

@wdio/local-runner

Version:
428 lines (416 loc) 12.4 kB
// src/index.ts import logger2 from "@wdio/logger"; import { WritableStreamBuffer } from "stream-buffers"; // src/worker.ts import url from "node:url"; import path from "node:path"; import child from "node:child_process"; import { EventEmitter } from "node:events"; import logger from "@wdio/logger"; // src/transformStream.ts import split from "split2"; import { Transform } from "node:stream"; // src/constants.ts var SHUTDOWN_TIMEOUT = 5e3; var DEBUGGER_MESSAGES = [ "Debugger listening on", "Debugger attached", "Waiting for the debugger" ]; var BUFFER_OPTIONS = { initialSize: 1e3 * 1024, // start at 100 kilobytes. incrementAmount: 100 * 1024 // grow by 10 kilobytes each time buffer overflows. }; // src/transformStream.ts function runnerTransformStream(cid, inputStream, aggregator) { return inputStream.pipe(split(/\r?\n/, (line) => `${line} `)).pipe(ignore(DEBUGGER_MESSAGES)).pipe(map((line) => { const newLine = `[${cid}] ${line}`; aggregator?.push(newLine); return newLine; })); } function ignore(patternsToIgnore) { return new Transform({ decodeStrings: false, transform(chunk, encoding, next) { if (patternsToIgnore.some((m) => chunk.startsWith(m))) { return next(); } return next(null, chunk); }, final(next) { this.unpipe(); next(); } }); } function map(mapper) { return new Transform({ decodeStrings: false, transform(chunk, encoding, next) { return next(null, mapper(chunk)); }, final(next) { this.unpipe(); next(); } }); } // src/repl.ts import WDIORepl from "@wdio/repl"; var WDIORunnerRepl = class extends WDIORepl { childProcess; callback; commandIsRunning = false; constructor(childProcess, options) { super(options); this.childProcess = childProcess; } _getError(params) { if (!params.error) { return null; } const err = new Error(params.message); err.stack = params.stack; return err; } eval(cmd, context, filename, callback) { if (this.commandIsRunning) { return; } this.commandIsRunning = true; this.childProcess.send({ origin: "debugger", name: "eval", content: { cmd } }); this.callback = callback; } onResult(params) { const error = this._getError(params); if (this.callback) { this.callback(error, params.result); } this.commandIsRunning = false; } start(context) { this.childProcess.send({ origin: "debugger", name: "start" }); return super.start(context); } }; // src/replQueue.ts var ReplQueue = class { _repls = []; runningRepl; add(childProcess, options, onStart, onEnd) { this._repls.push({ childProcess, options, onStart, onEnd }); } next() { if (this.isRunning || this._repls.length === 0) { return; } const nextRepl = this._repls.shift(); if (!nextRepl) { return; } const { childProcess, options, onStart, onEnd } = nextRepl; const runningRepl = this.runningRepl = new WDIORunnerRepl(childProcess, options); onStart(); runningRepl.start().then(() => { const ev = { origin: "debugger", name: "stop" }; runningRepl.childProcess.send(ev); onEnd(ev); delete this.runningRepl; this.next(); }); } get isRunning() { return Boolean(this.runningRepl); } }; // src/stdStream.ts import { Transform as Transform2 } from "node:stream"; // src/utils.ts function removeLastListener(target, eventName) { const listener = target.listeners(eventName).reverse()[0]; if (listener) { target.removeListener(eventName, listener); } } // src/stdStream.ts var RunnerStream = class extends Transform2 { constructor() { super(); this.on("pipe", () => { removeLastListener(this, "close"); removeLastListener(this, "drain"); removeLastListener(this, "error"); removeLastListener(this, "finish"); removeLastListener(this, "unpipe"); }); } _transform(chunk, _encoding, callback) { callback(void 0, chunk); } _final(callback) { this.unpipe(); callback(); } }; // src/worker.ts var log = logger("@wdio/local-runner"); var replQueue = new ReplQueue(); var __dirname = path.dirname(url.fileURLToPath(import.meta.url)); var ACCEPTABLE_BUSY_COMMANDS = ["workerRequest", "endSession"]; var stdOutStream = new RunnerStream(); var stdErrStream = new RunnerStream(); stdOutStream.pipe(process.stdout); stdErrStream.pipe(process.stderr); var WorkerInstance = class extends EventEmitter { cid; config; configFile; // requestedCapabilities caps; // actual capabilities returned by driver capabilities; specs; execArgv; retries; stdout; stderr; childProcess; sessionId; server; logsAggregator = []; instances; isMultiremote; isBusy = false; isKilled = false; isReady; isSetup; isReadyResolver = () => { }; isSetupResolver = () => { }; /** * assigns paramters to scope of instance * @param {object} config parsed configuration object * @param {string} cid capability id (e.g. 0-1) * @param {string} configFile path to config file (for sub process to parse) * @param {object} caps capability object * @param {string[]} specs list of paths to test files to run in this worker * @param {number} retries number of retries remaining * @param {object} execArgv execution arguments for the test run */ constructor(config, { cid, configFile, caps, specs, execArgv, retries }, stdout, stderr) { super(); this.cid = cid; this.config = config; this.configFile = configFile; this.caps = caps; this.capabilities = caps; this.specs = specs; this.execArgv = execArgv; this.retries = retries; this.stdout = stdout; this.stderr = stderr; this.isReady = new Promise((resolve) => { this.isReadyResolver = resolve; }); this.isSetup = new Promise((resolve) => { this.isSetupResolver = resolve; }); } /** * spawns process to kick of wdio-runner */ startProcess() { const { cid, execArgv } = this; const argv = process.argv.slice(2); const runnerEnv = Object.assign({ NODE_OPTIONS: "--enable-source-maps" }, process.env, this.config.runnerEnv, { WDIO_WORKER_ID: cid, NODE_ENV: process.env.NODE_ENV || "test" }); if (this.config.outputDir) { runnerEnv.WDIO_LOG_PATH = path.join(this.config.outputDir, `wdio-${cid}.log`); } runnerEnv.NODE_OPTIONS = process.env.NODE_OPTIONS + " " + (runnerEnv.NODE_OPTIONS || ""); log.info(`Start worker ${cid} with arg: ${argv.join(" ")}`); const childProcess = this.childProcess = child.fork(path.join(__dirname, "run.js"), argv, { cwd: process.cwd(), env: runnerEnv, execArgv, stdio: ["inherit", "pipe", "pipe", "ipc"] }); childProcess.on("message", this._handleMessage.bind(this)); childProcess.on("error", this._handleError.bind(this)); childProcess.on("exit", this._handleExit.bind(this)); if (!process.env.WDIO_UNIT_TESTS) { if (childProcess.stdout !== null) { if (this.config.groupLogsByTestSpec) { runnerTransformStream(cid, childProcess.stdout, this.logsAggregator); } else { runnerTransformStream(cid, childProcess.stdout).pipe(stdOutStream); } } if (childProcess.stderr !== null) { runnerTransformStream(cid, childProcess.stderr).pipe(stdErrStream); } } return childProcess; } _handleMessage(payload) { const { cid, childProcess } = this; if (payload.name === "finishedCommand") { this.isBusy = false; } if (payload.name === "ready") { this.isReadyResolver(true); } if (payload.name === "sessionStarted") { this.isSetupResolver(true); if (payload.content.isMultiremote) { Object.assign(this, payload.content); } else { this.sessionId = payload.content.sessionId; this.capabilities = payload.content.capabilities; Object.assign(this.config, payload.content); } } if (childProcess && payload.origin === "debugger" && payload.name === "start") { replQueue.add( childProcess, { prompt: `[${cid}] \u203A `, ...payload.params }, () => this.emit("message", Object.assign(payload, { cid })), (ev) => this.emit("message", ev) ); return replQueue.next(); } if (replQueue.isRunning && payload.origin === "debugger" && payload.name === "result") { replQueue.runningRepl?.onResult(payload.params); } this.emit("message", Object.assign(payload, { cid })); } _handleError(payload) { const { cid } = this; this.emit("error", Object.assign(payload, { cid })); } _handleExit(exitCode) { const { cid, childProcess, specs, retries } = this; delete this.childProcess; this.isBusy = false; this.isKilled = true; log.debug(`Runner ${cid} finished with exit code ${exitCode}`); this.emit("exit", { cid, exitCode, specs, retries }); if (childProcess) { childProcess.kill("SIGTERM"); } } /** * sends message to sub process to execute functions in wdio-runner * @param command method to run in wdio-runner * @param args arguments for functions to call */ postMessage(command, args, requiresSetup = false) { const { cid, configFile, capabilities, specs, retries, isBusy } = this; if (isBusy && !ACCEPTABLE_BUSY_COMMANDS.includes(command)) { return log.info(`worker with cid ${cid} already busy and can't take new commands`); } if (!this.childProcess) { this.childProcess = this.startProcess(); } const cmd = { cid, command, configFile, args, caps: capabilities, specs, retries }; log.debug(`Send command ${command} to worker with cid "${cid}"`); this.isReady.then(async () => { if (requiresSetup) { await this.isSetup; } this.childProcess.send(cmd); }); this.isBusy = true; } }; // src/index.ts var log2 = logger2("@wdio/local-runner"); var LocalRunner = class { constructor(_options, _config) { this._options = _options; this._config = _config; } workerPool = {}; stdout = new WritableStreamBuffer(BUFFER_OPTIONS); stderr = new WritableStreamBuffer(BUFFER_OPTIONS); /** * nothing to initialize when running locally */ initialize() { } getWorkerCount() { return Object.keys(this.workerPool).length; } async run({ command, args, ...workerOptions }) { const workerCnt = this.getWorkerCount(); if (workerCnt >= process.stdout.getMaxListeners() - 2) { process.stdout.setMaxListeners(workerCnt + 2); process.stderr.setMaxListeners(workerCnt + 2); } const worker = new WorkerInstance(this._config, workerOptions, this.stdout, this.stderr); this.workerPool[workerOptions.cid] = worker; worker.postMessage(command, args); return worker; } /** * shutdown all worker processes * * @return {Promise} resolves when all worker have been shutdown or * a timeout was reached */ shutdown() { log2.info("Shutting down spawned worker"); for (const [cid, worker] of Object.entries(this.workerPool)) { const { capabilities, server, sessionId, config, isMultiremote, instances } = worker; let payload = {}; if (config && config.watch && (sessionId || isMultiremote)) { payload = { config: { ...server, sessionId, ...config }, capabilities, watch: true, isMultiremote, instances }; } else if (!worker.isBusy) { delete this.workerPool[cid]; continue; } worker.postMessage("endSession", payload); } return new Promise((resolve) => { const timeout = setTimeout(resolve, SHUTDOWN_TIMEOUT); const interval = setInterval(() => { const busyWorker = Object.entries(this.workerPool).filter(([, worker]) => worker.isBusy).length; log2.info(`Waiting for ${busyWorker} to shut down gracefully`); if (busyWorker === 0) { clearTimeout(timeout); clearInterval(interval); log2.info("shutting down"); return resolve(true); } }, 250); }); } }; export { LocalRunner as default };