UNPKG

@wdio/sauce-service

Version:

WebdriverIO service that provides a better integration into Sauce Labs

560 lines (552 loc) 18.2 kB
// 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 };