UNPKG

@wdio/local-runner

Version:
490 lines (478 loc) 14.2 kB
// src/index.ts import logger2 from "@wdio/logger"; import { WritableStreamBuffer } from "stream-buffers"; import { XvfbManager } from "@wdio/xvfb"; // src/worker.ts import url from "node:url"; import path from "node:path"; import { EventEmitter } from "node:events"; import { ProcessFactory } from "@wdio/xvfb"; 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 = []; #processFactory; 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 * @param {XvfbManager} xvfbManager configured XvfbManager instance */ constructor(config, { cid, configFile, caps, specs, execArgv, retries }, stdout, stderr, xvfbManager) { 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.#processFactory = new ProcessFactory(xvfbManager); this.isReady = new Promise((resolve) => { this.isReadyResolver = resolve; }); this.isSetup = new Promise((resolve) => { this.isSetupResolver = resolve; }); } /** * spawns process to kick of wdio-runner */ async 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 = await this.#processFactory.createWorkerProcess( 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 */ async 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 = await 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; this.xvfbManager = new XvfbManager({ enabled: this.config.autoXvfb !== false, autoInstall: this.config.xvfbAutoInstall, autoInstallMode: this.config.xvfbAutoInstallMode, autoInstallCommand: this.config.xvfbAutoInstallCommand, xvfbMaxRetries: this.config.xvfbMaxRetries, xvfbRetryDelay: this.config.xvfbRetryDelay }); } workerPool = {}; xvfbInitialized = false; xvfbManager; stdout = new WritableStreamBuffer(BUFFER_OPTIONS); stderr = new WritableStreamBuffer(BUFFER_OPTIONS); /** * initialize local runner environment */ async initialize() { } getWorkerCount() { return Object.keys(this.workerPool).length; } async run({ command, args, ...workerOptions }) { if (!this.xvfbInitialized) { await this.initializeXvfb(workerOptions); this.xvfbInitialized = true; } 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.xvfbManager ); this.workerPool[workerOptions.cid] = worker; await worker.postMessage(command, args); return worker; } /** * Initialize XVFB with capability-aware detection */ async initializeXvfb(workerOptions) { if (this.config.autoXvfb === false) { log2.info("Skipping automatic Xvfb initialization (disabled by config)"); return; } try { const capabilities = workerOptions.caps; const xvfbInitialized = await this.xvfbManager.init(capabilities); if (xvfbInitialized) { log2.info("Xvfb is ready for use"); } } catch (error) { log2.warn( "Failed to initialize Xvfb, continuing without virtual display:", error ); } } /** * shutdown all worker processes * * @return {Promise} resolves when all worker have been shutdown or * a timeout was reached */ async 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; } await worker.postMessage("endSession", payload); } const shutdownResult = await 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); }); if (this.xvfbManager.shouldRun()) { log2.info("Xvfb cleanup handled automatically by xvfb-run"); } return shutdownResult; } }; export { LocalRunner as default };