@wdio/sauce-service
Version:
WebdriverIO service that provides a better integration into Sauce Labs
560 lines (552 loc) • 18.2 kB
JavaScript
// src/launcher.ts
import { performance, PerformanceObserver } from "node:perf_hooks";
import {
default as SauceLabs
} from "saucelabs";
import logger from "@wdio/logger";
// src/utils.ts
function isRDC(caps) {
const { "appium:deviceName": appiumDeviceName = "", deviceName = "", platformName = "" } = caps;
const name = appiumDeviceName || deviceName;
return !name.match(/(simulator)|(emulator)/gi) && !!platformName.match(/(ios)|(android)/gi);
}
function makeCapabilityFactory(tunnelName) {
return (capability) => {
if (!capability["sauce:options"]) {
capability["sauce:options"] = {};
}
capability["sauce:options"].tunnelName = capability["sauce:options"].tunnelName || tunnelName;
};
}
function ansiRegex() {
const pattern = [
"[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)",
"(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-ntqry=><~]))"
].join("|");
return new RegExp(pattern, "g");
}
// src/constants.ts
var DEFAULT_OPTIONS = {
uploadLogs: true
};
var DEFAULT_RUNNER_NAME = "webdriverio";
// src/launcher.ts
import path from "node:path";
var MAX_SC_START_TRIALS = 3;
var log = logger("@wdio/sauce-service");
var SauceLauncher = class {
constructor(_options, _, _config) {
this._options = _options;
this._config = _config;
this._api = new SauceLabs.default(this._config);
}
_api;
_sauceConnectProcess;
/**
* modify config and launch sauce connect
*/
async onPrepare(_, capabilities) {
if (!this._options.sauceConnect) {
return;
}
const sauceConnectTunnelName = this._options.sauceConnectOpts?.tunnelName || /**
* generate random identifier if not provided
*/
`SC-tunnel-${Math.random().toString().slice(2)}`;
let metadata = this._options.sauceConnectOpts?.metadata || "";
if (!metadata.includes("runner=")) {
metadata += `runner=${DEFAULT_RUNNER_NAME}`;
}
const sauceConnectOpts = {
tunnelName: sauceConnectTunnelName,
...this._options.sauceConnectOpts,
metadata,
logger: this._options.sauceConnectOpts?.logger || ((output) => log.debug(`Sauce Connect Log: ${output}`)),
...!this._options.sauceConnectOpts?.logFile && this._config.outputDir ? { logFile: path.join(this._config.outputDir, "wdio-sauce-connect-tunnel.log") } : {}
};
const prepareCapability = makeCapabilityFactory(sauceConnectTunnelName);
if (Array.isArray(capabilities)) {
for (const capability of capabilities) {
if (Object.values(capability).length > 0 && Object.values(capability).every((c) => typeof c === "object" && c.capabilities)) {
for (const browserName of Object.keys(capability)) {
const caps = capability[browserName].capabilities;
prepareCapability(caps.alwaysMatch || caps);
}
} else {
prepareCapability(capability);
}
}
} else {
for (const browserName of Object.keys(capabilities)) {
const caps = capabilities[browserName].capabilities;
prepareCapability(caps.alwaysMatch || caps);
}
}
const obs = new PerformanceObserver((list) => {
const entry = list.getEntries()[0];
log.info(`Sauce Connect successfully started after ${entry.duration}ms`);
});
obs.observe({ entryTypes: ["measure"] });
log.info("Starting Sauce Connect Tunnel");
performance.mark("sauceConnectStart");
this._sauceConnectProcess = await this.startTunnel(sauceConnectOpts);
performance.mark("sauceConnectEnd");
const bootimeMeasure = performance.measure("bootTime", "sauceConnectStart", "sauceConnectEnd");
log.info(`Started Sauce Connect Tunnel within ${bootimeMeasure.duration}ms`);
}
async startTunnel(sauceConnectOpts, retryCount = 0) {
try {
const scProcess = await this._api.startSauceConnect(sauceConnectOpts);
return scProcess;
} catch (err) {
++retryCount;
if (err instanceof Error && /**
* only fail for ENOENT errors due to racing condition
* see: https://github.com/saucelabs/node-saucelabs/issues/86
*/
!err.message.includes("ENOENT") || /**
* or if we reached the maximum rety count
*/
retryCount >= MAX_SC_START_TRIALS) {
throw err;
}
log.debug(`Failed to start Sauce Connect Proxy due to ${err.stack}`);
log.debug(`Retrying ${retryCount}/${MAX_SC_START_TRIALS}`);
return this.startTunnel(sauceConnectOpts, retryCount);
}
}
/**
* shut down sauce connect
*/
onComplete() {
if (!this._sauceConnectProcess) {
return;
}
return this._sauceConnectProcess.close();
}
};
// src/service.ts
import fs from "node:fs/promises";
import path2 from "node:path";
import {
default as SauceLabs2
} from "saucelabs";
import logger2 from "@wdio/logger";
// src/ci.ts
var GITHUB = {
matcher: () => !!process.env.GITHUB_ACTIONS,
ci: {
repo: process.env.GITHUB_REPOSITORY ?? "",
refName: process.env.GITHUB_HEAD_REF ?? process.env.GITHUB_REF_NAME ?? "",
sha: process.env.GITHUB_SHA ?? "",
user: process.env.GITHUB_ACTOR ?? ""
}
};
var GITLAB = {
matcher: () => !!process.env.GITLAB_CI,
ci: {
repo: process.env.CI_PROJECT_PATH ?? "",
refName: process.env.CI_COMMIT_REF_NAME ?? "",
sha: process.env.CI_COMMIT_SHA ?? "",
user: process.env.GITLAB_USER_LOGIN ?? ""
}
};
var JENKINS = {
matcher: () => !!process.env.JENKINS_URL,
ci: {
repo: process.env.GIT_URL ?? "",
refName: process.env.GIT_BRANCH ?? "",
sha: process.env.GIT_COMMIT ?? "",
user: ""
}
};
var BITBUCKET = {
matcher: () => !!process.env.BITBUCKET_BUILD_NUMBER,
ci: {
repo: process.env.BITBUCKET_REPO_FULL_NAME ?? "",
refName: process.env.BITBUCKET_BRANCH ?? "",
sha: process.env.BITBUCKET_COMMIT ?? "",
user: process.env.BITBUCKET_STEP_TRIGGERER_UUID ?? ""
}
};
var CIRCLECI = {
matcher: () => !!process.env.CIRCLECI,
ci: {
repo: process.env.CIRCLE_REPOSITORY_URL ?? "",
refName: process.env.CIRCLE_BRANCH ?? "",
sha: process.env.CIRCLE_SHA1 ?? "",
user: process.env.CIRCLE_USERNAME ?? ""
}
};
var DEFAULT = {
matcher: () => true,
ci: {
repo: "",
refName: "",
sha: "",
user: ""
}
};
var providers = [GITHUB, GITLAB, JENKINS, BITBUCKET, CIRCLECI];
var provider = providers.find((p) => p.matcher());
var CI = provider?.ci ?? DEFAULT.ci;
// src/service.ts
var jobDataProperties = ["name", "tags", "public", "build", "custom-data"];
var log2 = logger2("@wdio/sauce-service");
var SauceService = class {
constructor(options, _capabilities, _config) {
this._capabilities = _capabilities;
this._config = _config;
this._options = { ...DEFAULT_OPTIONS, ...options };
this._api = new SauceLabs2.default(this._config);
this._maxErrorStackLength = this._options.maxErrorStackLength || this._maxErrorStackLength;
this._testStartTime = /* @__PURE__ */ new Date();
this._testRuns = [];
}
_testCnt = 0;
_maxErrorStackLength = 5;
_failures = 0;
// counts failures between reloads
_isServiceEnabled = true;
_isJobNameSet = false;
_testStartTime;
_options;
_api;
_browser;
_isRDC;
_suiteTitle;
_cid = "";
_testRuns;
/**
* gather information about runner
*/
beforeSession(_, __, ___, cid) {
this._cid = cid;
if (!this._config.user) {
this._isServiceEnabled = false;
this._config.user = "unknown_user";
}
if (!this._config.key) {
this._isServiceEnabled = false;
this._config.key = "unknown_key";
}
}
before(_, __, browser) {
this._browser = browser;
const capabilities = this._browser.requestedCapabilities || {};
this._isRDC = isRDC(capabilities);
}
async beforeSuite(suite) {
this._suiteTitle = suite.title;
if (this._browser && !this._isJobNameSet && this._suiteTitle !== "Jasmine__TopLevel__Suite") {
await this._setJobName(this._suiteTitle);
}
}
async beforeTest(test) {
if (!this._isServiceEnabled || !this._browser) {
return;
}
this._testStartTime = /* @__PURE__ */ new Date();
if (this._suiteTitle === "Jasmine__TopLevel__Suite") {
this._suiteTitle = test.fullName.slice(0, test.fullName.indexOf(test.description || "") - 1);
}
if (this._browser && !this._isJobNameSet) {
await this._setJobName(this._suiteTitle);
}
const fullTitle = (
/**
* Jasmine
*/
test.fullName || /**
* Mocha
*/
`${test.parent} - ${test.title}`
);
return this.setAnnotation(`sauce:context=${fullTitle}`);
}
afterSuite(suite) {
if (Object.prototype.hasOwnProperty.call(suite, "error")) {
++this._failures;
}
}
_reportErrorLog(error) {
const lines = (error.stack || "").split(/\r?\n/).slice(0, this._maxErrorStackLength);
lines.forEach((line) => this.setAnnotation(`sauce:context=${line.replace(ansiRegex(), "")}`));
}
afterTest(test, context, results) {
this._collectTestRun(test, results);
if (results.error && results.error.stack) {
this._reportErrorLog(results.error);
}
if (test._retriedTest && results.passed) {
--this._failures;
return;
}
if (test._retriedTest && !results.passed && (typeof test._currentRetry === "number" && typeof test._retries === "number" && test._currentRetry < test._retries)) {
return;
}
const isJasminePendingError = typeof results.error === "string" && results.error.includes("marked Pending");
if (!results.passed && !isJasminePendingError) {
++this._failures;
}
}
afterHook(test, context, results) {
if (results.error) {
this._reportErrorLog(results.error);
}
if (!results.passed) {
++this._failures;
}
}
/**
* For CucumberJS
*/
async beforeFeature(uri, feature) {
if (!this._isServiceEnabled || !this._browser) {
return;
}
this._suiteTitle = feature.name;
if (this._browser && !this._isJobNameSet) {
await this._setJobName(this._suiteTitle);
}
return this.setAnnotation(`sauce:context=Feature: ${this._suiteTitle}`);
}
/**
* Runs before a Cucumber Scenario.
* @param world world object containing information on pickle and test step
*/
beforeScenario(world) {
if (!this._isServiceEnabled || !this._browser) {
return;
}
const scenarioName = world.pickle.name || "unknown scenario";
return this.setAnnotation(`sauce:context=-Scenario: ${scenarioName}`);
}
async beforeStep(step) {
if (!this._isServiceEnabled || !this._browser) {
return;
}
const { keyword, text } = step;
return this.setAnnotation(`sauce:context=--Step: ${keyword}${text}`);
}
/**
* Runs after a Cucumber Scenario.
* @param world world object containing information on pickle and test step
* @param result result object containing
* @param result.passed true if scenario has passed
* @param result.error error stack if scenario failed
* @param result.duration duration of scenario in milliseconds
*/
afterScenario(world, result) {
if (!result.passed) {
++this._failures;
}
}
/**
* update Sauce Labs job
*/
async after(result) {
if (!this._browser || !this._isServiceEnabled) {
return;
}
let failures = this._failures;
if (this._config.mochaOpts && this._config.mochaOpts.bail && Boolean(result)) {
failures = 1;
}
const status = "status: " + (failures > 0 ? "failing" : "passing");
if (!this._browser.isMultiremote) {
await this._uploadLogs(this._browser.sessionId);
this._updateJobIdInTestRuns(this._browser.sessionId);
try {
await this._api.createTestRunsV1({ test_runs: this._testRuns });
} catch (e) {
log2.debug("Submitting test run failed: ", e);
}
log2.info(`Update job with sessionId ${this._browser.sessionId}, ${status}`);
return this._isRDC ? this.setAnnotation(`sauce:job-result=${failures === 0}`) : this.updateJob(this._browser.sessionId, failures);
}
return Promise.all(Object.keys(this._capabilities).map(async (browserName) => {
const multiRemoteBrowser = this._browser.getInstance(browserName);
const isMultiRemoteRDC = isRDC(multiRemoteBrowser.capabilities);
log2.info(`Update multiRemote job for browser "${browserName}" and sessionId ${multiRemoteBrowser.sessionId}, ${status}`);
await this._uploadLogs(multiRemoteBrowser.sessionId);
this._updateJobIdInTestRuns(multiRemoteBrowser.sessionId);
try {
await this._api.createTestRunsV1({ test_runs: this._testRuns });
} catch (e) {
log2.debug("Submitting test run failed: ", e);
}
if (isMultiRemoteRDC) {
return this.setAnnotation(`sauce:job-result=${failures === 0}`);
}
return this.updateJob(multiRemoteBrowser.sessionId, failures, false, browserName);
}));
}
/**
* upload files to Sauce Labs platform
* @param jobId id of the job
* @returns a promise that is resolved once all files got uploaded
*/
async _uploadLogs(jobId) {
if (!this._options.uploadLogs || !this._config.outputDir) {
return;
}
const files = (await fs.readdir(this._config.outputDir)).filter((file) => (file.startsWith(`wdio-${this._cid}.`) || file.startsWith(`wdio-${this._cid}-`)) && file.endsWith(".log"));
log2.info(`Uploading WebdriverIO logs (${files.join(", ")}) to Sauce Labs`);
return this._api.uploadJobAssets(
jobId,
{ files: files.map((file) => path2.join(this._config.outputDir, file)) }
).catch((err) => log2.error(`Couldn't upload log files to Sauce Labs: ${err.message}`));
}
onReload(oldSessionId, newSessionId) {
if (!this._browser || !this._isServiceEnabled) {
return;
}
const status = "status: " + (this._failures > 0 ? "failing" : "passing");
if (!this._browser.isMultiremote) {
log2.info(`Update (reloaded) job with sessionId ${oldSessionId}, ${status}`);
return this.updateJob(oldSessionId, this._failures, true);
}
const mulitremoteBrowser = this._browser;
const browserName = mulitremoteBrowser.instances.filter(
(browserName2) => mulitremoteBrowser.getInstance(browserName2).sessionId === newSessionId
)[0];
log2.info(`Update (reloaded) multiremote job for browser "${browserName}" and sessionId ${oldSessionId}, ${status}`);
return this.updateJob(oldSessionId, this._failures, true, browserName);
}
async updateJob(sessionId, failures, calledOnReload = false, browserName) {
const body = this.getBody(failures, calledOnReload, browserName);
await this._api.updateJob(this._config.user, sessionId, body);
this._failures = 0;
}
/**
* VM message data
*/
getBody(failures, calledOnReload = false, browserName) {
const body = {};
if (calledOnReload || this._testCnt) {
body.name = this._suiteTitle;
if (browserName) {
body.name = `${browserName}: ${body.name}`;
}
let testCnt = ++this._testCnt;
const mulitremoteBrowser = this._browser;
if (this._browser && this._browser.isMultiremote) {
testCnt = Math.ceil(testCnt / mulitremoteBrowser.instances.length);
}
body.name += ` (${testCnt})`;
}
const caps = this._capabilities["sauce:options"] || this._capabilities;
for (const prop of jobDataProperties) {
if (!caps[prop]) {
continue;
}
body[prop] = caps[prop];
}
if (this._options.setJobName) {
body.name = this._options.setJobName(
this._config,
this._capabilities,
this._suiteTitle
);
}
body.passed = failures === 0;
return body;
}
/**
* Update the running Sauce Labs Job with an annotation
*/
async setAnnotation(annotation) {
if (!this._browser) {
return;
}
if (this._browser.isMultiremote) {
return Promise.all(Object.keys(this._capabilities).map(async (browserName) => {
const multiRemoteBrowser = this._browser.getInstance(browserName);
return multiRemoteBrowser.executeScript(annotation, []);
}));
}
return this._browser.executeScript(annotation, []);
}
async _setJobName(suiteTitle) {
if (!suiteTitle) {
return;
}
let jobName = suiteTitle;
if (this._options.setJobName) {
jobName = this._options.setJobName(
this._config,
this._capabilities,
suiteTitle
);
}
await this.setAnnotation(`sauce:job-name=${jobName}`);
this._isJobNameSet = true;
}
_getStatusForTestRun(result) {
if (["passed", "failed", "skipped"].includes(result.status)) {
return result.status;
}
if (result.error) {
return "failed";
}
if (result.passed) {
return "passed";
}
return "skipped";
}
_getOsName(osName) {
if (!osName) {
return "unknown";
}
if ("darwin" === osName) {
return "Mac";
}
return osName;
}
_collectTestRun(test, results) {
const caps = this._capabilities;
const sauceCaps = this._capabilities;
const testRun = {
name: `${test.parent} - ${test.title}`,
start_time: this._testStartTime?.toISOString(),
end_time: (/* @__PURE__ */ new Date()).toISOString(),
duration: results.duration || 0,
browser: caps?.browserName || "chrome",
build_name: sauceCaps?.build?.toString() || "",
tags: sauceCaps?.tags,
framework: "webdriverio",
platform: "other",
os: this._getOsName(process.platform),
status: this._getStatusForTestRun(results),
type: "web",
ci: {
ref_name: CI.refName,
commit_sha: CI.sha,
repository: CI.repo,
branch: CI.refName
}
};
if (results.error) {
testRun.errors = [{
message: results.error?.message?.toString(),
path: test.file
}];
}
this._testRuns?.push(testRun);
}
_updateJobIdInTestRuns(id) {
this._testRuns?.forEach((testRun) => {
testRun.sauce_job = {
id
};
});
}
};
// src/index.ts
var index_default = SauceService;
var launcher = SauceLauncher;
export {
index_default as default,
launcher
};