Implements Phases 1-8 of the TFTSR implementation plan. Rust backend (Tauri 2.x, src-tauri/): - Multi-provider AI: OpenAI-compatible, Anthropic, Gemini, Mistral, Ollama - PII detection engine: 11 regex patterns with overlap resolution - SQLCipher AES-256 encrypted database with 10 versioned migrations - 28 Tauri IPC commands for triage, analysis, document, and system ops - Ollama: hardware probe, model recommendations, pull/delete with events - RCA and blameless post-mortem Markdown document generators - PDF export via printpdf - Audit log: SHA-256 hash of every external data send - Integration stubs for Confluence, ServiceNow, Azure DevOps (v0.2) Frontend (React 18 + TypeScript + Vite, src/): - 9 pages: full triage workflow NewIssue→LogUpload→Triage→Resolution→RCA→Postmortem→History+Settings - 7 components: ChatWindow, TriageProgress, PiiDiffViewer, DocEditor, HardwareReport, ModelSelector, UI primitives - 3 Zustand stores: session, settings (persisted), history - Type-safe tauriCommands.ts matching Rust backend types exactly - 8 IT domain system prompts (Linux, Windows, Network, K8s, DB, Virt, HW, Obs) DevOps: - .woodpecker/test.yml: rustfmt, clippy, cargo test, tsc, vitest on every push - .woodpecker/release.yml: linux/amd64 + linux/arm64 builds, Gogs release upload Verified: - cargo check: zero errors - tsc --noEmit: zero errors - vitest run: 13/13 unit tests passing Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
1692 lines
57 KiB
JavaScript
1692 lines
57 KiB
JavaScript
var __defProp = Object.defineProperty;
|
|
var __export = (target, all) => {
|
|
for (var name in all)
|
|
__defProp(target, name, { get: all[name], enumerable: true });
|
|
};
|
|
|
|
// src/index.ts
|
|
import "dotenv/config";
|
|
|
|
// src/launcher.ts
|
|
import exitHook from "async-exit-hook";
|
|
import { resolve } from "import-meta-resolve";
|
|
import logger3 from "@wdio/logger";
|
|
import { validateConfig, DEFAULT_MAX_INSTANCES_PER_CAPABILITY_VALUE } from "@wdio/config";
|
|
import { ConfigParser as ConfigParser2 } from "@wdio/config/node";
|
|
import { initializePlugin, initializeLauncherService, sleep, enableFileLogging } from "@wdio/utils";
|
|
import { setupDriver, setupBrowser } from "@wdio/utils/node";
|
|
|
|
// src/interface.ts
|
|
import { EventEmitter } from "node:events";
|
|
import chalk, { supportsColor } from "chalk";
|
|
import logger2 from "@wdio/logger";
|
|
import { SnapshotManager } from "@vitest/snapshot/manager";
|
|
|
|
// src/utils.ts
|
|
import pickBy from "lodash.pickby";
|
|
import logger from "@wdio/logger";
|
|
import { SevereServiceError } from "webdriverio";
|
|
import { ConfigParser } from "@wdio/config/node";
|
|
import { CAPABILITY_KEYS } from "@wdio/protocols";
|
|
|
|
// src/constants.ts
|
|
import module from "node:module";
|
|
import { HOOK_DEFINITION } from "@wdio/utils";
|
|
var require2 = module.createRequire(import.meta.url);
|
|
var pkgJSON = require2("../package.json");
|
|
var pkg = pkgJSON;
|
|
var CLI_EPILOGUE = `Documentation: https://webdriver.io
|
|
@wdio/cli (v${pkg.version})`;
|
|
var SUPPORTED_COMMANDS = ["run", "install", "config", "repl"];
|
|
var ANDROID_CONFIG = {
|
|
platformName: "Android",
|
|
automationName: "UiAutomator2",
|
|
deviceName: "Test"
|
|
};
|
|
var IOS_CONFIG = {
|
|
platformName: "iOS",
|
|
automationName: "XCUITest",
|
|
deviceName: "iPhone Simulator"
|
|
};
|
|
var SUPPORTED_SNAPSHOTSTATE_OPTIONS = ["all", "new", "none"];
|
|
var TESTRUNNER_DEFAULTS = {
|
|
/**
|
|
* Define specs for test execution. You can either specify a glob
|
|
* pattern to match multiple files at once or wrap a glob or set of
|
|
* paths into an array to run them within a single worker process.
|
|
*/
|
|
specs: {
|
|
type: "object",
|
|
validate: (param) => {
|
|
if (!Array.isArray(param)) {
|
|
throw new Error('the "specs" option needs to be a list of strings');
|
|
}
|
|
}
|
|
},
|
|
/**
|
|
* exclude specs from test execution
|
|
*/
|
|
exclude: {
|
|
type: "object",
|
|
validate: (param) => {
|
|
if (!Array.isArray(param)) {
|
|
throw new Error('the "exclude" option needs to be a list of strings');
|
|
}
|
|
}
|
|
},
|
|
/**
|
|
* key/value definition of suites (named by key) and a list of specs as value
|
|
* to specify a specific set of tests to execute
|
|
*/
|
|
suites: {
|
|
type: "object"
|
|
},
|
|
/**
|
|
* Project root directory path.
|
|
*/
|
|
rootDir: {
|
|
type: "string"
|
|
},
|
|
/**
|
|
* If you only want to run your tests until a specific amount of tests have failed use
|
|
* bail (default is 0 - don't bail, run all tests).
|
|
*/
|
|
bail: {
|
|
type: "number",
|
|
default: 0
|
|
},
|
|
/**
|
|
* supported test framework by wdio testrunner
|
|
*/
|
|
framework: {
|
|
type: "string"
|
|
},
|
|
/**
|
|
* capabilities of WebDriver sessions
|
|
*/
|
|
capabilities: {
|
|
type: "object",
|
|
validate: (param) => {
|
|
if (!Array.isArray(param)) {
|
|
if (typeof param === "object") {
|
|
return true;
|
|
}
|
|
throw new Error('the "capabilities" options needs to be an object or a list of objects');
|
|
}
|
|
for (const option of param) {
|
|
if (typeof option === "object") {
|
|
continue;
|
|
}
|
|
throw new Error("expected every item of a list of capabilities to be of type object");
|
|
}
|
|
return true;
|
|
},
|
|
required: true
|
|
},
|
|
/**
|
|
* list of reporters to use, a reporter can be either a string or an object with
|
|
* reporter options, e.g.:
|
|
* [
|
|
* 'dot',
|
|
* {
|
|
* name: 'spec',
|
|
* outputDir: __dirname + '/reports'
|
|
* }
|
|
* ]
|
|
*/
|
|
reporters: {
|
|
type: "object",
|
|
validate: (param) => {
|
|
if (!Array.isArray(param)) {
|
|
throw new Error('the "reporters" options needs to be a list of strings');
|
|
}
|
|
const isValidReporter = (option) => typeof option === "string" || typeof option === "function";
|
|
for (const option of param) {
|
|
if (isValidReporter(option)) {
|
|
continue;
|
|
}
|
|
if (Array.isArray(option) && typeof option[1] === "object" && isValidReporter(option[0])) {
|
|
continue;
|
|
}
|
|
throw new Error(
|
|
'a reporter should be either a string in the format "wdio-<reportername>-reporter" or a function/class. Please see the docs for more information on custom reporters (https://webdriver.io/docs/customreporter)'
|
|
);
|
|
}
|
|
return true;
|
|
}
|
|
},
|
|
/**
|
|
* set of WDIO services to use
|
|
*/
|
|
services: {
|
|
type: "object",
|
|
validate: (param) => {
|
|
if (!Array.isArray(param)) {
|
|
throw new Error('the "services" options needs to be a list of strings and/or arrays');
|
|
}
|
|
for (const option of param) {
|
|
if (!Array.isArray(option)) {
|
|
if (typeof option === "string") {
|
|
continue;
|
|
}
|
|
throw new Error('the "services" options needs to be a list of strings and/or arrays');
|
|
}
|
|
}
|
|
return true;
|
|
},
|
|
default: []
|
|
},
|
|
/**
|
|
* Node arguments to specify when launching child processes
|
|
*/
|
|
execArgv: {
|
|
type: "object",
|
|
validate: (param) => {
|
|
if (!Array.isArray(param)) {
|
|
throw new Error('the "execArgv" options needs to be a list of strings');
|
|
}
|
|
},
|
|
default: []
|
|
},
|
|
/**
|
|
* amount of instances to be allowed to run in total
|
|
*/
|
|
maxInstances: {
|
|
type: "number"
|
|
},
|
|
/**
|
|
* amount of instances to be allowed to run per capability
|
|
*/
|
|
maxInstancesPerCapability: {
|
|
type: "number"
|
|
},
|
|
/**
|
|
* whether or not testrunner should inject `browser`, `$` and `$$` as
|
|
* global environment variables
|
|
*/
|
|
injectGlobals: {
|
|
type: "boolean"
|
|
},
|
|
/**
|
|
* Set to true if you want to update your snapshots.
|
|
*/
|
|
updateSnapshots: {
|
|
type: "string",
|
|
default: SUPPORTED_SNAPSHOTSTATE_OPTIONS[1],
|
|
validate: (param) => {
|
|
if (param && !SUPPORTED_SNAPSHOTSTATE_OPTIONS.includes(param)) {
|
|
throw new Error(`the "updateSnapshots" options needs to be one of "${SUPPORTED_SNAPSHOTSTATE_OPTIONS.join('", "')}"`);
|
|
}
|
|
}
|
|
},
|
|
/**
|
|
* Overrides default snapshot path. For example, to store snapshots next to test files.
|
|
*/
|
|
resolveSnapshotPath: {
|
|
type: "function",
|
|
validate: (param) => {
|
|
if (param && typeof param !== "function") {
|
|
throw new Error('the "resolveSnapshotPath" options needs to be a function');
|
|
}
|
|
}
|
|
},
|
|
/**
|
|
* The number of times to retry the entire specfile when it fails as a whole
|
|
*/
|
|
specFileRetries: {
|
|
type: "number",
|
|
default: 0
|
|
},
|
|
/**
|
|
* Delay in seconds between the spec file retry attempts
|
|
*/
|
|
specFileRetriesDelay: {
|
|
type: "number",
|
|
default: 0
|
|
},
|
|
/**
|
|
* Whether or not retried spec files should be retried immediately or deferred to the end of the queue
|
|
*/
|
|
specFileRetriesDeferred: {
|
|
type: "boolean",
|
|
default: true
|
|
},
|
|
/**
|
|
* whether or not print the log output grouped by test files
|
|
*/
|
|
groupLogsByTestSpec: {
|
|
type: "boolean",
|
|
default: false
|
|
},
|
|
/**
|
|
* list of strings to watch of `wdio` command is called with `--watch` flag
|
|
*/
|
|
filesToWatch: {
|
|
type: "object",
|
|
validate: (param) => {
|
|
if (!Array.isArray(param)) {
|
|
throw new Error('the "filesToWatch" option needs to be a list of strings');
|
|
}
|
|
}
|
|
},
|
|
shard: {
|
|
type: "object",
|
|
validate: (param) => {
|
|
if (typeof param !== "object") {
|
|
throw new Error('the "shard" options needs to be an object');
|
|
}
|
|
const p = param;
|
|
if (typeof p.current !== "number" || typeof p.total !== "number") {
|
|
throw new Error('the "shard" option needs to have "current" and "total" properties with number values');
|
|
}
|
|
if (p.current < 0 || p.current > p.total) {
|
|
throw new Error('the "shard.current" value has to be between 0 and "shard.total"');
|
|
}
|
|
}
|
|
},
|
|
/**
|
|
* hooks
|
|
*/
|
|
onPrepare: HOOK_DEFINITION,
|
|
onWorkerStart: HOOK_DEFINITION,
|
|
onWorkerEnd: HOOK_DEFINITION,
|
|
before: HOOK_DEFINITION,
|
|
beforeSession: HOOK_DEFINITION,
|
|
beforeSuite: HOOK_DEFINITION,
|
|
beforeHook: HOOK_DEFINITION,
|
|
beforeTest: HOOK_DEFINITION,
|
|
afterTest: HOOK_DEFINITION,
|
|
afterHook: HOOK_DEFINITION,
|
|
afterSuite: HOOK_DEFINITION,
|
|
afterSession: HOOK_DEFINITION,
|
|
after: HOOK_DEFINITION,
|
|
onComplete: HOOK_DEFINITION,
|
|
onReload: HOOK_DEFINITION,
|
|
beforeAssertion: HOOK_DEFINITION,
|
|
afterAssertion: HOOK_DEFINITION
|
|
};
|
|
var WORKER_GROUPLOGS_MESSAGES = {
|
|
normalExit: (cid) => `
|
|
***** List of steps of WorkerID=[${cid}] *****`,
|
|
exitWithError: (cid) => `
|
|
***** List of steps of WorkerID=[${cid}] that preceded the error above *****`
|
|
};
|
|
|
|
// src/utils.ts
|
|
var log = logger("@wdio/cli:utils");
|
|
var HookError = class extends SevereServiceError {
|
|
origin;
|
|
constructor(message, origin) {
|
|
super(message);
|
|
this.origin = origin;
|
|
}
|
|
};
|
|
async function runServiceHook(launcher, hookName, ...args) {
|
|
const start = Date.now();
|
|
return Promise.all(launcher.map(async (service) => {
|
|
try {
|
|
if (typeof service[hookName] === "function") {
|
|
await service[hookName](...args);
|
|
}
|
|
} catch (err) {
|
|
const message = `A service failed in the '${hookName}' hook
|
|
${err.stack}
|
|
|
|
`;
|
|
if (err instanceof SevereServiceError || err.name === "SevereServiceError") {
|
|
return { status: "rejected", reason: message, origin: hookName };
|
|
}
|
|
log.error(`${message}Continue...`);
|
|
}
|
|
})).then((results) => {
|
|
if (launcher.length) {
|
|
log.debug(`Finished to run "${hookName}" hook in ${Date.now() - start}ms`);
|
|
}
|
|
const rejectedHooks = results.filter((p) => p && p.status === "rejected");
|
|
if (rejectedHooks.length) {
|
|
return Promise.reject(new HookError(`
|
|
${rejectedHooks.map((p) => p && p.reason).join()}
|
|
|
|
Stopping runner...`, hookName));
|
|
}
|
|
});
|
|
}
|
|
async function runLauncherHook(hook, ...args) {
|
|
if (typeof hook === "function") {
|
|
hook = [hook];
|
|
}
|
|
const catchFn = (e) => {
|
|
log.error(`Error in hook: ${e.stack}`);
|
|
if (e instanceof SevereServiceError) {
|
|
throw new HookError(e.message, hook[0].name);
|
|
}
|
|
};
|
|
return Promise.all(hook.map((hook2) => {
|
|
try {
|
|
return hook2(...args);
|
|
} catch (err) {
|
|
return catchFn(err);
|
|
}
|
|
})).catch(catchFn);
|
|
}
|
|
async function runOnCompleteHook(onCompleteHook, config3, capabilities, exitCode, results) {
|
|
if (typeof onCompleteHook === "function") {
|
|
onCompleteHook = [onCompleteHook];
|
|
}
|
|
return Promise.all(onCompleteHook.map(async (hook) => {
|
|
try {
|
|
await hook(exitCode, config3, capabilities, results);
|
|
return 0;
|
|
} catch (err) {
|
|
log.error(`Error in onCompleteHook: ${err.stack}`);
|
|
if (err instanceof SevereServiceError) {
|
|
throw new HookError(err.message, "onComplete");
|
|
}
|
|
return 1;
|
|
}
|
|
}));
|
|
}
|
|
function getRunnerName(caps = {}) {
|
|
let runner = caps.browserName || caps.platformName || caps["appium:platformName"] || caps["appium:appPackage"] || caps["appium:appWaitActivity"] || caps["appium:app"];
|
|
if (!runner) {
|
|
runner = Object.values(caps).length === 0 || Object.values(caps).some((cap) => !cap.capabilities) ? "undefined" : "MultiRemote";
|
|
}
|
|
return runner;
|
|
}
|
|
async function getCapabilities(arg) {
|
|
const optionalCapabilites = {
|
|
platformVersion: arg.platformVersion,
|
|
udid: arg.udid,
|
|
...arg.deviceName && { deviceName: arg.deviceName }
|
|
};
|
|
if (/.*\.(apk|app|ipa)$/.test(arg.option)) {
|
|
return {
|
|
capabilities: {
|
|
app: arg.option,
|
|
...arg.option.endsWith("apk") ? ANDROID_CONFIG : IOS_CONFIG,
|
|
...optionalCapabilites
|
|
}
|
|
};
|
|
} else if (/android/.test(arg.option)) {
|
|
return { capabilities: { browserName: "Chrome", ...ANDROID_CONFIG, ...optionalCapabilites } };
|
|
} else if (/ios/.test(arg.option)) {
|
|
return { capabilities: { browserName: "Safari", ...IOS_CONFIG, ...optionalCapabilites } };
|
|
} else if (/(js|ts)$/.test(arg.option)) {
|
|
const config3 = new ConfigParser(arg.option);
|
|
try {
|
|
await config3.initialize();
|
|
} catch (e) {
|
|
throw Error(e.code === "MODULE_NOT_FOUND" ? `Config File not found: ${arg.option}` : `Could not parse ${arg.option}, failed with error: ${e.message}`);
|
|
}
|
|
if (typeof arg.capabilities === "undefined") {
|
|
throw Error("Please provide index/named property of capability to use from the capabilities array/object in wdio config file");
|
|
}
|
|
let requiredCaps = config3.getCapabilities();
|
|
requiredCaps = // multi capabilities
|
|
requiredCaps[parseInt(arg.capabilities, 10)] || // multiremote
|
|
requiredCaps[arg.capabilities]?.capabilities;
|
|
const requiredW3CCaps = pickBy(requiredCaps, (_, key) => CAPABILITY_KEYS.includes(key) || key.includes(":"));
|
|
if (!Object.keys(requiredW3CCaps).length) {
|
|
throw Error(`No capability found in given config file with the provided capability indexed/named property: ${arg.capabilities}. Please check the capability in your wdio config file.`);
|
|
}
|
|
return { capabilities: { ...requiredW3CCaps } };
|
|
}
|
|
return { capabilities: { browserName: arg.option } };
|
|
}
|
|
var cucumberTypes = {
|
|
paths: "array",
|
|
backtrace: "boolean",
|
|
dryRun: "boolean",
|
|
forceExit: "boolean",
|
|
failFast: "boolean",
|
|
format: "array",
|
|
formatOptions: "object",
|
|
import: "array",
|
|
language: "string",
|
|
name: "array",
|
|
order: "string",
|
|
publish: "boolean",
|
|
requireModule: "array",
|
|
retry: "number",
|
|
retryTagFilter: "string",
|
|
strict: "boolean",
|
|
tags: "string",
|
|
worldParameters: "object",
|
|
timeout: "number",
|
|
scenarioLevelReporter: "boolean",
|
|
tagsInTitle: "boolean",
|
|
ignoreUndefinedDefinitions: "boolean",
|
|
failAmbiguousDefinitions: "boolean",
|
|
tagExpression: "string",
|
|
profiles: "array",
|
|
file: "string"
|
|
};
|
|
var mochaTypes = {
|
|
require: "array",
|
|
compilers: "array",
|
|
allowUncaught: "boolean",
|
|
asyncOnly: "boolean",
|
|
bail: "boolean",
|
|
checkLeaks: "boolean",
|
|
delay: "boolean",
|
|
fgrep: "string",
|
|
forbidOnly: "boolean",
|
|
forbidPending: "boolean",
|
|
fullTrace: "boolean",
|
|
global: "array",
|
|
grep: "string",
|
|
invert: "boolean",
|
|
retries: "number",
|
|
timeout: "number",
|
|
ui: "string"
|
|
};
|
|
var jasmineTypes = {
|
|
defaultTimeoutInterval: "number",
|
|
helpers: "array",
|
|
requires: "array",
|
|
random: "boolean",
|
|
seed: "string",
|
|
failFast: "boolean",
|
|
failSpecWithNoExpectations: "boolean",
|
|
oneFailurePerSpec: "boolean",
|
|
grep: "string",
|
|
invertGrep: "boolean",
|
|
cleanStack: "boolean",
|
|
stopOnSpecFailure: "boolean",
|
|
stopSpecOnExpectationFailure: "boolean",
|
|
requireModule: "array"
|
|
};
|
|
function coerceOpts(types, opts) {
|
|
for (const key in opts) {
|
|
if (types[key] === "boolean" && typeof opts[key] === "string") {
|
|
opts[key] = opts[key] === "true";
|
|
} else if (types[key] === "number") {
|
|
opts[key] = Number(opts[key]);
|
|
} else if (types[key] === "array") {
|
|
opts[key] = Array.isArray(opts[key]) ? opts[key] : [opts[key]];
|
|
} else if (types[key] === "object" && typeof opts[key] === "string") {
|
|
opts[key] = JSON.parse(opts[key]);
|
|
}
|
|
}
|
|
return opts;
|
|
}
|
|
function coerceOptsFor(framework) {
|
|
if (framework === "cucumber") {
|
|
return coerceOpts.bind(null, cucumberTypes);
|
|
} else if (framework === "mocha") {
|
|
return coerceOpts.bind(null, mochaTypes);
|
|
} else if (framework === "jasmine") {
|
|
return coerceOpts.bind(null, jasmineTypes);
|
|
}
|
|
throw new Error(`Unsupported framework "${framework}"`);
|
|
}
|
|
var NodeVersion = /* @__PURE__ */ ((NodeVersion2) => {
|
|
NodeVersion2[NodeVersion2["major"] = 0] = "major";
|
|
NodeVersion2[NodeVersion2["minor"] = 1] = "minor";
|
|
NodeVersion2[NodeVersion2["patch"] = 2] = "patch";
|
|
return NodeVersion2;
|
|
})(NodeVersion || {});
|
|
function nodeVersion(type) {
|
|
return process.versions.node.split(".").map(Number)[NodeVersion[type]];
|
|
}
|
|
|
|
// src/interface.ts
|
|
var log2 = logger2("@wdio/cli");
|
|
var EVENT_FILTER = ["sessionStarted", "sessionEnded", "finishedCommand", "ready", "workerResponse", "workerEvent"];
|
|
var WDIOCLInterface = class extends EventEmitter {
|
|
constructor(_config, totalWorkerCnt, _isWatchMode = false) {
|
|
super();
|
|
this._config = _config;
|
|
this.totalWorkerCnt = totalWorkerCnt;
|
|
this._isWatchMode = _isWatchMode;
|
|
this.hasAnsiSupport = supportsColor && supportsColor.hasBasic;
|
|
this.totalWorkerCnt = totalWorkerCnt;
|
|
this._isWatchMode = _isWatchMode;
|
|
this._specFileRetries = _config.specFileRetries || 0;
|
|
this._specFileRetriesDelay = _config.specFileRetriesDelay || 0;
|
|
this.on("job:start", this.addJob.bind(this));
|
|
this.on("job:end", this.clearJob.bind(this));
|
|
this.setup();
|
|
this.onStart();
|
|
}
|
|
#snapshotManager = new SnapshotManager({
|
|
updateSnapshot: "new"
|
|
// ignored in this context
|
|
});
|
|
hasAnsiSupport;
|
|
result = {
|
|
finished: 0,
|
|
passed: 0,
|
|
retries: 0,
|
|
failed: 0
|
|
};
|
|
_jobs = /* @__PURE__ */ new Map();
|
|
_specFileRetries;
|
|
_specFileRetriesDelay;
|
|
_skippedSpecs = 0;
|
|
_inDebugMode = false;
|
|
_start = /* @__PURE__ */ new Date();
|
|
_messages = {
|
|
reporter: {},
|
|
debugger: {}
|
|
};
|
|
#hasShard() {
|
|
return this._config.shard && this._config.shard.total !== 1;
|
|
}
|
|
setup() {
|
|
this._jobs = /* @__PURE__ */ new Map();
|
|
this._start = /* @__PURE__ */ new Date();
|
|
this.result = {
|
|
finished: 0,
|
|
passed: 0,
|
|
retries: 0,
|
|
failed: 0
|
|
};
|
|
this._messages = {
|
|
reporter: {},
|
|
debugger: {}
|
|
};
|
|
}
|
|
onStart() {
|
|
const shardNote = this.#hasShard() ? ` (Shard ${this._config.shard.current} of ${this._config.shard.total})` : "";
|
|
this.log(chalk.bold(`
|
|
Execution of ${chalk.blue(this.totalWorkerCnt)} workers${shardNote} started at`), this._start.toISOString());
|
|
if (this._inDebugMode) {
|
|
this.log(chalk.bgYellow(chalk.black("DEBUG mode enabled!")));
|
|
}
|
|
if (this._isWatchMode) {
|
|
this.log(chalk.bgYellow(chalk.black("WATCH mode enabled!")));
|
|
}
|
|
this.log("");
|
|
}
|
|
onSpecRunning(rid) {
|
|
this.onJobComplete(rid, this._jobs.get(rid), 0, chalk.bold(chalk.cyan("RUNNING")));
|
|
}
|
|
onSpecRetry(rid, job, retries = 0) {
|
|
const delayMsg = this._specFileRetriesDelay > 0 ? ` after ${this._specFileRetriesDelay}s` : "";
|
|
this.onJobComplete(rid, job, retries, chalk.bold(chalk.yellow("RETRYING") + delayMsg));
|
|
}
|
|
onSpecPass(rid, job, retries = 0) {
|
|
this.onJobComplete(rid, job, retries, chalk.bold(chalk.green("PASSED")));
|
|
}
|
|
onSpecFailure(rid, job, retries = 0) {
|
|
this.onJobComplete(rid, job, retries, chalk.bold(chalk.red("FAILED")));
|
|
}
|
|
onSpecSkip(rid, job) {
|
|
this.onJobComplete(rid, job, 0, "SKIPPED", log2.info);
|
|
}
|
|
onJobComplete(cid, job, retries = 0, message = "", _logger = this.log) {
|
|
const details = [`[${cid}]`, message];
|
|
if (job) {
|
|
const caps = job.caps;
|
|
const version = caps?.browserVersion || caps?.["appium:platformVersion"];
|
|
const runnerName = getRunnerName(caps);
|
|
if (version) {
|
|
details.push("in", `${runnerName}(${version})`);
|
|
} else {
|
|
details.push("in", runnerName);
|
|
}
|
|
if (caps?.platformName || caps?.["appium:deviceName"]) {
|
|
details.push("on", caps?.platformName || caps?.["appium:deviceName"]);
|
|
}
|
|
details.push(this.getFilenames(job.specs));
|
|
}
|
|
if (retries > 0) {
|
|
details.push(`(${retries} retries)`);
|
|
}
|
|
return _logger(...details);
|
|
}
|
|
onTestError(payload) {
|
|
const error = {
|
|
type: payload.error?.type || "Error",
|
|
message: payload.error?.message || (typeof payload.error === "string" ? payload.error : "Unknown error."),
|
|
stack: payload.error?.stack
|
|
};
|
|
return this.log(`[${payload.cid}]`, `${chalk.red(error.type)} in "${payload.fullTitle}"
|
|
${chalk.red(error.stack || error.message)}`);
|
|
}
|
|
getFilenames(specs = []) {
|
|
if (specs.length > 0) {
|
|
return "- " + specs.join(", ").replace(new RegExp(`${process.cwd()}`, "g"), "");
|
|
}
|
|
return "";
|
|
}
|
|
/**
|
|
* add job to interface
|
|
*/
|
|
addJob({ cid, caps, specs, hasTests }) {
|
|
this._jobs.set(cid, { caps, specs, hasTests });
|
|
if (hasTests) {
|
|
this.onSpecRunning(cid);
|
|
} else {
|
|
this._skippedSpecs++;
|
|
}
|
|
}
|
|
/**
|
|
* clear job from interface
|
|
*/
|
|
clearJob({ cid, passed, retries }) {
|
|
const job = this._jobs.get(cid);
|
|
this._jobs.delete(cid);
|
|
const retryAttempts = this._specFileRetries - retries;
|
|
const retry = !passed && retries > 0;
|
|
if (!retry) {
|
|
this.result.finished++;
|
|
}
|
|
if (job && job.hasTests === false) {
|
|
return this.onSpecSkip(cid, job);
|
|
}
|
|
if (passed) {
|
|
this.result.passed++;
|
|
this.onSpecPass(cid, job, retryAttempts);
|
|
} else if (retry) {
|
|
this.totalWorkerCnt++;
|
|
this.result.retries++;
|
|
this.onSpecRetry(cid, job, retryAttempts);
|
|
} else {
|
|
this.result.failed++;
|
|
this.onSpecFailure(cid, job, retryAttempts);
|
|
}
|
|
}
|
|
/**
|
|
* for testing purposes call console log in a static method
|
|
*/
|
|
log(...args) {
|
|
console.log(...args);
|
|
return args;
|
|
}
|
|
logHookError(error) {
|
|
if (error instanceof HookError) {
|
|
return this.log(`${chalk.red(error.name)} in "${error.origin}"
|
|
${chalk.red(error.stack || error.message)}`);
|
|
}
|
|
return this.log(`${chalk.red(error.name)}: ${chalk.red(error.stack || error.message)}`);
|
|
}
|
|
/**
|
|
* event handler that is triggered when runner sends up events
|
|
*/
|
|
onMessage(event) {
|
|
if (event.name === "reporterRealTime") {
|
|
this.log(event.content);
|
|
return;
|
|
}
|
|
if (event.origin === "debugger" && event.name === "start") {
|
|
this.log(chalk.yellow(event.params.introMessage));
|
|
this._inDebugMode = true;
|
|
return this._inDebugMode;
|
|
}
|
|
if (event.origin === "debugger" && event.name === "stop") {
|
|
this._inDebugMode = false;
|
|
return this._inDebugMode;
|
|
}
|
|
if (event.name === "testFrameworkInit") {
|
|
return this.emit("job:start", event.content);
|
|
}
|
|
if (event.name === "snapshot") {
|
|
const snapshotResults = event.content;
|
|
return snapshotResults.forEach((snapshotResult) => {
|
|
this.#snapshotManager.add(snapshotResult);
|
|
});
|
|
}
|
|
if (event.name === "error") {
|
|
return this.log(
|
|
`[${event.cid}]`,
|
|
chalk.white(chalk.bgRed(chalk.bold(" Error: "))),
|
|
event.content ? event.content.message || event.content.stack || event.content : ""
|
|
);
|
|
}
|
|
if (event.origin !== "reporter" && event.origin !== "debugger") {
|
|
if (EVENT_FILTER.includes(event.name)) {
|
|
return;
|
|
}
|
|
return this.log(event.cid, event.origin, event.name, event.content);
|
|
}
|
|
if (event.name === "printFailureMessage") {
|
|
return this.onTestError(event.content);
|
|
}
|
|
if (!this._messages[event.origin][event.name]) {
|
|
this._messages[event.origin][event.name] = [];
|
|
}
|
|
this._messages[event.origin][event.name].push(event.content);
|
|
}
|
|
sigintTrigger() {
|
|
if (this._inDebugMode) {
|
|
return false;
|
|
}
|
|
const isRunning = this._jobs.size !== 0 || this._isWatchMode;
|
|
const shutdownMessage = isRunning ? "Ending WebDriver sessions gracefully ...\n(press ctrl+c again to hard kill the runner)" : "Ended WebDriver sessions gracefully after a SIGINT signal was received!";
|
|
return this.log("\n\n" + shutdownMessage);
|
|
}
|
|
printReporters() {
|
|
const reporter = this._messages.reporter;
|
|
this._messages.reporter = {};
|
|
for (const [reporterName, messages] of Object.entries(reporter)) {
|
|
this.log("\n", chalk.bold(chalk.magenta(`"${reporterName}" Reporter:`)));
|
|
this.log(messages.join(""));
|
|
}
|
|
}
|
|
printSummary() {
|
|
const totalJobs = this.totalWorkerCnt - this.result.retries;
|
|
const elapsed = new Date(Date.now() - this._start.getTime()).toUTCString().match(/(\d\d:\d\d:\d\d)/)[0];
|
|
const retries = this.result.retries ? chalk.yellow(this.result.retries, "retries") + ", " : "";
|
|
const failed = this.result.failed ? chalk.red(this.result.failed, "failed") + ", " : "";
|
|
const skipped = this._skippedSpecs > 0 ? chalk.gray(this._skippedSpecs, "skipped") + ", " : "";
|
|
const percentCompleted = totalJobs ? Math.round(this.result.finished / totalJobs * 100) : 0;
|
|
const snapshotSummary = this.#snapshotManager.summary;
|
|
const snapshotNotes = [];
|
|
if (snapshotSummary.added > 0) {
|
|
snapshotNotes.push(chalk.green(`${snapshotSummary.added} snapshot(s) added.`));
|
|
}
|
|
if (snapshotSummary.updated > 0) {
|
|
snapshotNotes.push(chalk.yellow(`${snapshotSummary.updated} snapshot(s) updated.`));
|
|
}
|
|
if (snapshotSummary.unmatched > 0) {
|
|
snapshotNotes.push(chalk.red(`${snapshotSummary.unmatched} snapshot(s) unmatched.`));
|
|
}
|
|
if (snapshotSummary.unchecked > 0) {
|
|
snapshotNotes.push(chalk.gray(`${snapshotSummary.unchecked} snapshot(s) unchecked.`));
|
|
}
|
|
if (snapshotNotes.length > 0) {
|
|
this.log("\nSnapshot Summary:");
|
|
snapshotNotes.forEach((note) => this.log(note));
|
|
}
|
|
return this.log(
|
|
"\nSpec Files: ",
|
|
chalk.green(this.result.passed, "passed") + ", " + retries + failed + skipped + totalJobs,
|
|
"total",
|
|
`(${percentCompleted}% completed)`,
|
|
"in",
|
|
elapsed,
|
|
this.#hasShard() ? `
|
|
Shard: ${this._config.shard.current} / ${this._config.shard.total}` : "",
|
|
"\n"
|
|
);
|
|
}
|
|
finalise() {
|
|
this.printReporters();
|
|
this.printSummary();
|
|
}
|
|
};
|
|
|
|
// src/launcher.ts
|
|
var log3 = logger3("@wdio/cli:launcher");
|
|
var TS_FILE_EXTENSIONS = [".ts", ".tsx", ".mts", ".cts"];
|
|
var Launcher = class {
|
|
constructor(_configFilePath, _args = {}, _isWatchMode = false) {
|
|
this._configFilePath = _configFilePath;
|
|
this._args = _args;
|
|
this._isWatchMode = _isWatchMode;
|
|
this.configParser = new ConfigParser2(this._configFilePath, this._args);
|
|
}
|
|
#isInitialized = false;
|
|
configParser;
|
|
isMultiremote = false;
|
|
isParallelMultiremote = false;
|
|
runner;
|
|
interface;
|
|
_exitCode = 0;
|
|
_hasTriggeredExitRoutine = false;
|
|
_schedule = [];
|
|
_rid = [];
|
|
_runnerStarted = 0;
|
|
_runnerFailed = 0;
|
|
_launcher;
|
|
_resolve;
|
|
/**
|
|
* run sequence
|
|
* @return {Promise} that only gets resolved with either an exitCode or an error
|
|
*/
|
|
async run() {
|
|
await this.initialize();
|
|
const config3 = this.configParser.getConfig();
|
|
const capabilities = this.configParser.getCapabilities();
|
|
this.isParallelMultiremote = Array.isArray(capabilities) && capabilities.length > 0 && capabilities.every((cap) => Object.values(cap).length > 0 && Object.values(cap).every((c) => typeof c === "object" && c.capabilities));
|
|
this.isMultiremote = this.isParallelMultiremote || !Array.isArray(capabilities);
|
|
validateConfig(TESTRUNNER_DEFAULTS, { ...config3, capabilities });
|
|
await enableFileLogging(config3.outputDir);
|
|
logger3.setLogLevelsConfig(config3.logLevels, config3.logLevel);
|
|
const [runnerName, runnerOptions] = Array.isArray(config3.runner) ? config3.runner : [config3.runner, {}];
|
|
const Runner = (await initializePlugin(runnerName, "runner")).default;
|
|
this.runner = new Runner(runnerOptions, config3);
|
|
exitHook(this._exitHandler.bind(this));
|
|
let exitCode = 0;
|
|
let error = void 0;
|
|
const caps = this.configParser.getCapabilities();
|
|
try {
|
|
const { ignoredWorkerServices, launcherServices } = await initializeLauncherService(config3, caps);
|
|
this._launcher = launcherServices;
|
|
this._args.ignoredWorkerServices = ignoredWorkerServices;
|
|
await this.runner.initialize();
|
|
log3.info("Run onPrepare hook");
|
|
await runLauncherHook(config3.onPrepare, config3, caps);
|
|
await runServiceHook(this._launcher, "onPrepare", config3, caps);
|
|
const totalWorkerCnt = Array.isArray(capabilities) ? capabilities.map((c) => {
|
|
if (this.isParallelMultiremote) {
|
|
const keys = Object.keys(c);
|
|
const caps2 = c[keys[0]].capabilities;
|
|
return this.configParser.getSpecs(caps2["wdio:specs"], caps2["wdio:exclude"]).length;
|
|
}
|
|
const standaloneCaps = c;
|
|
const cap = "alwaysMatch" in standaloneCaps ? standaloneCaps.alwaysMatch : standaloneCaps;
|
|
return this.configParser.getSpecs(cap["wdio:specs"], cap["wdio:exclude"]).length;
|
|
}).reduce((a, b) => a + b, 0) : 1;
|
|
this.interface = new WDIOCLInterface(config3, totalWorkerCnt, this._isWatchMode);
|
|
config3.runnerEnv.FORCE_COLOR = Number(this.interface.hasAnsiSupport).toString();
|
|
await Promise.all([
|
|
setupDriver(config3, caps),
|
|
setupBrowser(config3, caps)
|
|
]);
|
|
exitCode = await this._runMode(config3, caps);
|
|
await logger3.waitForBuffer();
|
|
this.interface.finalise();
|
|
} catch (err) {
|
|
error = err;
|
|
} finally {
|
|
if (!this._hasTriggeredExitRoutine) {
|
|
this._hasTriggeredExitRoutine = true;
|
|
const passesCodeCoverage = await this.runner.shutdown();
|
|
if (!passesCodeCoverage) {
|
|
exitCode = exitCode || 1;
|
|
}
|
|
}
|
|
exitCode = await this.#runOnCompleteHook(config3, caps, exitCode);
|
|
}
|
|
if (error) {
|
|
if (this.interface) {
|
|
this.interface.logHookError(error);
|
|
}
|
|
throw error;
|
|
}
|
|
return exitCode;
|
|
}
|
|
/**
|
|
* initialize launcher by loading `tsx` if needed
|
|
*/
|
|
async initialize() {
|
|
if (this.#isInitialized) {
|
|
return;
|
|
}
|
|
const tsxPath = resolve("tsx", import.meta.url);
|
|
if (!process.env.NODE_OPTIONS || !process.env.NODE_OPTIONS.includes(tsxPath)) {
|
|
const moduleLoaderFlag = nodeVersion("major") >= 21 || nodeVersion("major") === 20 && nodeVersion("minor") >= 6 || nodeVersion("major") === 18 && nodeVersion("minor") >= 19 ? "--import" : "--loader";
|
|
process.env.NODE_OPTIONS = `${process.env.NODE_OPTIONS || ""} ${moduleLoaderFlag} ${tsxPath}`;
|
|
}
|
|
if (TS_FILE_EXTENSIONS.some((ext) => this._configFilePath.endsWith(ext))) {
|
|
await import(tsxPath);
|
|
}
|
|
this.#isInitialized = true;
|
|
await this.configParser.initialize(this._args);
|
|
}
|
|
/**
|
|
* run onComplete hook
|
|
* Even if it fails we still want to see result and end logger stream.
|
|
* Also ensure that user hooks are run before service hooks so that e.g.
|
|
* a user can use plugin service, e.g. shared store service is still
|
|
* available running hooks in this order
|
|
*/
|
|
async #runOnCompleteHook(config3, caps, exitCode) {
|
|
log3.info("Run onComplete hook");
|
|
const onCompleteResults = await runOnCompleteHook(config3.onComplete, config3, caps, exitCode, this.interface?.result || { finished: 0, passed: 0, retries: 0, failed: 0 });
|
|
if (this._launcher) {
|
|
await runServiceHook(this._launcher, "onComplete", exitCode, config3, caps);
|
|
}
|
|
return onCompleteResults.includes(1) ? 1 : exitCode;
|
|
}
|
|
/**
|
|
* run without triggering onPrepare/onComplete hooks
|
|
*/
|
|
_runMode(config3, caps) {
|
|
if (
|
|
/**
|
|
* no caps were provided
|
|
*/
|
|
!caps || /**
|
|
* capability array is empty
|
|
*/
|
|
Array.isArray(caps) && caps.length === 0 || /**
|
|
* user wants to use multiremote but capability object is empty
|
|
*/
|
|
!Array.isArray(caps) && Object.keys(caps).length === 0
|
|
) {
|
|
return new Promise((resolve2) => {
|
|
log3.error("Missing capabilities, exiting with failure");
|
|
return resolve2(1);
|
|
});
|
|
}
|
|
const specFileRetries = this._isWatchMode ? 0 : -1;
|
|
let cid = 0;
|
|
if (this.isMultiremote && !this.isParallelMultiremote) {
|
|
this._schedule.push({
|
|
cid: cid++,
|
|
caps,
|
|
specs: this._formatSpecs(caps, specFileRetries),
|
|
availableInstances: config3.maxInstances || 1,
|
|
runningInstances: 0
|
|
});
|
|
} else {
|
|
for (const capabilities of caps) {
|
|
const availableInstances = this.isParallelMultiremote ? config3.maxInstances || 1 : config3.runner === "browser" ? 1 : capabilities["wdio:maxInstances"] || config3.maxInstancesPerCapability || DEFAULT_MAX_INSTANCES_PER_CAPABILITY_VALUE;
|
|
this._schedule.push({
|
|
cid: cid++,
|
|
caps: capabilities,
|
|
specs: this._formatSpecs(capabilities, specFileRetries),
|
|
availableInstances,
|
|
runningInstances: 0
|
|
});
|
|
}
|
|
}
|
|
return new Promise((resolve2) => {
|
|
this._resolve = resolve2;
|
|
if (Object.values(this._schedule).reduce((specCnt, schedule) => specCnt + schedule.specs.length, 0) === 0) {
|
|
const { total, current } = config3.shard;
|
|
if (total > 1) {
|
|
log3.info(`No specs to execute in shard ${current}/${total}, exiting!`);
|
|
return resolve2(0);
|
|
}
|
|
log3.error("No specs found to run, exiting with failure");
|
|
return resolve2(1);
|
|
}
|
|
if (this._runSpecs()) {
|
|
resolve2(0);
|
|
}
|
|
});
|
|
}
|
|
/**
|
|
* Format the specs into an array of objects with files and retries
|
|
*/
|
|
_formatSpecs(capabilities, specFileRetries) {
|
|
let caps;
|
|
if ("alwaysMatch" in capabilities) {
|
|
caps = capabilities.alwaysMatch;
|
|
} else if (typeof Object.keys(capabilities)[0] === "object" && "capabilities" in capabilities[Object.keys(capabilities)[0]]) {
|
|
caps = {};
|
|
} else {
|
|
caps = capabilities;
|
|
}
|
|
const specs = (
|
|
// @ts-expect-error deprecated
|
|
caps.specs || caps["wdio:specs"]
|
|
);
|
|
const excludes = (
|
|
// @ts-expect-error deprecated
|
|
caps.exclude || caps["wdio:exclude"]
|
|
);
|
|
const files = this.configParser.getSpecs(specs, excludes);
|
|
return files.map((file) => {
|
|
if (typeof file === "string") {
|
|
return { files: [file], retries: specFileRetries };
|
|
} else if (Array.isArray(file)) {
|
|
return { files: file, retries: specFileRetries };
|
|
}
|
|
log3.warn("Unexpected entry in specs that is neither string nor array: ", file);
|
|
return { files: [], retries: specFileRetries };
|
|
});
|
|
}
|
|
/**
|
|
* run multiple single remote tests
|
|
* @return {Boolean} true if all specs have been run and all instances have finished
|
|
*/
|
|
_runSpecs() {
|
|
if (this._hasTriggeredExitRoutine) {
|
|
return true;
|
|
}
|
|
const config3 = this.configParser.getConfig();
|
|
while (this._getNumberOfRunningInstances() < config3.maxInstances) {
|
|
const schedulableCaps = this._schedule.filter((session) => {
|
|
const filter = typeof config3.bail !== "number" || config3.bail < 1 || config3.bail > this._runnerFailed;
|
|
if (!filter) {
|
|
this._schedule.forEach((t) => {
|
|
t.specs = [];
|
|
});
|
|
return false;
|
|
}
|
|
if (this._getNumberOfRunningInstances() >= config3.maxInstances) {
|
|
return false;
|
|
}
|
|
return session.availableInstances > 0 && session.specs.length > 0;
|
|
}).sort((a, b) => a.runningInstances - b.runningInstances);
|
|
if (schedulableCaps.length === 0) {
|
|
break;
|
|
}
|
|
const specs = schedulableCaps[0].specs.shift();
|
|
this._startInstance(
|
|
specs.files,
|
|
schedulableCaps[0].caps,
|
|
schedulableCaps[0].cid,
|
|
specs.rid,
|
|
specs.retries
|
|
);
|
|
schedulableCaps[0].availableInstances--;
|
|
schedulableCaps[0].runningInstances++;
|
|
}
|
|
return this._getNumberOfRunningInstances() === 0 && this._getNumberOfSpecsLeft() === 0;
|
|
}
|
|
/**
|
|
* gets number of all running instances
|
|
* @return {number} number of running instances
|
|
*/
|
|
_getNumberOfRunningInstances() {
|
|
return this._schedule.map((a) => a.runningInstances).reduce((a, b) => a + b);
|
|
}
|
|
/**
|
|
* get number of total specs left to complete whole suites
|
|
* @return {number} specs left to complete suite
|
|
*/
|
|
_getNumberOfSpecsLeft() {
|
|
return this._schedule.map((a) => a.specs.length).reduce((a, b) => a + b);
|
|
}
|
|
/**
|
|
* Start instance in a child process.
|
|
* @param {Array} specs Specs to run
|
|
* @param {number} cid Capabilities ID
|
|
* @param {string} rid Runner ID override
|
|
* @param {number} retries Number of retries remaining
|
|
*/
|
|
async _startInstance(specs, caps, cid, rid, retries) {
|
|
if (!this.runner || !this.interface) {
|
|
throw new Error("Internal Error: no runner initialized, call run() first");
|
|
}
|
|
const config3 = this.configParser.getConfig();
|
|
if (typeof config3.specFileRetriesDelay === "number" && config3.specFileRetries > 0 && config3.specFileRetries !== retries) {
|
|
await sleep(config3.specFileRetriesDelay * 1e3);
|
|
}
|
|
const runnerId = rid || this._getRunnerId(cid);
|
|
const processNumber = this._runnerStarted + 1;
|
|
const debugArgs = [];
|
|
let debugType;
|
|
let debugHost = "";
|
|
const debugPort = process.debugPort;
|
|
for (const arg of process.execArgv) {
|
|
const debugArgs2 = arg.match("--(debug|inspect)(?:-brk)?(?:=(.*):)?");
|
|
if (debugArgs2) {
|
|
const [, type, host] = debugArgs2;
|
|
if (type) {
|
|
debugType = type;
|
|
}
|
|
if (host) {
|
|
debugHost = `${host}:`;
|
|
}
|
|
}
|
|
}
|
|
if (debugType) {
|
|
debugArgs.push(`--${debugType}=${debugHost}${debugPort + processNumber}`);
|
|
}
|
|
const capExecArgs = [...config3.execArgv || []];
|
|
const defaultArgs = capExecArgs.length ? process.execArgv : [];
|
|
const execArgv = [...defaultArgs, ...debugArgs, ...capExecArgs];
|
|
this._runnerStarted++;
|
|
const workerCaps = structuredClone(caps);
|
|
log3.info("Run onWorkerStart hook");
|
|
await runLauncherHook(config3.onWorkerStart, runnerId, workerCaps, specs, this._args, execArgv).catch((error) => this._workerHookError(error));
|
|
await runServiceHook(this._launcher, "onWorkerStart", runnerId, workerCaps, specs, this._args, execArgv).catch((error) => this._workerHookError(error));
|
|
const worker = await this.runner.run({
|
|
cid: runnerId,
|
|
command: "run",
|
|
configFile: this._configFilePath,
|
|
args: {
|
|
...this._args,
|
|
/**
|
|
* Pass on user and key values to ensure they are available in the worker process when using
|
|
* environment variables that were locally exported but not part of the environment.
|
|
*/
|
|
user: config3.user,
|
|
key: config3.key
|
|
},
|
|
caps: workerCaps,
|
|
specs,
|
|
execArgv,
|
|
retries
|
|
});
|
|
worker.on("message", this.interface.onMessage.bind(this.interface));
|
|
worker.on("error", this.interface.onMessage.bind(this.interface));
|
|
worker.on("exit", (code) => {
|
|
if (!this.configParser.getConfig().groupLogsByTestSpec) {
|
|
return;
|
|
}
|
|
if (code.exitCode === 0) {
|
|
console.log(WORKER_GROUPLOGS_MESSAGES.normalExit(code.cid));
|
|
} else {
|
|
console.log(WORKER_GROUPLOGS_MESSAGES.exitWithError(code.cid));
|
|
}
|
|
worker.logsAggregator.forEach((logLine) => {
|
|
console.log(logLine.replace(new RegExp("\\n$"), ""));
|
|
});
|
|
});
|
|
worker.on("exit", this._endHandler.bind(this));
|
|
}
|
|
_workerHookError(error) {
|
|
if (!this.interface) {
|
|
throw new Error("Internal Error: no interface initialized, call run() first");
|
|
}
|
|
this.interface.logHookError(error);
|
|
if (this._resolve) {
|
|
this._resolve(1);
|
|
}
|
|
}
|
|
/**
|
|
* generates a runner id
|
|
* @param {number} cid capability id (unique identifier for a capability)
|
|
* @return {String} runner id (combination of cid and test id e.g. 0a, 0b, 1a, 1b ...)
|
|
*/
|
|
_getRunnerId(cid) {
|
|
if (!this._rid[cid]) {
|
|
this._rid[cid] = 0;
|
|
}
|
|
return `${cid}-${this._rid[cid]++}`;
|
|
}
|
|
/**
|
|
* Close test runner process once all child processes have exited
|
|
* @param {number} cid Capabilities ID
|
|
* @param {number} exitCode exit code of child process
|
|
* @param {Array} specs Specs that were run
|
|
* @param {number} retries Number or retries remaining
|
|
*/
|
|
async _endHandler({ cid: rid, exitCode, specs, retries }) {
|
|
const passed = this._isWatchModeHalted() || exitCode === 0;
|
|
if (!passed && retries > 0) {
|
|
const requeue = this.configParser.getConfig().specFileRetriesDeferred ? "push" : "unshift";
|
|
this._schedule[parseInt(rid, 10)].specs[requeue]({ files: specs, retries: retries - 1, rid });
|
|
} else {
|
|
this._exitCode = this._isWatchModeHalted() ? 0 : this._exitCode || exitCode;
|
|
this._runnerFailed += !passed ? 1 : 0;
|
|
}
|
|
if (!this._isWatchModeHalted() && this.interface) {
|
|
this.interface.emit("job:end", { cid: rid, passed, retries });
|
|
}
|
|
const cid = parseInt(rid, 10);
|
|
this._schedule[cid].availableInstances++;
|
|
this._schedule[cid].runningInstances--;
|
|
log3.info("Run onWorkerEnd hook");
|
|
const config3 = this.configParser.getConfig();
|
|
await runLauncherHook(config3.onWorkerEnd, rid, exitCode, specs, retries).catch((error) => this._workerHookError(error));
|
|
await runServiceHook(this._launcher, "onWorkerEnd", rid, exitCode, specs, retries).catch((error) => this._workerHookError(error));
|
|
const shouldRunSpecs = this._runSpecs();
|
|
const inWatchMode = this._isWatchMode && !this._hasTriggeredExitRoutine;
|
|
if (!shouldRunSpecs || inWatchMode) {
|
|
if (inWatchMode) {
|
|
this.interface?.finalise();
|
|
}
|
|
return;
|
|
}
|
|
if (this._resolve) {
|
|
this._resolve(passed ? this._exitCode : 1);
|
|
}
|
|
}
|
|
/**
|
|
* We need exitHandler to catch SIGINT / SIGTERM events.
|
|
* Make sure all started selenium sessions get closed properly and prevent
|
|
* having dead driver processes. To do so let the runner end its Selenium
|
|
* session first before killing
|
|
*/
|
|
_exitHandler(callback) {
|
|
if (!callback || !this.runner || !this.interface) {
|
|
return;
|
|
}
|
|
if (this._hasTriggeredExitRoutine) {
|
|
return callback(true);
|
|
}
|
|
this._hasTriggeredExitRoutine = true;
|
|
this.interface.sigintTrigger();
|
|
return this.runner.shutdown().then(callback);
|
|
}
|
|
/**
|
|
* returns true if user stopped watch mode, ex with ctrl+c
|
|
* @returns {boolean}
|
|
*/
|
|
_isWatchModeHalted() {
|
|
return this._isWatchMode && this._hasTriggeredExitRoutine;
|
|
}
|
|
};
|
|
var launcher_default = Launcher;
|
|
|
|
// src/run.ts
|
|
import path3 from "node:path";
|
|
import yargs from "yargs";
|
|
import { hideBin } from "yargs/helpers";
|
|
|
|
// src/commands/index.ts
|
|
import { config as config2 } from "create-wdio/config/cli";
|
|
import { install } from "create-wdio/install/cli";
|
|
|
|
// src/commands/repl.ts
|
|
var repl_exports = {};
|
|
__export(repl_exports, {
|
|
builder: () => builder2,
|
|
cmdArgs: () => cmdArgs2,
|
|
command: () => command2,
|
|
desc: () => desc2,
|
|
handler: () => handler2
|
|
});
|
|
import pickBy3 from "lodash.pickby";
|
|
import { remote } from "webdriverio";
|
|
|
|
// src/commands/run.ts
|
|
var run_exports = {};
|
|
__export(run_exports, {
|
|
builder: () => builder,
|
|
cmdArgs: () => cmdArgs,
|
|
command: () => command,
|
|
desc: () => desc,
|
|
handler: () => handler,
|
|
launch: () => launch,
|
|
launchWithStdin: () => launchWithStdin
|
|
});
|
|
import fs from "node:fs/promises";
|
|
import path2 from "node:path";
|
|
|
|
// src/watcher.ts
|
|
import url from "node:url";
|
|
import path from "node:path";
|
|
import chokidar from "chokidar";
|
|
import pickBy2 from "lodash.pickby";
|
|
import flattenDeep from "lodash.flattendeep";
|
|
import union from "lodash.union";
|
|
import logger4 from "@wdio/logger";
|
|
var log4 = logger4("@wdio/cli:watch");
|
|
var Watcher = class {
|
|
constructor(_configFile, _args) {
|
|
this._configFile = _configFile;
|
|
this._args = _args;
|
|
log4.info("Starting launcher in watch mode");
|
|
this._launcher = new launcher_default(this._configFile, this._args, true);
|
|
}
|
|
_launcher;
|
|
_specs = [];
|
|
async watch() {
|
|
await this._launcher.initialize();
|
|
const specs = this._launcher.configParser.getSpecs();
|
|
const capSpecs = this._launcher.isMultiremote ? [] : union(flattenDeep(
|
|
this._launcher.configParser.getCapabilities().map((cap) => "alwaysMatch" in cap ? cap.alwaysMatch["wdio:specs"] : cap["wdio:specs"] || [])
|
|
));
|
|
this._specs = [...specs, ...capSpecs];
|
|
const flattenedSpecs = flattenDeep(this._specs).map((fileUrl) => url.fileURLToPath(fileUrl));
|
|
chokidar.watch(flattenedSpecs, { ignoreInitial: true }).on("add", this.getFileListener()).on("change", this.getFileListener());
|
|
const { filesToWatch } = this._launcher.configParser.getConfig();
|
|
if (filesToWatch.length) {
|
|
const rootDir = path.dirname(path.resolve(process.cwd(), this._configFile));
|
|
const globbedFilesToWatch = filesToWatch.map((file) => {
|
|
const absolutePath = path.isAbsolute(file) ? path.normalize(file) : path.resolve(rootDir, file);
|
|
return absolutePath;
|
|
});
|
|
chokidar.watch(globbedFilesToWatch, { ignoreInitial: true }).on("add", this.getFileListener(false)).on("change", this.getFileListener(false));
|
|
}
|
|
await this._launcher.run();
|
|
const workers = this.getWorkers();
|
|
Object.values(workers).forEach((worker) => worker.on("exit", () => {
|
|
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) => {
|
|
const runSpecs = [];
|
|
let singleSpecFound = 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((spec2) => {
|
|
return this.run({
|
|
...args,
|
|
...passOnFile ? { spec: [spec2] } : {}
|
|
});
|
|
});
|
|
};
|
|
}
|
|
/**
|
|
* 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, includeBusyWorker) {
|
|
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 = pickBy2(workers, predicate);
|
|
}
|
|
if (!includeBusyWorker) {
|
|
workers = pickBy2(workers, (worker) => !worker.isBusy);
|
|
}
|
|
return workers;
|
|
}
|
|
/**
|
|
* run workers with params
|
|
* @param params parameters to run the worker with
|
|
*/
|
|
run(params = {}) {
|
|
const workers = this.getWorkers(
|
|
params.spec ? (worker) => Boolean(worker.specs.find((s) => params.spec?.includes(s))) : void 0
|
|
);
|
|
if (Object.keys(workers).length === 0 || !this._launcher.interface) {
|
|
return;
|
|
}
|
|
this._launcher.interface.totalWorkerCnt = Object.entries(workers).length;
|
|
this.cleanUp();
|
|
for (const [, worker] of Object.entries(workers)) {
|
|
const { cid, capabilities, specs, sessionId } = worker;
|
|
const { hostname, path: path4, port, protocol, automationProtocol } = worker.config;
|
|
const args = Object.assign({ sessionId, baseUrl: worker.config.baseUrl, hostname, path: path4, port, protocol, automationProtocol }, params);
|
|
worker.postMessage("run", args);
|
|
this._launcher.interface.emit("job:start", { cid, caps: capabilities, specs });
|
|
}
|
|
}
|
|
cleanUp() {
|
|
this._launcher.interface?.setup();
|
|
}
|
|
};
|
|
|
|
// src/commands/run.ts
|
|
import { config } from "create-wdio/config/cli";
|
|
import { ConfigParser as ConfigParser3 } from "@wdio/config/node";
|
|
import logger5 from "@wdio/logger";
|
|
var log5 = logger5("@wdio/cli:run");
|
|
var command = "run <configPath>";
|
|
var desc = "Run your WDIO configuration file to initialize your tests. (default)";
|
|
var cmdArgs = {
|
|
watch: {
|
|
desc: "Run WebdriverIO in watch mode",
|
|
type: "boolean"
|
|
},
|
|
hostname: {
|
|
alias: "h",
|
|
desc: "automation driver host address",
|
|
type: "string"
|
|
},
|
|
port: {
|
|
alias: "p",
|
|
desc: "automation driver port",
|
|
type: "number"
|
|
},
|
|
path: {
|
|
type: "string",
|
|
desc: 'path to WebDriver endpoints (default "/")'
|
|
},
|
|
user: {
|
|
alias: "u",
|
|
desc: "username if using a cloud service as automation backend",
|
|
type: "string"
|
|
},
|
|
key: {
|
|
alias: "k",
|
|
desc: "corresponding access key to the user",
|
|
type: "string"
|
|
},
|
|
logLevel: {
|
|
alias: "l",
|
|
desc: "level of logging verbosity",
|
|
choices: ["trace", "debug", "info", "warn", "error", "silent"]
|
|
},
|
|
bail: {
|
|
desc: "stop test runner after specific amount of tests have failed",
|
|
type: "number"
|
|
},
|
|
baseUrl: {
|
|
desc: "shorten url command calls by setting a base url",
|
|
type: "string"
|
|
},
|
|
waitforTimeout: {
|
|
alias: "w",
|
|
desc: "timeout for all waitForXXX commands",
|
|
type: "number"
|
|
},
|
|
updateSnapshots: {
|
|
alias: "s",
|
|
desc: "update DOM, image or test snapshots",
|
|
type: "string",
|
|
coerce: (value) => {
|
|
if (value === "") {
|
|
return "all";
|
|
}
|
|
return value;
|
|
}
|
|
},
|
|
framework: {
|
|
alias: "f",
|
|
desc: "defines the framework (Mocha, Jasmine or Cucumber) to run the specs",
|
|
type: "string"
|
|
},
|
|
reporters: {
|
|
alias: "r",
|
|
desc: "reporters to print out the results on stdout",
|
|
type: "array"
|
|
},
|
|
suite: {
|
|
desc: "overwrites the specs attribute and runs the defined suite",
|
|
type: "array"
|
|
},
|
|
spec: {
|
|
desc: "run only a certain spec file or wildcard - overrides specs piped from stdin",
|
|
type: "array"
|
|
},
|
|
exclude: {
|
|
desc: "exclude certain spec file or wildcard from the test run - overrides exclude piped from stdin",
|
|
type: "array"
|
|
},
|
|
"repeat": {
|
|
desc: "Repeat specific specs and/or suites N times",
|
|
type: "number"
|
|
},
|
|
mochaOpts: {
|
|
desc: "Mocha options",
|
|
coerce: coerceOptsFor("mocha")
|
|
},
|
|
jasmineOpts: {
|
|
desc: "Jasmine options",
|
|
coerce: coerceOptsFor("jasmine")
|
|
},
|
|
cucumberOpts: {
|
|
desc: "Cucumber options",
|
|
coerce: coerceOptsFor("cucumber")
|
|
},
|
|
coverage: {
|
|
desc: "Enable coverage for browser runner"
|
|
},
|
|
shard: {
|
|
desc: "Shard tests and execute only the selected shard. Specify in the one-based form like `--shard x/y`, where x is the current and y the total shard.",
|
|
coerce: (shard) => {
|
|
const [current, total] = shard.split("/").map(Number);
|
|
if (Number.isNaN(current) || Number.isNaN(total)) {
|
|
throw new Error("Shard parameter must be in the form `x/y`, where x and y are positive integers.");
|
|
}
|
|
return { current, total };
|
|
}
|
|
}
|
|
};
|
|
var builder = (yargs2) => {
|
|
return yargs2.options(cmdArgs).example("$0 run wdio.conf.js --suite foobar", 'Run suite on testsuite "foobar"').example("$0 run wdio.conf.js --spec ./tests/e2e/a.js --spec ./tests/e2e/b.js", "Run suite on specific specs").example("$0 run wdio.conf.js --shard 1/4", "Run only the first shard of 4 shards").example("$0 run wdio.conf.js --mochaOpts.timeout 60000", "Run suite with custom Mocha timeout").example("$0 run wdio.conf.js --tsConfigPath=./configs/bdd-tsconfig.json", "Run suite with tsx using custom tsconfig.json").epilogue(CLI_EPILOGUE).help();
|
|
};
|
|
function launchWithStdin(wdioConfPath, params) {
|
|
let stdinData = "";
|
|
process.stdin.resume();
|
|
const stdin = process.stdin;
|
|
stdin.setEncoding("utf8");
|
|
stdin.on("data", (data) => {
|
|
stdinData += data;
|
|
});
|
|
stdin.on("end", () => {
|
|
if (stdinData.length > 0) {
|
|
params.spec = stdinData.trim().split(/\r?\n/);
|
|
}
|
|
launch(wdioConfPath, params);
|
|
});
|
|
}
|
|
async function launch(wdioConfPath, params) {
|
|
const launcher = new launcher_default(wdioConfPath, params);
|
|
return launcher.run().then((...args) => {
|
|
if (!process.env.WDIO_UNIT_TESTS) {
|
|
process.exit(...args);
|
|
}
|
|
}).catch((err) => {
|
|
console.error(err);
|
|
if (!process.env.WDIO_UNIT_TESTS) {
|
|
process.exit(1);
|
|
}
|
|
});
|
|
}
|
|
async function handler(argv) {
|
|
const { configPath = "wdio.conf.js", ...params } = argv;
|
|
const wdioConf = await config.formatConfigFilePaths(configPath);
|
|
const confAccess = await config.canAccessConfigPath(wdioConf.fullPathNoExtension, wdioConf.fullPath);
|
|
if (!confAccess) {
|
|
try {
|
|
await config.missingConfigurationPrompt("run", wdioConf.fullPathNoExtension);
|
|
if (process.env.WDIO_UNIT_TESTS) {
|
|
return;
|
|
}
|
|
return handler(argv);
|
|
} catch {
|
|
process.exit(1);
|
|
}
|
|
}
|
|
const tsConfigPathFromEnvVar = process.env.TSCONFIG_PATH && path2.resolve(process.cwd(), process.env.TSCONFIG_PATH) || process.env.TSX_TSCONFIG_PATH && path2.resolve(process.cwd(), process.env.TSX_TSCONFIG_PATH);
|
|
const tsConfigPathFromParams = params.tsConfigPath && path2.resolve(process.cwd(), params.tsConfigPath);
|
|
const tsConfigPathRelativeToWdioConfig = path2.join(path2.dirname(confAccess), "tsconfig.json");
|
|
const TS_FILE_EXTENSIONS2 = [".ts", ".tsx", ".mts", ".cts"];
|
|
if (TS_FILE_EXTENSIONS2.some((ext) => confAccess.endsWith(ext))) {
|
|
const { resolve: resolve2 } = await import("import-meta-resolve");
|
|
const tsxPath = resolve2("tsx", import.meta.url);
|
|
await import(tsxPath);
|
|
}
|
|
const localTSConfigPath = tsConfigPathFromEnvVar || tsConfigPathFromParams || await tsConfigPathFromConfigFile(confAccess, params) || tsConfigPathRelativeToWdioConfig;
|
|
const hasLocalTSConfig = await fs.access(localTSConfigPath).then(() => true, () => false);
|
|
if (hasLocalTSConfig) {
|
|
process.env.TSX_TSCONFIG_PATH = localTSConfigPath;
|
|
}
|
|
if (params.watch) {
|
|
const watcher = new Watcher(confAccess, params);
|
|
return watcher.watch();
|
|
}
|
|
if (process.stdin.isTTY || !process.stdout.isTTY) {
|
|
return launch(confAccess, params);
|
|
}
|
|
launchWithStdin(confAccess, params);
|
|
}
|
|
async function tsConfigPathFromConfigFile(wdioConfPath, params) {
|
|
try {
|
|
const configParser = new ConfigParser3(wdioConfPath, params);
|
|
await configParser.initialize();
|
|
const { tsConfigPath } = configParser.getConfig();
|
|
if (tsConfigPath) {
|
|
return tsConfigPath;
|
|
}
|
|
} catch {
|
|
log5.debug(`Unable to parse config file. If tsConfigPath is set in ${wdioConfPath}, it will be ignored.`);
|
|
return;
|
|
}
|
|
return;
|
|
}
|
|
|
|
// src/commands/repl.ts
|
|
var IGNORED_ARGS = [
|
|
"bail",
|
|
"framework",
|
|
"reporters",
|
|
"suite",
|
|
"spec",
|
|
"exclude",
|
|
"mochaOpts",
|
|
"jasmineOpts",
|
|
"cucumberOpts"
|
|
];
|
|
var command2 = "repl <option> [capabilities]";
|
|
var desc2 = "Run WebDriver session in command line";
|
|
var cmdArgs2 = {
|
|
platformVersion: {
|
|
alias: "v",
|
|
desc: "Version of OS for mobile devices",
|
|
type: "string"
|
|
},
|
|
deviceName: {
|
|
alias: "d",
|
|
desc: "Device name for mobile devices",
|
|
type: "string"
|
|
},
|
|
udid: {
|
|
alias: "u",
|
|
desc: "UDID of real mobile devices",
|
|
type: "string"
|
|
}
|
|
};
|
|
var builder2 = (yargs2) => {
|
|
return yargs2.options(pickBy3({ ...cmdArgs2, ...cmdArgs }, (_, key) => !IGNORED_ARGS.includes(key))).example("$0 repl firefox --path /", "Run repl locally").example("$0 repl chrome -u <SAUCE_USERNAME> -k <SAUCE_ACCESS_KEY>", "Run repl in Sauce Labs cloud").example("$0 repl android", "Run repl browser on launched Android device").example('$0 repl "./path/to/your_app.app"', "Run repl native app on iOS simulator").example('$0 repl ios -v 11.3 -d "iPhone 7" -u 123432abc', "Run repl browser on iOS device with capabilities").example('$0 repl "./path/to/wdio.config.js" 0 -p 9515', "Run repl using the first capability from the capabilty array in wdio.config.js").example('$0 repl "./path/to/wdio.config.js" "myChromeBrowser" -p 9515', "Run repl using a named multiremote capabilities in wdio.config.js").epilogue(CLI_EPILOGUE).help();
|
|
};
|
|
var handler2 = async (argv) => {
|
|
const caps = await getCapabilities(argv);
|
|
const client = await remote({ ...argv, ...caps });
|
|
global.$ = client.$.bind(client);
|
|
global.$$ = client.$$.bind(client);
|
|
global.browser = client;
|
|
await client.debug();
|
|
return client.deleteSession();
|
|
};
|
|
|
|
// src/commands/index.ts
|
|
var commands = [config2, install, repl_exports, run_exports];
|
|
|
|
// src/run.ts
|
|
var DEFAULT_CONFIG_FILENAME = "wdio.conf.js";
|
|
var DESCRIPTION = [
|
|
"The `wdio` command allows you run and manage your WebdriverIO test suite.",
|
|
"If no command is provided it calls the `run` command by default, so:",
|
|
"",
|
|
"$ wdio wdio.conf.js",
|
|
"",
|
|
"is the same as:",
|
|
"$ wdio run wdio.conf.js",
|
|
"",
|
|
"For more information, visit: https://webdriver.io/docs/clioptions"
|
|
];
|
|
async function run() {
|
|
const argv = yargs(hideBin(process.argv)).command(commands).example("wdio run wdio.conf.js --suite foobar", 'Run suite on testsuite "foobar"').example("wdio run wdio.conf.js --spec ./tests/e2e/a.js --spec ./tests/e2e/b.js", "Run suite on specific specs").example("wdio run wdio.conf.js --spec ./tests/e2e/a.feature:5", "Run scenario by line number").example("wdio run wdio.conf.js --spec ./tests/e2e/a.feature:5:10", "Run scenarios by line number").example("wdio run wdio.conf.js --spec ./tests/e2e/a.feature:5:10 --spec ./test/e2e/b.feature", "Run scenarios by line number in single feature and another complete feature").example("wdio run wdio.conf.js --tsConfigPath=./configs/bdd-tsconfig.json", "Run suite with tsx using custom tsconfig.json").example("wdio install reporter spec", "Install @wdio/spec-reporter").example("wdio repl chrome -u <SAUCE_USERNAME> -k <SAUCE_ACCESS_KEY>", "Run repl in Sauce Labs cloud").updateStrings({ "Commands:": `${DESCRIPTION.join("\n")}
|
|
|
|
Commands:` }).version(pkg.version).epilogue(CLI_EPILOGUE);
|
|
if (!process.argv.find((arg) => arg === "--help")) {
|
|
argv.options(cmdArgs);
|
|
}
|
|
const params = await argv.parse();
|
|
if (!params._ || params._.find((param) => SUPPORTED_COMMANDS.includes(param))) {
|
|
return;
|
|
}
|
|
const args = {
|
|
...params,
|
|
configPath: path3.resolve(process.cwd(), params._[0] && params._[0].toString() || DEFAULT_CONFIG_FILENAME)
|
|
};
|
|
try {
|
|
const cp = await handler(args);
|
|
return cp;
|
|
} catch (err) {
|
|
const output = await new Promise(
|
|
(resolve2) => yargs(hideBin(process.argv)).parse("--help", (err2, argv2, output2) => resolve2(output2))
|
|
);
|
|
console.error(`${output}
|
|
|
|
${err.stack}`);
|
|
if (!process.env.WDIO_UNIT_TESTS) {
|
|
process.exit(1);
|
|
}
|
|
}
|
|
}
|
|
export {
|
|
launcher_default as Launcher,
|
|
run
|
|
};
|