@wdio/local-runner
Version:
A WebdriverIO runner to run tests locally
490 lines (478 loc) • 14.2 kB
JavaScript
// 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
};