diff --git a/bin/accessibility-automation/constants.js b/bin/accessibility-automation/constants.js new file mode 100644 index 00000000..496667a9 --- /dev/null +++ b/bin/accessibility-automation/constants.js @@ -0,0 +1 @@ +exports.API_URL = 'https://accessibility.browserstack.com/api'; diff --git a/bin/accessibility-automation/cypress/index.js b/bin/accessibility-automation/cypress/index.js new file mode 100644 index 00000000..6586a256 --- /dev/null +++ b/bin/accessibility-automation/cypress/index.js @@ -0,0 +1,381 @@ +/* Event listeners + custom commands for Cypress */ + +const browserStackLog = (message) => { + if (!Cypress.env('BROWSERSTACK_LOGS')) return; + cy.task('browserstack_log', message); + } + +const commandsToWrap = ['visit', 'click', 'type', 'request', 'dblclick', 'rightclick', 'clear', 'check', 'uncheck', 'select', 'trigger', 'selectFile', 'scrollIntoView', 'scroll', 'scrollTo', 'blur', 'focus', 'go', 'reload', 'submit', 'viewport', 'origin']; + +const performScan = (win, payloadToSend) => +new Promise(async (resolve, reject) => { + const isHttpOrHttps = /^(http|https):$/.test(win.location.protocol); + if (!isHttpOrHttps) { + resolve(); + } + + function findAccessibilityAutomationElement() { + return win.document.querySelector("#accessibility-automation-element"); + } + + function waitForScannerReadiness(retryCount = 30, retryInterval = 100) { + return new Promise(async (resolve, reject) => { + let count = 0; + const intervalID = setInterval(async () => { + if (count > retryCount) { + clearInterval(intervalID); + reject( + new Error( + "Accessibility Automation Scanner is not ready on the page." + ) + ); + } else if (findAccessibilityAutomationElement()) { + clearInterval(intervalID); + resolve("Scanner set"); + } else { + count += 1; + } + }, retryInterval); + }); + } + + function startScan() { + function onScanComplete() { + win.removeEventListener("A11Y_SCAN_FINISHED", onScanComplete); + resolve(); + } + + win.addEventListener("A11Y_SCAN_FINISHED", onScanComplete); + const e = new CustomEvent("A11Y_SCAN", { detail: payloadToSend }); + win.dispatchEvent(e); + } + + if (findAccessibilityAutomationElement()) { + startScan(); + } else { + waitForScannerReadiness() + .then(startScan) + .catch(async (err) => { + resolve("Scanner is not ready on the page after multiple retries. performscan"); + }); + } +}) + +const getAccessibilityResultsSummary = (win) => +new Promise((resolve) => { + const isHttpOrHttps = /^(http|https):$/.test(window.location.protocol); + if (!isHttpOrHttps) { + resolve(); + } + + function findAccessibilityAutomationElement() { + return win.document.querySelector("#accessibility-automation-element"); + } + + function waitForScannerReadiness(retryCount = 30, retryInterval = 100) { + return new Promise((resolve, reject) => { + let count = 0; + const intervalID = setInterval(() => { + if (count > retryCount) { + clearInterval(intervalID); + reject( + new Error( + "Accessibility Automation Scanner is not ready on the page." + ) + ); + } else if (findAccessibilityAutomationElement()) { + clearInterval(intervalID); + resolve("Scanner set"); + } else { + count += 1; + } + }, retryInterval); + }); + } + + function getSummary() { + function onReceiveSummary(event) { + win.removeEventListener("A11Y_RESULTS_SUMMARY", onReceiveSummary); + resolve(event.detail); + } + + win.addEventListener("A11Y_RESULTS_SUMMARY", onReceiveSummary); + const e = new CustomEvent("A11Y_GET_RESULTS_SUMMARY"); + win.dispatchEvent(e); + } + + if (findAccessibilityAutomationElement()) { + getSummary(); + } else { + waitForScannerReadiness() + .then(getSummary) + .catch((err) => { + resolve(); + }); + } +}) + +const getAccessibilityResults = (win) => +new Promise((resolve) => { + const isHttpOrHttps = /^(http|https):$/.test(window.location.protocol); + if (!isHttpOrHttps) { + resolve(); + } + + function findAccessibilityAutomationElement() { + return win.document.querySelector("#accessibility-automation-element"); + } + + function waitForScannerReadiness(retryCount = 30, retryInterval = 100) { + return new Promise((resolve, reject) => { + let count = 0; + const intervalID = setInterval(() => { + if (count > retryCount) { + clearInterval(intervalID); + reject( + new Error( + "Accessibility Automation Scanner is not ready on the page." + ) + ); + } else if (findAccessibilityAutomationElement()) { + clearInterval(intervalID); + resolve("Scanner set"); + } else { + count += 1; + } + }, retryInterval); + }); + } + + function getResults() { + function onReceivedResult(event) { + win.removeEventListener("A11Y_RESULTS_RESPONSE", onReceivedResult); + resolve(event.detail); + } + + win.addEventListener("A11Y_RESULTS_RESPONSE", onReceivedResult); + const e = new CustomEvent("A11Y_GET_RESULTS"); + win.dispatchEvent(e); + } + + if (findAccessibilityAutomationElement()) { + getResults(); + } else { + waitForScannerReadiness() + .then(getResults) + .catch((err) => { + resolve(); + }); + } +}); + +const saveTestResults = (win, payloadToSend) => +new Promise( (resolve, reject) => { + try { + const isHttpOrHttps = /^(http|https):$/.test(win.location.protocol); + if (!isHttpOrHttps) { + resolve("Unable to save accessibility results, Invalid URL."); + } + + function findAccessibilityAutomationElement() { + return win.document.querySelector("#accessibility-automation-element"); + } + + function waitForScannerReadiness(retryCount = 30, retryInterval = 100) { + return new Promise((resolve, reject) => { + let count = 0; + const intervalID = setInterval(async () => { + if (count > retryCount) { + clearInterval(intervalID); + reject( + new Error( + "Accessibility Automation Scanner is not ready on the page." + ) + ); + } else if (findAccessibilityAutomationElement()) { + clearInterval(intervalID); + resolve("Scanner set"); + } else { + count += 1; + } + }, retryInterval); + }); + } + + function saveResults() { + function onResultsSaved(event) { + resolve(); + } + win.addEventListener("A11Y_RESULTS_SAVED", onResultsSaved); + const e = new CustomEvent("A11Y_SAVE_RESULTS", { + detail: payloadToSend, + }); + win.dispatchEvent(e); + } + + if (findAccessibilityAutomationElement()) { + saveResults(); + } else { + waitForScannerReadiness() + .then(saveResults) + .catch(async (err) => { + resolve("Scanner is not ready on the page after multiple retries. after run"); + }); + } + } catch(er) { + resolve() + } + +}) + +const shouldScanForAccessibility = (attributes) => { + if (Cypress.env("IS_ACCESSIBILITY_EXTENSION_LOADED") !== "true") return false; + + const extensionPath = Cypress.env("ACCESSIBILITY_EXTENSION_PATH"); + const isHeaded = Cypress.browser.isHeaded; + + if (!isHeaded || (extensionPath === undefined)) return false; + + let shouldScanTestForAccessibility = true; + + if (Cypress.env("INCLUDE_TAGS_FOR_ACCESSIBILITY") || Cypress.env("EXCLUDE_TAGS_FOR_ACCESSIBILITY")) { + try { + let includeTagArray = []; + let excludeTagArray = []; + if (Cypress.env("INCLUDE_TAGS_FOR_ACCESSIBILITY")) { + includeTagArray = Cypress.env("INCLUDE_TAGS_FOR_ACCESSIBILITY").split(";") + } + if (Cypress.env("EXCLUDE_TAGS_FOR_ACCESSIBILITY")) { + excludeTagArray = Cypress.env("EXCLUDE_TAGS_FOR_ACCESSIBILITY").split(";") + } + + const fullTestName = attributes.title; + const excluded = excludeTagArray.some((exclude) => fullTestName.includes(exclude)); + const included = includeTagArray.length === 0 || includeTags.some((include) => fullTestName.includes(include)); + shouldScanTestForAccessibility = !excluded && included; + } catch (error) { + browserStackLog("Error while validating test case for accessibility before scanning. Error : ", error); + } + } + + return shouldScanTestForAccessibility; +} + +Cypress.on('command:start', async (command) => { + if(!command || !command.attributes) return; + if(command.attributes.name == 'window' || command.attributes.name == 'then' || command.attributes.name == 'wrap') { + return; + } + + if (!commandsToWrap.includes(command.attributes.name)) return; + + const attributes = Cypress.mocha.getRunner().suite.ctx.currentTest || Cypress.mocha.getRunner().suite.ctx._runnable; + + let shouldScanTestForAccessibility = shouldScanForAccessibility(attributes); + if (!shouldScanTestForAccessibility) return; + + cy.window().then((win) => { + browserStackLog('Performing scan form command ' + command.attributes.name); + cy.wrap(performScan(win, {method: command.attributes.name}), {timeout: 30000}); + }) +}) + +afterEach(() => { + const attributes = Cypress.mocha.getRunner().suite.ctx.currentTest; + cy.window().then(async (win) => { + let shouldScanTestForAccessibility = shouldScanForAccessibility(attributes); + if (!shouldScanTestForAccessibility) return cy.wrap({}); + + cy.wrap(performScan(win), {timeout: 30000}).then(() => { + try { + let os_data; + if (Cypress.env("OS")) { + os_data = Cypress.env("OS"); + } else { + os_data = Cypress.platform === 'linux' ? 'mac' : "win" + } + let filePath = ''; + if (attributes.invocationDetails !== undefined && attributes.invocationDetails.relativeFile !== undefined) { + filePath = attributes.invocationDetails.relativeFile; + } + const payloadToSend = { + "saveResults": shouldScanTestForAccessibility, + "testDetails": { + "name": attributes.title, + "testRunId": '5058', // variable not consumed, shouldn't matter what we send + "filePath": filePath, + "scopeList": [ + filePath, + attributes.title + ] + }, + "platform": { + "os_name": os_data, + "os_version": Cypress.env("OS_VERSION"), + "browser_name": Cypress.browser.name, + "browser_version": Cypress.browser.version + } + }; + browserStackLog(`Saving accessibility test results`); + cy.wrap(saveTestResults(win, payloadToSend), {timeout: 30000}).then(() => { + browserStackLog(`Saved accessibility test results`); + }) + + } catch (er) { + } + }) + }); +}) + +Cypress.Commands.add('performScan', () => { + try { + const attributes = Cypress.mocha.getRunner().suite.ctx.currentTest || Cypress.mocha.getRunner().suite.ctx._runnable; + const shouldScanTestForAccessibility = shouldScanForAccessibility(attributes); + if (!shouldScanTestForAccessibility) { + browserStackLog(`Not a Accessibility Automation session, cannot perform scan.`); + return cy.wrap({}); + } + cy.window().then(async (win) => { + browserStackLog(`Performing accessibility scan`); + await performScan(win); + }); + } catch {} +}) + +Cypress.Commands.add('getAccessibilityResultsSummary', () => { + try { + const attributes = Cypress.mocha.getRunner().suite.ctx.currentTest || Cypress.mocha.getRunner().suite.ctx._runnable; + const shouldScanTestForAccessibility = shouldScanForAccessibility(attributes); + if (!shouldScanTestForAccessibility) { + browserStackLog(`Not a Accessibility Automation session, cannot retrieve Accessibility results summary.`); + return cy.wrap({}); + } + cy.window().then(async (win) => { + await performScan(win); + browserStackLog('Getting accessibility results summary'); + return await getAccessibilityResultsSummary(win); + }); + } catch {} + +}); + +Cypress.Commands.add('getAccessibilityResults', () => { + try { + const attributes = Cypress.mocha.getRunner().suite.ctx.currentTest || Cypress.mocha.getRunner().suite.ctx._runnable; + const shouldScanTestForAccessibility = shouldScanForAccessibility(attributes); + if (!shouldScanTestForAccessibility) { + browserStackLog(`Not a Accessibility Automation session, cannot retrieve Accessibility results.`); + return cy.wrap({}); + } + + /* browserstack_accessibility_automation_script */ + + cy.window().then(async (win) => { + await performScan(win); + browserStackLog('Getting accessibility results'); + return await getAccessibilityResults(win); + }); + + } catch {} + +}); diff --git a/bin/accessibility-automation/helper.js b/bin/accessibility-automation/helper.js new file mode 100644 index 00000000..1d49988f --- /dev/null +++ b/bin/accessibility-automation/helper.js @@ -0,0 +1,247 @@ +const logger = require("../helpers/logger").winstonLogger; +const { API_URL } = require('./constants'); +const utils = require('../helpers/utils'); +const fs = require('fs'); +const path = require('path'); +const axios = require('axios'); +const os = require('os'); +const glob = require('glob'); +const helper = require('../helpers/helper'); +const { CYPRESS_V10_AND_ABOVE_CONFIG_FILE_EXTENSIONS } = require('../helpers/constants'); +const { consoleHolder } = require("../testObservability/helper/constants"); +const supportFileContentMap = {} +const HttpsProxyAgent = require('https-proxy-agent'); + +exports.checkAccessibilityPlatform = (user_config) => { + let accessibility = false; + try { + user_config.browsers.forEach(browser => { + if (browser.accessibility) { + accessibility = true; + } + }) + } catch {} + + return accessibility; +} + +exports.setAccessibilityCypressCapabilities = async (user_config, accessibilityResponse) => { + if (utils.isUndefined(user_config.run_settings.accessibilityOptions)) { + user_config.run_settings.accessibilityOptions = {} + } + user_config.run_settings.accessibilityOptions["authToken"] = accessibilityResponse.data.accessibilityToken; + user_config.run_settings.accessibilityOptions["auth"] = accessibilityResponse.data.accessibilityToken; + user_config.run_settings.accessibilityOptions["scannerVersion"] = accessibilityResponse.data.scannerVersion; + user_config.run_settings.system_env_vars.push(`ACCESSIBILITY_AUTH=${accessibilityResponse.data.accessibilityToken}`) + user_config.run_settings.system_env_vars.push(`ACCESSIBILITY_SCANNERVERSION=${accessibilityResponse.data.scannerVersion}`) +} + +exports.isAccessibilitySupportedCypressVersion = (cypress_config_filename) => { + const extension = cypress_config_filename.split('.').pop(); + return CYPRESS_V10_AND_ABOVE_CONFIG_FILE_EXTENSIONS.includes(extension); +} + +exports.createAccessibilityTestRun = async (user_config, framework) => { + + try { + if (!this.isAccessibilitySupportedCypressVersion(user_config.run_settings.cypress_config_file) ){ + logger.warn(`Accessibility Testing is not supported on Cypress version 9 and below.`) + process.env.BROWSERSTACK_TEST_ACCESSIBILITY = 'false'; + user_config.run_settings.accessibility = false; + return; + } + const userName = user_config["auth"]["username"]; + const accessKey = user_config["auth"]["access_key"]; + let settings = utils.isUndefined(user_config.run_settings.accessibilityOptions) ? {} : user_config.run_settings.accessibilityOptions + + const { + buildName, + projectName, + buildDescription + } = helper.getBuildDetails(user_config); + + const data = { + 'projectName': projectName, + 'buildName': buildName, + 'startTime': (new Date()).toISOString(), + 'description': buildDescription, + 'source': { + frameworkName: "Cypress", + frameworkVersion: helper.getPackageVersion('cypress', user_config), + sdkVersion: helper.getAgentVersion(), + language: 'javascript', + testFramework: 'cypress', + testFrameworkVersion: helper.getPackageVersion('cypress', user_config) + }, + 'settings': settings, + 'versionControl': await helper.getGitMetaData(), + 'ciInfo': helper.getCiInfo(), + 'hostInfo': { + hostname: os.hostname(), + platform: os.platform(), + type: os.type(), + version: os.version(), + arch: os.arch() + }, + 'browserstackAutomation': process.env.BROWSERSTACK_AUTOMATION === 'true' + }; + + const config = { + auth: { + username: userName, + password: accessKey + }, + headers: { + 'Content-Type': 'application/json' + } + }; + + const response = await nodeRequest( + 'POST', 'v2/test_runs', data, config, API_URL + ); + if(!utils.isUndefined(response.data)) { + process.env.BS_A11Y_JWT = response.data.data.accessibilityToken; + process.env.BS_A11Y_TEST_RUN_ID = response.data.data.id; + } + if (process.env.BS_A11Y_JWT) { + process.env.BROWSERSTACK_TEST_ACCESSIBILITY = 'true'; + } + logger.debug(`BrowserStack Accessibility Automation Test Run ID: ${response.data.data.id}`); + + this.setAccessibilityCypressCapabilities(user_config, response.data); + helper.setBrowserstackCypressCliDependency(user_config); + + } catch (error) { + if (error.response) { + logger.error("Incorrect Cred") + logger.error( + `Exception while creating test run for BrowserStack Accessibility Automation: ${ + error.response.status + } ${error.response.statusText} ${JSON.stringify(error.response.data)}` + ); + } else { + if(error.message === 'Invalid configuration passed.') { + logger.error("Invalid configuration passed.") + logger.error( + `Exception while creating test run for BrowserStack Accessibility Automation: ${ + error.message || error.stack + }` + ); + for(const errorkey of error.errors){ + logger.error(errorkey.message); + } + + } else { + logger.error( + `Exception while creating test run for BrowserStack Accessibility Automation: ${ + error.message || error.stack + }` + ); + } + // since create accessibility session failed + process.env.BROWSERSTACK_TEST_ACCESSIBILITY = 'false'; + user_config.run_settings.accessibility = false; + } + } +} + +const nodeRequest = (type, url, data, config) => { + return new Promise(async (resolve, reject) => { + const options = { + ...config, + method: type, + url: `${API_URL}/${url}`, + data: data + }; + + if(process.env.HTTP_PROXY){ + options.proxy = false + options.httpsAgent = new HttpsProxyAgent(process.env.HTTP_PROXY); + + } else if (process.env.HTTPS_PROXY){ + options.proxy = false + options.httpsAgent = new HttpsProxyAgent(process.env.HTTPS_PROXY); + } + + axios(options).then(response => { + if(!(response.status == 201 || response.status == 200)) { + logger.info("response.status in nodeRequest", response.status); + reject(response && response.data ? response.data : `Received response from BrowserStack Server with status : ${response.status}`); + } else { + try { + if(typeof(response.data) !== 'object') body = JSON.parse(response.data); + } catch(e) { + if(!url.includes('/stop')) { + reject('Not a JSON response from BrowserStack Server'); + } + } + resolve({ + data: response.data + }); + } + }).catch(error => { + + logger.info("error in nodeRequest", error); + reject(error); + }) + }); +} + +exports.supportFileCleanup = () => { + logger.debug("Cleaning up support file changes added for accessibility.") + Object.keys(supportFileContentMap).forEach(file => { + try { + if(typeof supportFileContentMap[file] === 'object') { + let fileOrDirpath = file; + if(supportFileContentMap[file].deleteSupportDir) { + fileOrDirpath = path.join(process.cwd(), 'cypress', 'support'); + } + helper.deleteSupportFileOrDir(fileOrDirpath); + } else { + fs.writeFileSync(file, supportFileContentMap[file], {encoding: 'utf-8'}); + } + } catch(e) { + logger.debug(`Error while replacing file content for ${file} with it's original content with error : ${e}`, true, e); + } + }); +} + +const getAccessibilityCypressCommandEventListener = (extName) => { + return extName == 'js' ? ( + `require('browserstack-cypress-cli/bin/accessibility-automation/cypress');` + ) : ( + `import 'browserstack-cypress-cli/bin/accessibility-automation/cypress'` + ) +} + +exports.setAccessibilityEventListeners = (bsConfig) => { + try { + // Searching form command.js recursively + const supportFilesData = helper.getSupportFiles(bsConfig, true); + if(!supportFilesData.supportFile) return; + glob(process.cwd() + supportFilesData.supportFile, {}, (err, files) => { + if(err) return logger.debug('EXCEPTION IN BUILD START EVENT : Unable to parse cypress support files'); + files.forEach(file => { + try { + if(!file.includes('commands.js') && !file.includes('commands.ts')) { + const defaultFileContent = fs.readFileSync(file, {encoding: 'utf-8'}); + + let cypressCommandEventListener = getAccessibilityCypressCommandEventListener(path.extname(file)); + if(!defaultFileContent.includes(cypressCommandEventListener)) { + let newFileContent = defaultFileContent + + '\n' + + cypressCommandEventListener + + '\n' + fs.writeFileSync(file, newFileContent, {encoding: 'utf-8'}); + supportFileContentMap[file] = supportFilesData.cleanupParams ? supportFilesData.cleanupParams : defaultFileContent; + } + } + } catch(e) { + logger.debug(`Unable to modify file contents for ${file} to set event listeners with error ${e}`, true, e); + } + }); + }); + } catch(e) { + logger.debug(`Unable to parse support files to set event listeners with error ${e}`, true, e); + } +} diff --git a/bin/accessibility-automation/plugin/index.js b/bin/accessibility-automation/plugin/index.js new file mode 100644 index 00000000..8d614cf7 --- /dev/null +++ b/bin/accessibility-automation/plugin/index.js @@ -0,0 +1,52 @@ +const path = require("node:path"); + +const browserstackAccessibility = (on, config) => { + let browser_validation = true; + if (process.env.BROWSERSTACK_ACCESSIBILITY_DEBUG === 'true') { + config.env.BROWSERSTACK_LOGS = 'true'; + process.env.BROWSERSTACK_LOGS = 'true'; + } + on('task', { + browserstack_log(message) { + console.log(message) + + return null + }, + }) + on('before:browser:launch', (browser = {}, launchOptions) => { + try { + if (process.env.ACCESSIBILITY_EXTENSION_PATH !== undefined) { + if (browser.name !== 'chrome') { + console.log(`Accessibility Automation will run only on Chrome browsers.`); + browser_validation = false; + } + if (browser.name === 'chrome' && browser.majorVersion <= 94) { + console.log(`Accessibility Automation will run only on Chrome browser version greater than 94.`); + browser_validation = false; + } + if (browser.isHeadless === true) { + console.log(`Accessibility Automation will not run on legacy headless mode. Switch to new headless mode or avoid using headless mode.`); + browser_validation = false; + } + if (browser_validation) { + const ally_path = path.dirname(process.env.ACCESSIBILITY_EXTENSION_PATH) + launchOptions.extensions.push(ally_path); + return launchOptions + } + } + } catch(err) {} + + }) + config.env.ACCESSIBILITY_EXTENSION_PATH = process.env.ACCESSIBILITY_EXTENSION_PATH + config.env.OS_VERSION = process.env.OS_VERSION + config.env.OS = process.env.OS + + config.env.IS_ACCESSIBILITY_EXTENSION_LOADED = browser_validation.toString() + + config.env.INCLUDE_TAGS_FOR_ACCESSIBILITY = process.env.ACCESSIBILITY_INCLUDETAGSINTESTINGSCOPE + config.env.EXCLUDE_TAGS_FOR_ACCESSIBILITY = process.env.ACCESSIBILITY_EXCLUDETAGSINTESTINGSCOPE + + return config; +} + +module.exports = browserstackAccessibility; diff --git a/bin/commands/generateReport.js b/bin/commands/generateReport.js index 7bb11cb7..603ed0a7 100644 --- a/bin/commands/generateReport.js +++ b/bin/commands/generateReport.js @@ -5,13 +5,13 @@ const logger = require("../helpers/logger").winstonLogger, utils = require("../helpers/utils"), reporterHTML = require('../helpers/reporterHTML'), getInitialDetails = require('../helpers/getInitialDetails').getInitialDetails; - +const { isTurboScaleSession } = require('../helpers/atsHelper'); module.exports = function generateReport(args, rawArgs) { let bsConfigPath = utils.getConfigPath(args.cf); let reportGenerator = reporterHTML.reportGenerator; - return utils.validateBstackJson(bsConfigPath).then(function (bsConfig) { + return utils.validateBstackJson(bsConfigPath).then(async function (bsConfig) { // setting setDefaults to {} if not present and set via env variables or via args. utils.setDefaults(bsConfig, args); @@ -21,9 +21,9 @@ module.exports = function generateReport(args, rawArgs) { // accept the access key from command line if provided utils.setAccessKey(bsConfig, args); - getInitialDetails(bsConfig, args, rawArgs).then((buildReportData) => { - - utils.setUsageReportingFlag(bsConfig, args.disableUsageReporting); + try { + let buildReportData = isTurboScaleSession(bsConfig) ? null : await getInitialDetails(bsConfig, args, rawArgs); + utils.setUsageReportingFlag(bsConfig, args.disableUsageReporting); // set cypress config filename utils.setCypressConfigFilename(bsConfig, args); @@ -33,9 +33,9 @@ module.exports = function generateReport(args, rawArgs) { let buildId = args._[1]; reportGenerator(bsConfig, buildId, args, rawArgs, buildReportData); utils.sendUsageReport(bsConfig, args, 'generate-report called', messageType, errorCode, buildReportData, rawArgs); - }).catch((err) => { + } catch(err) { logger.warn(err); - }); + }; }).catch(function (err) { logger.error(err); utils.setUsageReportingFlag(null, args.disableUsageReporting); diff --git a/bin/commands/runs.js b/bin/commands/runs.js index 84440b50..e719d97e 100644 --- a/bin/commands/runs.js +++ b/bin/commands/runs.js @@ -22,6 +22,23 @@ const archiver = require("../helpers/archiver"), packageDiff = require('../helpers/package-diff'); const { getStackTraceUrl } = require('../helpers/sync/syncSpecsLogs'); +const { + launchTestSession, + setEventListeners, + setTestObservabilityFlags, + runCypressTestsLocally, + printBuildLink +} = require('../testObservability/helper/helper'); + +const { + createAccessibilityTestRun, + setAccessibilityEventListeners, + checkAccessibilityPlatform, + supportFileCleanup +} = require('../accessibility-automation/helper'); +const { isTurboScaleSession, getTurboScaleGridDetails, patchCypressConfigFileContent, atsFileCleanup } = require('../helpers/atsHelper'); + + module.exports = function run(args, rawArgs) { markBlockStart('preBuild'); @@ -47,8 +64,14 @@ module.exports = function run(args, rawArgs) { // set cypress config filename utils.setCypressConfigFilename(bsConfig, args); + + /* Set testObservability & browserstackAutomation flags */ + const [isTestObservabilitySession, isBrowserstackInfra] = setTestObservabilityFlags(bsConfig); + const checkAccessibility = checkAccessibilityPlatform(bsConfig); + const isAccessibilitySession = bsConfig.run_settings.accessibility || checkAccessibility; + const turboScaleSession = isTurboScaleSession(bsConfig); + Constants.turboScaleObj.enabled = turboScaleSession; - const turboScaleSession = false; utils.setUsageReportingFlag(bsConfig, args.disableUsageReporting); @@ -60,8 +83,7 @@ module.exports = function run(args, rawArgs) { // accept the access key from command line or env variable if provided utils.setAccessKey(bsConfig, args); - const isBrowserstackInfra = true - let buildReportData = await getInitialDetails(bsConfig, args, rawArgs); + let buildReportData = (turboScaleSession || !isBrowserstackInfra) ? null : await getInitialDetails(bsConfig, args, rawArgs); // accept the build name from command line if provided utils.setBuildName(bsConfig, args); @@ -89,6 +111,12 @@ module.exports = function run(args, rawArgs) { // set build tag caps utils.setBuildTags(bsConfig, args); + + // Send build start to Observability + if(isTestObservabilitySession) { + await launchTestSession(bsConfig, bsConfigPath); + utils.setO11yProcessHooks(null, bsConfig, args, null, buildReportData); + } // accept the system env list from bsconf and set it utils.setSystemEnvs(bsConfig); @@ -117,9 +145,36 @@ module.exports = function run(args, rawArgs) { // add cypress dependency if missing utils.setCypressNpmDependency(bsConfig); + + if (isAccessibilitySession && isBrowserstackInfra) { + await createAccessibilityTestRun(bsConfig); + } + + if (turboScaleSession) { + // Local is only required in case user is running on trial grid and wants to access private website. + // Even then, it will be spawned separately via browserstack-cli ats connect-grid command and not via browserstack-cypress-cli + // Hence whenever running on ATS, need to make local as false + bsConfig.connection_settings.local = false; + + const gridDetails = await getTurboScaleGridDetails(bsConfig, args, rawArgs); + + if (gridDetails && Object.keys(gridDetails).length > 0) { + Constants.turboScaleObj.gridDetails = gridDetails; + Constants.turboScaleObj.gridUrl = gridDetails.cypressUrl; + Constants.turboScaleObj.uploadUrl = gridDetails.cypressUrl + '/upload'; + Constants.turboScaleObj.buildUrl = gridDetails.cypressUrl + '/build'; + + logger.debug(`Automate TurboScale Grid URL set to ${gridDetails.url}`); + + patchCypressConfigFileContent(bsConfig); + } else { + process.exitCode = Constants.ERROR_EXIT_CODE; + return; + } + } } - const { packagesInstalled } = await packageInstaller.packageSetupAndInstaller(bsConfig, config.packageDirName, {markBlockStart, markBlockEnd}); + const { packagesInstalled } = !isBrowserstackInfra ? false : await packageInstaller.packageSetupAndInstaller(bsConfig, config.packageDirName, {markBlockStart, markBlockEnd}); if(isBrowserstackInfra) { // set node version @@ -141,10 +196,20 @@ module.exports = function run(args, rawArgs) { markBlockEnd('setConfig'); logger.debug("Completed setting the configs"); + if(!isBrowserstackInfra) { + return runCypressTestsLocally(bsConfig, args, rawArgs); + } + // Validate browserstack.json values and parallels specified via arguments markBlockStart('validateConfig'); logger.debug("Started configs validation"); return capabilityHelper.validate(bsConfig, args).then(function (cypressConfigFile) { + if(process.env.BROWSERSTACK_TEST_ACCESSIBILITY) { + setAccessibilityEventListeners(bsConfig); + } + if(process.env.BS_TESTOPS_BUILD_COMPLETED) { + // setEventListeners(bsConfig); + } markBlockEnd('validateConfig'); logger.debug("Completed configs validation"); markBlockStart('preArchiveSteps'); @@ -222,6 +287,14 @@ module.exports = function run(args, rawArgs) { markBlockEnd('zip.zipUpload'); markBlockEnd('zip'); + if (process.env.BROWSERSTACK_TEST_ACCESSIBILITY === 'true') { + supportFileCleanup(); + } + + if (turboScaleSession) { + atsFileCleanup(bsConfig); + } + // Set config args for enforce_settings if ( !utils.isUndefinedOrFalse(bsConfig.run_settings.enforce_settings) ) { markBlockStart('setEnforceSettingsConfig'); @@ -246,6 +319,9 @@ module.exports = function run(args, rawArgs) { markBlockEnd('createBuild'); markBlockEnd('total'); utils.setProcessHooks(data.build_id, bsConfig, bs_local, args, buildReportData); + if(isTestObservabilitySession) { + utils.setO11yProcessHooks(data.build_id, bsConfig, bs_local, args, buildReportData); + } let message = `${data.message}! ${Constants.userMessages.BUILD_CREATED} with build id: ${data.build_id}`; let dashboardLink = `${Constants.userMessages.VISIT_DASHBOARD} ${data.dashboard_url}`; if (turboScaleSession) { @@ -287,10 +363,10 @@ module.exports = function run(args, rawArgs) { await new Promise(resolve => setTimeout(resolve, 5000)); // download build artifacts - if (exitCode != Constants.BUILD_FAILED_EXIT_CODE && !turboScaleSession) { + if (exitCode != Constants.BUILD_FAILED_EXIT_CODE) { if (utils.nonEmptyArray(bsConfig.run_settings.downloads)) { logger.debug("Downloading build artifacts"); - await downloadBuildArtifacts(bsConfig, data.build_id, args, rawArgs, buildReportData); + await downloadBuildArtifacts(bsConfig, data.build_id, args, rawArgs, buildReportData, turboScaleSession); } // Generate custom report! @@ -325,6 +401,7 @@ module.exports = function run(args, rawArgs) { logger.info(dashboardLink); if(!args.sync) { logger.info(Constants.userMessages.EXIT_SYNC_CLI_MESSAGE.replace("",data.build_id)); + printBuildLink(false); } let dataToSend = { time_components: getTimeComponents(), diff --git a/bin/helpers/atsHelper.js b/bin/helpers/atsHelper.js new file mode 100644 index 00000000..90fed779 --- /dev/null +++ b/bin/helpers/atsHelper.js @@ -0,0 +1,128 @@ +const path = require('path'); +const fs = require('fs'); +const { consoleHolder } = require('../testObservability/helper/constants'); +const HttpsProxyAgent = require('https-proxy-agent'); + +const axios = require('axios'), + logger = require('./logger').winstonLogger, + utils = require('./utils'), + config = require('./config'); + Constants = require('./constants'); + +exports.isTurboScaleSession = (bsConfig) => { + // env var will override config + if (process.env.BROWSERSTACK_TURBOSCALE && process.env.BROWSERSTACK_TURBOSCALE === 'true') { + return true; + } + + if (utils.isNotUndefined(bsConfig) && bsConfig.run_settings && bsConfig.run_settings.turboScale) { + return true; + } + + return false; +}; + +exports.getTurboScaleOptions = (bsConfig) => { + if (bsConfig.run_settings && bsConfig.run_settings.turboScaleOptions) { + return bsConfig.run_settings.turboScaleOptions; + } + + return {}; +}; + +exports.getTurboScaleGridName = (bsConfig) => { + // env var will override config + if (process.env.BROWSERSTACK_TURBOSCALE_GRID_NAME) { + return process.env.BROWSERSTACK_TURBOSCALE_GRID_NAME; + } + + if (bsConfig.run_settings && bsConfig.run_settings.turboScaleOptions && bsConfig.run_settings.turboScaleOptions.gridName) { + return bsConfig.run_settings.turboScaleOptions.gridName; + } + + return 'NO_GRID_NAME_PASSED'; +}; + +exports.getTurboScaleGridDetails = async (bsConfig, args, rawArgs) => { + try { + const gridName = this.getTurboScaleGridName(bsConfig); + + return new Promise((resolve, reject) => { + let options = { + url: `${config.turboScaleAPIUrl}/grids/${gridName}`, + auth: { + username: bsConfig.auth.username, + password: bsConfig.auth.access_key, + }, + headers: { + 'User-Agent': utils.getUserAgent(), + } + }; + + if (process.env.HTTP_PROXY) { + options.proxy = false; + options.httpsAgent = new HttpsProxyAgent(process.env.HTTP_PROXY); + } else if (process.env.HTTPS_PROXY) { + options.proxy = false; + options.httpsAgent = new HttpsProxyAgent(process.env.HTTPS_PROXY); + } + + let responseData = {}; + + axios(options).then(response => { + try { + responseData = response.data; + } catch (e) { + responseData = {}; + } + if(response.status != 200) { + logger.warn(`Warn: Get Automate TurboScale Details Request failed with status code ${response.status}`); + utils.sendUsageReport(bsConfig, args, responseData["error"], Constants.messageTypes.ERROR, 'get_ats_details_failed', null, rawArgs); + resolve({}); + } + resolve(responseData); + }).catch(error => { + logger.warn(utils.formatRequest(error, null, null)); + utils.sendUsageReport(bsConfig, args, error, Constants.messageTypes.ERROR, 'get_ats_details_failed', null, rawArgs); + resolve({}); + }); + }); + } catch (err) { + logger.error(`Failed to find TurboScale Grid: ${err}: ${err.stack}`); + } +}; + +exports.patchCypressConfigFileContent = (bsConfig) => { + try { + let cypressConfigFileData = fs.readFileSync(path.resolve(bsConfig.run_settings.cypress_config_file)).toString(); + const patchedConfigFileData = cypressConfigFileData + '\n\n' + ` + let originalFunction = module.exports.e2e.setupNodeEvents; + module.exports.e2e.setupNodeEvents = (on, config) => { + const bstackOn = require("./cypressPatch.js")(on); + if (originalFunction !== null && originalFunction !== undefined) { + originalFunction(bstackOn, config); + } + return config; + } + ` + + let confPath = bsConfig.run_settings.cypress_config_file; + let patchedConfPathList = confPath.split(path.sep); + patchedConfPathList[patchedConfPathList.length - 1] = 'patched_ats_config_file.js' + const patchedConfPath = patchedConfPathList.join(path.sep); + + bsConfig.run_settings.patched_cypress_config_file = patchedConfPath; + + fs.writeFileSync(path.resolve(bsConfig.run_settings.patched_cypress_config_file), patchedConfigFileData); + } catch(e) { + logger.error(`Encountered an error when trying to patch ATS Cypress Config File ${e}`); + return {}; + } +} + +exports.atsFileCleanup = (bsConfig) => { + const filePath = path.resolve(bsConfig.run_settings.patched_cypress_config_file); + if(fs.existsSync(filePath)){ + fs.unlinkSync(filePath); + } +} diff --git a/bin/helpers/build.js b/bin/helpers/build.js index eca5ef89..c05634df 100644 --- a/bin/helpers/build.js +++ b/bin/helpers/build.js @@ -24,6 +24,9 @@ const createBuild = (bsConfig, zip) => { }, body: data } + if (Constants.turboScaleObj.enabled) { + options.url = Constants.turboScaleObj.buildUrl; + } const axiosConfig = { auth: { diff --git a/bin/helpers/buildArtifacts.js b/bin/helpers/buildArtifacts.js index 5f74c29a..30986a60 100644 --- a/bin/helpers/buildArtifacts.js +++ b/bin/helpers/buildArtifacts.js @@ -233,13 +233,15 @@ const sendUpdatesToBstack = async (bsConfig, buildId, args, options, rawArgs, bu }); } -exports.downloadBuildArtifacts = async (bsConfig, buildId, args, rawArgs, buildReportData = null) => { +exports.downloadBuildArtifacts = async (bsConfig, buildId, args, rawArgs, buildReportData = null, isTurboScaleSession = false) => { return new Promise ( async (resolve, reject) => { BUILD_ARTIFACTS_FAIL_COUNT = 0; BUILD_ARTIFACTS_TOTAL_COUNT = 0; let options = { - url: `${config.buildUrl}${buildId}/build_artifacts`, + url: isTurboScaleSession + ? `${config.turboScaleBuildsUrl}/${buildId}/build_artifacts` + : `${config.buildUrl}${buildId}/build_artifacts`, auth: { username: bsConfig.auth.username, password: bsConfig.auth.access_key, diff --git a/bin/helpers/capabilityHelper.js b/bin/helpers/capabilityHelper.js index ba26045a..425f7e43 100644 --- a/bin/helpers/capabilityHelper.js +++ b/bin/helpers/capabilityHelper.js @@ -131,6 +131,10 @@ const caps = (bsConfig, zip) => { obj.run_settings = JSON.stringify(bsConfig.run_settings); } + obj.cypress_cli_user_agent = Utils.getUserAgent(); + + logger.info(`Cypress CLI User Agent: ${obj.cypress_cli_user_agent}`); + if(obj.parallels === Constants.cliMessages.RUN.DEFAULT_PARALLEL_MESSAGE) obj.parallels = undefined if (obj.project) logger.info(`Project name is: ${obj.project}`); @@ -262,7 +266,6 @@ const validate = (bsConfig, args) => { if (!Utils.isUndefined(bsConfig.run_settings.integrationFolder) && !Utils.isCypressProjDirValid(bsConfig.run_settings.cypressProjectDir,bsConfig.run_settings.integrationFolder)) reject(Constants.validationMessages.INCORRECT_DIRECTORY_STRUCTURE); } } catch(error){ - logger.debug(error) reject(Constants.validationMessages.INVALID_CYPRESS_JSON) } diff --git a/bin/helpers/helper.js b/bin/helpers/helper.js index 93dfbd40..ecb4e279 100644 --- a/bin/helpers/helper.js +++ b/bin/helpers/helper.js @@ -17,12 +17,16 @@ const glob = require('glob'); const pGitconfig = promisify(gitconfig); const { readCypressConfigFile } = require('./readCypressConfigUtil'); const { MAX_GIT_META_DATA_SIZE_IN_BYTES, GIT_META_DATA_TRUNCATED } = require('./constants') +const CrashReporter = require('../testObservability/crashReporter'); const HttpsProxyAgent = require('https-proxy-agent'); exports.debug = (text, shouldReport = false, throwable = null) => { if (process.env.BROWSERSTACK_OBSERVABILITY_DEBUG === "true" || process.env.BROWSERSTACK_OBSERVABILITY_DEBUG === "1") { logger.info(`[ OBSERVABILITY ] ${text}`); } + if(shouldReport) { + CrashReporter.getInstance().uploadCrashReport(text, throwable ? throwable && throwable.stack : null); + } } exports.getFileSeparatorData = () => { diff --git a/bin/helpers/reporterHTML.js b/bin/helpers/reporterHTML.js index 9345f25d..541a0a28 100644 --- a/bin/helpers/reporterHTML.js +++ b/bin/helpers/reporterHTML.js @@ -7,6 +7,7 @@ const fs = require('fs'), Constants = require('./constants'), config = require("./config"), decompress = require('decompress'); +const { isTurboScaleSession } = require('../helpers/atsHelper'); const { setAxiosProxy } = require('./helper'); @@ -22,6 +23,10 @@ let reportGenerator = async (bsConfig, buildId, args, rawArgs, buildReportData, }, }; + if (isTurboScaleSession(bsConfig)) { + options.url = `${config.turboScaleBuildsUrl}/${buildId}/custom_report`; + } + logger.debug('Started fetching the build json and html reports.'); let message = null; diff --git a/bin/helpers/sync/syncSpecsLogs.js b/bin/helpers/sync/syncSpecsLogs.js index 07e7ad31..0f6141e8 100644 --- a/bin/helpers/sync/syncSpecsLogs.js +++ b/bin/helpers/sync/syncSpecsLogs.js @@ -150,7 +150,7 @@ let whileProcess = async (whilstCallback) => { }; setAxiosProxy(axiosConfig); - const response = await axios.post(options.url, null, axiosConfig); + const response = await axios.post(options.url, {}, axiosConfig); whileTries = config.retries; // reset to default after every successful request switch (response.status) { case 202: // get data here and print it diff --git a/bin/helpers/usageReporting.js b/bin/helpers/usageReporting.js index 255ed6eb..95a827ec 100644 --- a/bin/helpers/usageReporting.js +++ b/bin/helpers/usageReporting.js @@ -11,6 +11,7 @@ const config = require('./config'), const { AUTH_REGEX, REDACTED_AUTH, REDACTED, CLI_ARGS_REGEX, RAW_ARGS_REGEX } = require("./constants"); const { default: axios } = require("axios"); const axiosRetry = require("axios-retry"); +const { isTurboScaleSession } = require("./atsHelper"); const { setAxiosProxy } = require('./helper'); @@ -200,9 +201,61 @@ function redactKeys(str, regex, redact) { return str.replace(regex, redact); } +function sendTurboscaleErrorLogs(args) { + let bsConfig = JSON.parse(JSON.stringify(args.bstack_config)); + let data = utils.isUndefined(args.data) ? {} : args.data; + const turboscaleErrorPayload = { + kind: 'hst-cypress-cli-error', + data: data, + error: args.message + } + + const options = { + headers: { + 'User-Agent': utils.getUserAgent() + }, + method: "POST", + auth: { + username: bsConfig.auth.username, + password: bsConfig.auth.access_key, + }, + url: `${config.turboScaleAPIUrl}/send-instrumentation`, + data: turboscaleErrorPayload, + maxAttempts: 10, + retryDelay: 2000, // (default) wait for 2s before trying again + }; + + axiosRetry(axios, { + retries: options.maxAttempts, + retryDelay: (retryCount) => options.retryDelay, + retryCondition: (error) => { + return axiosRetry.isRetryableError(error) // (default) retry on 5xx or network errors + } + }); + + fileLogger.info(`Sending ${JSON.stringify(turboscaleErrorPayload)} to ${config.turboScaleAPIUrl}/send-instrumentation`); + + axios(options) + .then((res) => { + let response = { + attempts: res.config['axios-retry'].retryCount + 1, + statusCode: res.status, + body: res.data + }; + fileLogger.info(`${JSON.stringify(response)}`); + }) + .catch((error) => { + fileLogger.error(JSON.stringify(error)); + }); +} + async function send(args) { let bsConfig = JSON.parse(JSON.stringify(args.bstack_config)); + if (isTurboScaleSession(bsConfig) && args.message_type === 'error') { + sendTurboscaleErrorLogs(args); + } + if (isUsageReportingEnabled() === "true") return; let runSettings = ""; diff --git a/bin/helpers/utils.js b/bin/helpers/utils.js index 44a2fcd4..12d6c99a 100644 --- a/bin/helpers/utils.js +++ b/bin/helpers/utils.js @@ -22,7 +22,9 @@ const usageReporting = require("./usageReporting"), fileHelpers = require("./fileHelpers"), config = require("../helpers/config"), pkg = require('../../package.json'), - transports = require('./logger').transports + transports = require('./logger').transports, + o11yHelpers = require('../testObservability/helper/helper'), + { OBSERVABILITY_ENV_VARS, TEST_OBSERVABILITY_REPORTER } = require('../testObservability/helper/constants'); const { default: axios } = require("axios"); @@ -490,6 +492,11 @@ exports.setNodeVersion = (bsConfig, args) => { // specs can be passed via command line args as a string // command line args takes precedence over config exports.setUserSpecs = (bsConfig, args) => { + + if(o11yHelpers.isBrowserstackInfra() && o11yHelpers.isTestObservabilitySession() && o11yHelpers.shouldReRunObservabilityTests()) { + bsConfig.run_settings.specs = process.env.BROWSERSTACK_RERUN_TESTS; + return; + } let bsConfigSpecs = bsConfig.run_settings.specs; @@ -582,6 +589,19 @@ exports.setSystemEnvs = (bsConfig) => { logger.error(`Error in adding accessibility configs ${error}`) } + try { + OBSERVABILITY_ENV_VARS.forEach(key => { + envKeys[key] = process.env[key]; + }); + + let gitConfigPath = o11yHelpers.findGitConfig(process.cwd()); + if(!o11yHelpers.isBrowserstackInfra()) process.env.OBSERVABILITY_GIT_CONFIG_PATH_LOCAL = gitConfigPath; + if(gitConfigPath) { + const relativePathFromGitConfig = path.relative(gitConfigPath, process.cwd()); + envKeys["OBSERVABILITY_GIT_CONFIG_PATH"] = relativePathFromGitConfig ? relativePathFromGitConfig : 'DEFAULT'; + } + } catch(e){} + if (Object.keys(envKeys).length === 0) { bsConfig.run_settings.system_env_vars = null; } else { @@ -1207,7 +1227,11 @@ exports.handleSyncExit = (exitCode, dashboard_url) => { syncCliLogger.info(Constants.userMessages.BUILD_REPORT_MESSAGE); syncCliLogger.info(dashboard_url); } - process.exit(exitCode); + if(o11yHelpers.isTestObservabilitySession()) { + o11yHelpers.printBuildLink(true, exitCode); + } else { + process.exit(exitCode); + } } exports.getNetworkErrorMessage = (dashboard_url) => { @@ -1467,6 +1491,11 @@ exports.splitStringByCharButIgnoreIfWithinARange = (str, splitChar, leftLimiter, // blindly send other passed configs with run_settings and handle at backend exports.setOtherConfigs = (bsConfig, args) => { + if(o11yHelpers.isTestObservabilitySession() && process.env.BS_TESTOPS_JWT) { + bsConfig["run_settings"]["reporter"] = TEST_OBSERVABILITY_REPORTER; + return; + } + /* Non Observability use-case */ if (!this.isUndefined(args.reporter)) { bsConfig["run_settings"]["reporter"] = args.reporter; @@ -1634,14 +1663,37 @@ exports.setProcessHooks = (buildId, bsConfig, bsLocal, args, buildReportData) => process.on('uncaughtException', processExitHandler.bind(this, bindData)); } + +exports.setO11yProcessHooks = (() => { + let bindData = {}; + let handlerAdded = false; + return (buildId, bsConfig, bsLocal, args, buildReportData) => { + bindData.buildId = buildId; + bindData.bsConfig = bsConfig; + bindData.bsLocal = bsLocal; + bindData.args = args; + bindData.buildReportData = buildReportData; + if (handlerAdded) return; + handlerAdded = true; + process.on('beforeExit', processO11yExitHandler.bind(this, bindData)); + } +})() + async function processExitHandler(exitData){ logger.warn(Constants.userMessages.PROCESS_KILL_MESSAGE); await this.stopBrowserStackBuild(exitData.bsConfig, exitData.args, exitData.buildId, null, exitData.buildReportData); await this.stopLocalBinary(exitData.bsConfig, exitData.bsLocalInstance, exitData.args, null, exitData.buildReportData); - // await o11yHelpers.printBuildLink(true); + await o11yHelpers.printBuildLink(true); process.exit(0); } +async function processO11yExitHandler(exitData){ + if (exitData.buildId) { + await o11yHelpers.printBuildLink(false); + } else { + await o11yHelpers.printBuildLink(true); + } +} exports.fetchZipSize = (fileName) => { try { diff --git a/bin/testObservability/crashReporter/index.js b/bin/testObservability/crashReporter/index.js new file mode 100644 index 00000000..00ecb6bc --- /dev/null +++ b/bin/testObservability/crashReporter/index.js @@ -0,0 +1,183 @@ +const fs = require('fs'); +const path = require('path'); +const axios = require('axios'); +const https = require('https'); +const HttpsProxyAgent = require('https-proxy-agent'); + +const logger = require("../../helpers/logger").winstonLogger; +const utils = require('../../helpers/utils'); + +const { API_URL, consoleHolder } = require('../helper/constants'); + +/* Below global methods are added here to remove cyclic dependency with helper.js, refactor later */ +const httpsKeepAliveAgent = new https.Agent({ + keepAlive: true, + timeout: 60000, + maxSockets: 2, + maxTotalSockets: 2 +}); + +const debug = (text) => { + if (process.env.BROWSERSTACK_OBSERVABILITY_DEBUG === "true" || process.env.BROWSERSTACK_OBSERVABILITY_DEBUG === "1") { + logger.info(`[ OBSERVABILITY ] ${text}`); + } +} + +let packages = {}; + +exports.requireModule = (module, internal = false) => { + let local_path = ""; + if(process.env["browserStackCwd"]){ + local_path = path.join(process.env["browserStackCwd"], 'node_modules', module); + } else if(internal) { + local_path = path.join(process.cwd(), 'node_modules', 'browserstack-cypress-cli', 'node_modules', module); + } else { + local_path = path.join(process.cwd(), 'node_modules', module); + } + if(!fs.existsSync(local_path)) { + let global_path; + if(['jest-runner', 'jest-runtime'].includes(module)) + global_path = path.join(GLOBAL_MODULE_PATH, 'jest', 'node_modules', module); + else + global_path = path.join(GLOBAL_MODULE_PATH, module); + if(!fs.existsSync(global_path)) { + throw new Error(`${module} doesn't exist.`); + } + return require(global_path); + } + return require(local_path); +} + +getPackageVersion = (package_, bsConfig = null) => { + if(packages[package_]) return packages[package_]; + let packageVersion; + /* Try to find version from module path */ + try { + packages[package_] = this.requireModule(`${package_}/package.json`).version; + logger.info(`Getting ${package_} package version from module path = ${packages[package_]}`); + packageVersion = packages[package_]; + } catch(e) { + debug(`Unable to find package ${package_} at module path with error ${e}`); + } + + /* Read package version from npm_dependencies in browserstack.json file if present */ + if(utils.isUndefined(packageVersion) && bsConfig && (process.env.BROWSERSTACK_AUTOMATION == "true" || process.env.BROWSERSTACK_AUTOMATION == "1")) { + const runSettings = bsConfig.run_settings; + if (runSettings && runSettings.npm_dependencies !== undefined && + Object.keys(runSettings.npm_dependencies).length !== 0 && + typeof runSettings.npm_dependencies === 'object') { + if (package_ in runSettings.npm_dependencies) { + packages[package_] = runSettings.npm_dependencies[package_]; + logger.info(`Getting ${package_} package version from browserstack.json = ${packages[package_]}`); + packageVersion = packages[package_]; + } + } + } + + /* Read package version from project's package.json if present */ + const packageJSONPath = path.join(process.cwd(), 'package.json'); + if(utils.isUndefined(packageVersion) && fs.existsSync(packageJSONPath)) { + const packageJSONContents = require(packageJSONPath); + if(packageJSONContents.devDependencies && !utils.isUndefined(packageJSONContents.devDependencies[package_])) packages[package_] = packageJSONContents.devDependencies[package_]; + if(packageJSONContents.dependencies && !utils.isUndefined(packageJSONContents.dependencies[package_])) packages[package_] = packageJSONContents.dependencies[package_]; + logger.info(`Getting ${package_} package version from package.json = ${packages[package_]}`); + packageVersion = packages[package_]; + } + + return packageVersion; +} + +getAgentVersion = () => { + let _path = path.join(__dirname, '../../../package.json'); + if(fs.existsSync(_path)) + return require(_path).version; +} + +class CrashReporter { + static instance; + + constructor() { + } + + static getInstance() { + if (!CrashReporter.instance) { + CrashReporter.instance = new CrashReporter(); + } + return CrashReporter.instance; + } + + setCredentialsForCrashReportUpload(credentialsStr) { + /* User credentials used for reporting crashes */ + this.credentialsForCrashReportUpload = JSON.parse(credentialsStr); + } + + setConfigDetails(credentialsStr, browserstackConfigFile, cypressConfigFile) { + /* User test config for build run */ + this.userConfigForReporting = { + framework: 'Cypress', + browserstackConfigFile: browserstackConfigFile, + cypressConfigFile: cypressConfigFile + }; + this.setCredentialsForCrashReportUpload(credentialsStr); + } + + uploadCrashReport(exception, stacktrace) { + try { + if (!this.credentialsForCrashReportUpload.username || !this.credentialsForCrashReportUpload.password) { + return debug('[Crash_Report_Upload] Failed to parse user credentials while reporting crash') + } + + const data = { + hashed_id: process.env.BS_TESTOPS_BUILD_HASHED_ID, + observability_version: { + frameworkName: 'Cypress', + frameworkVersion: getPackageVersion('cypress', this.userConfigForReporting.browserstackConfigFile), + sdkVersion: getAgentVersion() + }, + exception: { + error: exception.toString(), + stackTrace: stacktrace + }, + config: this.userConfigForReporting + } + + const options = { + auth: { + ...this.credentialsForCrashReportUpload + }, + headers: { + 'Content-Type': 'application/json', + 'X-BSTACK-TESTOPS': 'true' + }, + method: 'POST', + url: `${API_URL}/api/v1/analytics`, + data: data, + json: true, + agent: httpsKeepAliveAgent + }; + + if(process.env.HTTP_PROXY){ + options.proxy = false + options.httpsAgent = new HttpsProxyAgent(process.env.HTTP_PROXY); + } else if (process.env.HTTPS_PROXY){ + options.proxy = false + options.httpsAgent = new HttpsProxyAgent(process.env.HTTPS_PROXY); + } + + axios(options) + .then(response => { + + if(response.status != 200) { + debug(`[Crash_Report_Upload] Failed due to ${response && response.data ? response.data : `Received response from BrowserStack Server with status : ${response.status}`}`); + } else { + debug(`[Crash_Report_Upload] Success response: ${JSON.stringify({status: response.status, body: response.data})}`) + } + }) + .catch(error => debug(`[Crash_Report_Upload] Failed due to ${error}`)); + } catch(e) { + debug(`[Crash_Report_Upload] Processing failed due to ${e && e.stack}`); + } + } +} + +module.exports = CrashReporter; diff --git a/bin/testObservability/cypress/index.js b/bin/testObservability/cypress/index.js new file mode 100644 index 00000000..78482442 --- /dev/null +++ b/bin/testObservability/cypress/index.js @@ -0,0 +1,158 @@ +/* Event listeners + custom commands for Cypress */ + +/* Used to detect Gherkin steps */ +Cypress.on('log:added', (log) => { + return () => { + return cy.now('task', 'test_observability_step', { + log + }, {log: false}) + } + }); + +Cypress.on('command:start', (command) => { + if(!command || !command.attributes) return; + if(command.attributes.name == 'log' || (command.attributes.name == 'task' && (command.attributes.args.includes('test_observability_command') || command.attributes.args.includes('test_observability_log')))) { + return; + } + /* Send command details */ + cy.now('task', 'test_observability_command', { + type: 'COMMAND_START', + command: { + attributes: { + id: command.attributes.id, + name: command.attributes.name, + args: command.attributes.args + }, + state: 'pending' + } + }, {log: false}).then((res) => { + }).catch((err) => { + }); + + /* Send platform details */ + cy.now('task', 'test_observability_platform_details', { + testTitle: Cypress.currentTest.title, + browser: Cypress.browser, + platform: Cypress.platform, + cypressVersion: Cypress.version + }, {log: false}).then((res) => { + }).catch((err) => { + }); +}); + +Cypress.on('command:retry', (command) => { + if(!command || !command.attributes) return; + if(command.attributes.name == 'log' || (command.attributes.name == 'task' && (command.attributes.args.includes('test_observability_command') || command.attributes.args.includes('test_observability_log')))) { + return; + } + cy.now('task', 'test_observability_command', { + type: 'COMMAND_RETRY', + command: { + _log: command._log, + error: { + message: command && command.error ? command.error.message : null, + isDefaultAssertionErr: command && command.error ? command.error.isDefaultAssertionErr : null + } + } + }, {log: false}).then((res) => { + }).catch((err) => { + }); +}); + +Cypress.on('command:end', (command) => { + if(!command || !command.attributes) return; + if(command.attributes.name == 'log' || (command.attributes.name == 'task' && (command.attributes.args.includes('test_observability_command') || command.attributes.args.includes('test_observability_log')))) { + return; + } + cy.now('task', 'test_observability_command', { + 'type': 'COMMAND_END', + 'command': { + 'attributes': { + 'id': command.attributes.id, + 'name': command.attributes.name, + 'args': command.attributes.args + }, + 'state': command.state + } + }, {log: false}).then((res) => { + }).catch((err) => { + }); +}); + +Cypress.Commands.overwrite('log', (originalFn, ...args) => { + if(args.includes('test_observability_log') || args.includes('test_observability_command')) return; + const message = args.reduce((result, logItem) => { + if (typeof logItem === 'object') { + return [result, JSON.stringify(logItem)].join(' '); + } + + return [result, logItem ? logItem.toString() : ''].join(' '); + }, ''); + cy.now('task', 'test_observability_log', { + 'level': 'info', + message, + }, {log: false}).then((res) => { + }).catch((err) => { + }); + originalFn(...args); +}); + +Cypress.Commands.add('trace', (message, file) => { + cy.now('task', 'test_observability_log', { + level: 'trace', + message, + file, + }).then((res) => { + }).catch((err) => { + }); +}); + +Cypress.Commands.add('logDebug', (message, file) => { + cy.now('task', 'test_observability_log', { + level: 'debug', + message, + file, + }).then((res) => { + }).catch((err) => { + }); +}); + +Cypress.Commands.add('info', (message, file) => { + cy.now('task', 'test_observability_log', { + level: 'info', + message, + file, + }).then((res) => { + }).catch((err) => { + }); +}); + +Cypress.Commands.add('warn', (message, file) => { + cy.now('task', 'test_observability_log', { + level: 'warn', + message, + file, + }).then((res) => { + }).catch((err) => { + }); +}); + +Cypress.Commands.add('error', (message, file) => { + cy.now('task', 'test_observability_log', { + level: 'error', + message, + file, + }).then((res) => { + }).catch((err) => { + }); +}); + +Cypress.Commands.add('fatal', (message, file) => { + cy.now('task', 'test_observability_log', { + level: 'fatal', + message, + file, + }).then((res) => { + }).catch((err) => { + }); +}); diff --git a/bin/testObservability/helper/constants.js b/bin/testObservability/helper/constants.js new file mode 100644 index 00000000..dbf5e053 --- /dev/null +++ b/bin/testObservability/helper/constants.js @@ -0,0 +1,36 @@ +const path = require('path'); + +exports.consoleHolder = Object.assign({},console); +exports.BATCH_SIZE = 1000; +exports.BATCH_INTERVAL = 2000; +exports.API_URL = 'https://collector-observability.browserstack.com'; + +exports.IPC_EVENTS = { + LOG: 'testObservability:cypressLog', + CONFIG: 'testObservability:cypressConfig', + SCREENSHOT: 'testObservability:cypressScreenshot', + COMMAND: 'testObservability:cypressCommand', + CUCUMBER: 'testObservability:cypressCucumberStep', + PLATFORM_DETAILS: 'testObservability:cypressPlatformDetails' +}; + +exports.OBSERVABILITY_ENV_VARS = [ + "BROWSERSTACK_TEST_OBSERVABILITY", + "BROWSERSTACK_AUTOMATION", + "BS_TESTOPS_BUILD_COMPLETED", + "BS_TESTOPS_JWT", + "BS_TESTOPS_BUILD_HASHED_ID", + "BS_TESTOPS_ALLOW_SCREENSHOTS", + "OBSERVABILITY_LAUNCH_SDK_VERSION", + "BROWSERSTACK_OBSERVABILITY_DEBUG", + "OBS_CRASH_REPORTING_USERNAME", + "OBS_CRASH_REPORTING_ACCESS_KEY", + "OBS_CRASH_REPORTING_BS_CONFIG_PATH", + "OBS_CRASH_REPORTING_CYPRESS_CONFIG_PATH" +]; + +exports.TEST_OBSERVABILITY_REPORTER = 'browserstack-cypress-cli/bin/testObservability/reporter'; + +exports.TEST_OBSERVABILITY_REPORTER_LOCAL = path.join(__dirname, '..', 'reporter'); + +exports.PENDING_QUEUES_FILE = `pending_queues_${process.pid}.json`; diff --git a/bin/testObservability/helper/helper.js b/bin/testObservability/helper/helper.js new file mode 100644 index 00000000..467f090c --- /dev/null +++ b/bin/testObservability/helper/helper.js @@ -0,0 +1,961 @@ +const fs = require('fs'); +const path = require('path'); +const https = require('https'); +const { v4: uuidv4 } = require('uuid'); +const os = require('os'); +const { promisify } = require('util'); +const gitconfig = require('gitconfiglocal'); +const { spawn, execSync } = require('child_process'); +const glob = require('glob'); +const util = require('util'); +const axios = require('axios'); +const HttpsProxyAgent = require('https-proxy-agent'); + +const { runOptions } = require('../../helpers/runnerArgs') + +const pGitconfig = promisify(gitconfig); + +const logger = require("../../helpers/logger").winstonLogger; +const utils = require('../../helpers/utils'); +const helper = require('../../helpers/helper'); + +const CrashReporter = require('../crashReporter'); + +// Getting global packages path +const GLOBAL_MODULE_PATH = execSync('npm root -g').toString().trim(); + +const { name, version } = require('../../../package.json'); + +const { CYPRESS_V10_AND_ABOVE_CONFIG_FILE_EXTENSIONS } = require('../../helpers/constants'); +const { consoleHolder, API_URL, TEST_OBSERVABILITY_REPORTER, TEST_OBSERVABILITY_REPORTER_LOCAL } = require('./constants'); + +const ALLOWED_MODULES = [ + 'cypress/package.json', + 'mocha/lib/reporters/base.js', + 'mocha/lib/utils.js', + 'mocha' +]; + +exports.pending_test_uploads = { + count: 0 +}; + +exports.debugOnConsole = (text) => { + if ((process.env.BROWSERSTACK_OBSERVABILITY_DEBUG + '') === "true" || + (process.env.BROWSERSTACK_OBSERVABILITY_DEBUG + '') === "1") { + consoleHolder.log(`[ OBSERVABILITY ] ${text}`); + } +} + +exports.debug = (text, shouldReport = false, throwable = null) => { + if (process.env.BROWSERSTACK_OBSERVABILITY_DEBUG === "true" || process.env.BROWSERSTACK_OBSERVABILITY_DEBUG === "1") { + logger.info(`[ OBSERVABILITY ] ${text}`); + } + if(shouldReport) { + CrashReporter.getInstance().uploadCrashReport(text, throwable ? throwable && throwable.stack : null); + } +} + +const supportFileContentMap = {}; + +exports.httpsKeepAliveAgent = new https.Agent({ + keepAlive: true, + timeout: 60000, + maxSockets: 2, + maxTotalSockets: 2 +}); + +const httpsScreenshotsKeepAliveAgent = new https.Agent({ + keepAlive: true, + timeout: 60000, + maxSockets: 2, + maxTotalSockets: 2 +}); + +const supportFileCleanup = () => { + Object.keys(supportFileContentMap).forEach(file => { + try { + if(typeof supportFileContentMap[file] === 'object') { + let fileOrDirpath = file; + if(supportFileContentMap[file].deleteSupportDir) { + fileOrDirpath = path.join(process.cwd(), 'cypress', 'support'); + } + helper.deleteSupportFileOrDir(fileOrDirpath); + } else { + fs.writeFileSync(file, supportFileContentMap[file], {encoding: 'utf-8'}); + } + } catch(e) { + exports.debug(`Error while replacing file content for ${file} with it's original content with error : ${e}`, true, e); + } + }); +} + +exports.buildStopped = false; + +exports.printBuildLink = async (shouldStopSession, exitCode = null) => { + if(!this.isTestObservabilitySession() || exports.buildStopped) return; + exports.buildStopped = true; + try { + if(shouldStopSession) { + supportFileCleanup(); + await this.stopBuildUpstream(); + } + try { + if(process.env.BS_TESTOPS_BUILD_HASHED_ID + && process.env.BS_TESTOPS_BUILD_HASHED_ID != "null" + && process.env.BS_TESTOPS_BUILD_HASHED_ID != "undefined") { + console.log(); + logger.info(`Visit https://observability.browserstack.com/builds/${process.env.BS_TESTOPS_BUILD_HASHED_ID} to view build report, insights, and many more debugging information all at one place!\n`); + } + } catch(err) { + exports.debug('Build Not Found'); + } + } catch(err) { + exports.debug(`Error while stopping build with error : ${err}`, true, err); + } + if(exitCode) process.exit(exitCode); +} + +const nodeRequest = (type, url, data, config) => { + return new Promise(async (resolve, reject) => { + const options = { + ...config, + method: type, + url: `${API_URL}/${url}`, + data: data, + httpsAgent: this.httpsKeepAliveAgent, + maxAttempts: 2, + headers: { + ...config.headers, + 'Content-Type': 'application/json;charset=utf-8', + "X-Forwarded-For": "127.0.0.1" + }, + clientIp: "127.0.0.1" + }; + + if(process.env.HTTP_PROXY){ + options.proxy = false + options.httpsAgent = new HttpsProxyAgent(process.env.HTTP_PROXY); + } else if (process.env.HTTPS_PROXY){ + options.proxy = false + options.httpsAgent = new HttpsProxyAgent(process.env.HTTPS_PROXY); + } + + if(url === exports.requestQueueHandler.screenshotEventUrl) { + options.agent = httpsScreenshotsKeepAliveAgent; + } + axios(options) + .then(response => { + + if(response.status != 200) { + reject(response && response.data ? response.data : `Received response from BrowserStack Server with status : ${response.status}`); + } else { + try { + const responseBody = typeof response.data === 'object' ? response.data : JSON.parse(response.data); + resolve({ data: responseBody }); + } catch (error) { + if (!url.includes('/stop')) { + reject('Not a JSON response from BrowserStack Server'); + } else { + resolve({ data: response.data }); + } + } + } + }) + .catch(error => { + reject(error) + }); + }); +} + +exports.failureData = (errors,tag) => { + if(!errors) return []; + try { + if(tag === 'test') { + return errors.map((failure) => { + let {stack, ...expanded} = failure + let expandedArray = Object.keys(expanded).map((key) => { + return `${key}: ${expanded[key]}` + }) + return { backtrace: stack.split(/\r?\n/), expanded: expandedArray } + }) + } else if(tag === 'err') { + let failureArr = [], failureChildArr = []; + Object.keys(errors).forEach((key) => { + try { + failureChildArr.push(`${key}: ${errors[key]}`); + } catch(e) { + exports.debug(`Exception in populating test failure data with error : ${e.message} : ${e.backtrace}`, true, e); + } + }) + failureArr.push({ backtrace: errors.stack.split(/\r?\n/), expanded: failureChildArr }); + return failureArr; + } else { + return []; + } + } catch(e) { + exports.debug(`Exception in populating test failure data with error : ${e.message} : ${e.backtrace}`, true, e); + } + return []; +} + +exports.getTestEnv = () => { + return { + "ci": "generic", + "key": uuidv4(), + "version": version, + "collector": `js-${name}`, + } +} + +exports.getFileSeparatorData = () => { + return /^win/.test(process.platform) ? "\\" : "/"; +} + +exports.findGitConfig = (filePath) => { + const fileSeparator = exports.getFileSeparatorData(); + if(filePath == null || filePath == '' || filePath == fileSeparator) { + return null; + } + try { + fs.statSync(filePath + fileSeparator + '.git' + fileSeparator + 'config'); + return filePath; + } catch(e) { + let parentFilePath = filePath.split(fileSeparator); + parentFilePath.pop(); + return exports.findGitConfig(parentFilePath.join(fileSeparator)); + } +} + +let packages = {}; + +exports.getPackageVersion = (package_, bsConfig = null) => { + if(packages[package_]) return packages[package_]; + let packageVersion; + /* Try to find version from module path */ + try { + packages[package_] = this.requireModule(`${package_}/package.json`).version; + logger.info(`Getting ${package_} package version from module path = ${packages[package_]}`); + packageVersion = packages[package_]; + } catch(e) { + exports.debug(`Unable to find package ${package_} at module path with error ${e}`); + } + + /* Read package version from npm_dependencies in browserstack.json file if present */ + if(utils.isUndefined(packageVersion) && bsConfig && (process.env.BROWSERSTACK_AUTOMATION == "true" || process.env.BROWSERSTACK_AUTOMATION == "1")) { + const runSettings = bsConfig.run_settings; + if (runSettings && runSettings.npm_dependencies !== undefined && + Object.keys(runSettings.npm_dependencies).length !== 0 && + typeof runSettings.npm_dependencies === 'object') { + if (package_ in runSettings.npm_dependencies) { + packages[package_] = runSettings.npm_dependencies[package_]; + logger.info(`Getting ${package_} package version from browserstack.json = ${packages[package_]}`); + packageVersion = packages[package_]; + } + } + } + + /* Read package version from project's package.json if present */ + const packageJSONPath = path.join(process.cwd(), 'package.json'); + if(utils.isUndefined(packageVersion) && fs.existsSync(packageJSONPath)) { + const packageJSONContents = require(packageJSONPath); + if(packageJSONContents.devDependencies && !utils.isUndefined(packageJSONContents.devDependencies[package_])) packages[package_] = packageJSONContents.devDependencies[package_]; + if(packageJSONContents.dependencies && !utils.isUndefined(packageJSONContents.dependencies[package_])) packages[package_] = packageJSONContents.dependencies[package_]; + logger.info(`Getting ${package_} package version from package.json = ${packages[package_]}`); + packageVersion = packages[package_]; + } + + return packageVersion; +} + +const setEnvironmentVariablesForRemoteReporter = (BS_TESTOPS_JWT, BS_TESTOPS_BUILD_HASHED_ID, BS_TESTOPS_ALLOW_SCREENSHOTS, OBSERVABILITY_LAUNCH_SDK_VERSION) => { + process.env.BS_TESTOPS_JWT = BS_TESTOPS_JWT; + process.env.BS_TESTOPS_BUILD_HASHED_ID = BS_TESTOPS_BUILD_HASHED_ID; + process.env.BS_TESTOPS_ALLOW_SCREENSHOTS = BS_TESTOPS_ALLOW_SCREENSHOTS; + process.env.OBSERVABILITY_LAUNCH_SDK_VERSION = OBSERVABILITY_LAUNCH_SDK_VERSION; +} + +const getCypressCommandEventListener = (isJS) => { + return isJS ? ( + `require('browserstack-cypress-cli/bin/testObservability/cypress');` + ) : ( + `import 'browserstack-cypress-cli/bin/testObservability/cypress'` + ) +} + +exports.setEventListeners = (bsConfig) => { + try { + const supportFilesData = helper.getSupportFiles(bsConfig, false); + if(!supportFilesData.supportFile) return; + glob(process.cwd() + supportFilesData.supportFile, {}, (err, files) => { + if(err) return exports.debug('EXCEPTION IN BUILD START EVENT : Unable to parse cypress support files'); + files.forEach(file => { + try { + if(!file.includes('commands.js')) { + const defaultFileContent = fs.readFileSync(file, {encoding: 'utf-8'}); + + let cypressCommandEventListener = getCypressCommandEventListener(file.includes('js')); + if(!defaultFileContent.includes(cypressCommandEventListener)) { + let newFileContent = defaultFileContent + + '\n' + + cypressCommandEventListener + + '\n' + fs.writeFileSync(file, newFileContent, {encoding: 'utf-8'}); + supportFileContentMap[file] = supportFilesData.cleanupParams ? supportFilesData.cleanupParams : defaultFileContent; + } + } + } catch(e) { + exports.debug(`Unable to modify file contents for ${file} to set event listeners with error ${e}`, true, e); + } + }); + }); + } catch(e) { + exports.debug(`Unable to parse support files to set event listeners with error ${e}`, true, e); + } +} + +const getCypressConfigFileContent = (bsConfig, cypressConfigPath) => { + try { + const cypressConfigFile = require(path.resolve(bsConfig ? bsConfig.run_settings.cypress_config_file : cypressConfigPath)); + if(bsConfig) process.env.OBS_CRASH_REPORTING_CYPRESS_CONFIG_PATH = bsConfig.run_settings.cypress_config_file; + return cypressConfigFile; + } catch(e) { + exports.debug(`Encountered an error when trying to import Cypress Config File ${e}`); + return {}; + } +} + +exports.setCrashReportingConfigFromReporter = (credentialsStr, bsConfigPath, cypressConfigPath) => { + try { + const browserstackConfigFile = utils.readBsConfigJSON(bsConfigPath); + const cypressConfigFile = getCypressConfigFileContent(null, cypressConfigPath); + + if(!credentialsStr) { + credentialsStr = JSON.stringify({ + username: process.env.OBS_CRASH_REPORTING_USERNAME, + password: process.env.OBS_CRASH_REPORTING_ACCESS_KEY + }); + } + CrashReporter.getInstance().setConfigDetails(credentialsStr, browserstackConfigFile, cypressConfigFile); + } catch(e) { + exports.debug(`Encountered an error when trying to set Crash Reporting Config from reporter ${e}`); + } +} + +const setCrashReportingConfig = (bsConfig, bsConfigPath) => { + try { + const browserstackConfigFile = utils.readBsConfigJSON(bsConfigPath); + const cypressConfigFile = getCypressConfigFileContent(bsConfig, null); + const credentialsStr = JSON.stringify({ + username: bsConfig["auth"]["username"], + password: bsConfig["auth"]["access_key"] + }); + CrashReporter.getInstance().setConfigDetails(credentialsStr, browserstackConfigFile, cypressConfigFile); + process.env.OBS_CRASH_REPORTING_USERNAME = bsConfig["auth"]["username"]; + process.env.OBS_CRASH_REPORTING_ACCESS_KEY = bsConfig["auth"]["access_key"]; + process.env.OBS_CRASH_REPORTING_BS_CONFIG_PATH = bsConfigPath ? path.relative(process.cwd(), bsConfigPath) : null; + } catch(e) { + exports.debug(`Encountered an error when trying to set Crash Reporting Config ${e}`); + } +} + +exports.launchTestSession = async (user_config, bsConfigPath) => { + setCrashReportingConfig(user_config, bsConfigPath); + + const obsUserName = user_config["auth"]["username"]; + const obsAccessKey = user_config["auth"]["access_key"]; + + const BSTestOpsToken = `${obsUserName || ''}:${obsAccessKey || ''}`; + if(BSTestOpsToken === '') { + exports.debug('EXCEPTION IN BUILD START EVENT : Missing authentication token', true, null); + process.env.BS_TESTOPS_BUILD_COMPLETED = false; + return [null, null]; + } else { + try { + const { + buildName, + projectName, + buildDescription, + buildTags + } = helper.getBuildDetails(user_config, true); + const data = { + 'format': 'json', + 'project_name': projectName, + 'name': buildName, + 'description': buildDescription, + 'start_time': (new Date()).toISOString(), + 'tags': buildTags, + 'host_info': { + hostname: os.hostname(), + platform: os.platform(), + type: os.type(), + version: os.version(), + arch: os.arch() + }, + 'ci_info': helper.getCiInfo(), + 'build_run_identifier': process.env.BROWSERSTACK_BUILD_RUN_IDENTIFIER, + 'failed_tests_rerun': process.env.BROWSERSTACK_RERUN || false, + 'version_control': await helper.getGitMetaData(), + 'observability_version': { + frameworkName: "Cypress", + frameworkVersion: exports.getPackageVersion('cypress', user_config), + sdkVersion: helper.getAgentVersion() + } + }; + const config = { + auth: { + username: obsUserName, + password: obsAccessKey + }, + headers: { + 'Content-Type': 'application/json', + 'X-BSTACK-TESTOPS': 'true' + } + }; + + const response = await nodeRequest('POST','api/v1/builds',data,config); + exports.debug('Build creation successfull!'); + process.env.BS_TESTOPS_BUILD_COMPLETED = true; + setEnvironmentVariablesForRemoteReporter(response.data.jwt, response.data.build_hashed_id, response.data.allow_screenshots, data.observability_version.sdkVersion); + if(this.isBrowserstackInfra()) helper.setBrowserstackCypressCliDependency(user_config); + } catch(error) { + if(!error.errorType) { + if (error.response) { + exports.debug(`EXCEPTION IN BUILD START EVENT : ${error.response.status} ${error.response.statusText} ${JSON.stringify(error.response.data)}`, true, error); + } else { + exports.debug(`EXCEPTION IN BUILD START EVENT : ${error.message || error}`, true, error); + } + } else { + const { errorType, message } = error; + switch (errorType) { + case 'ERROR_INVALID_CREDENTIALS': + logger.error(message); + break; + case 'ERROR_ACCESS_DENIED': + logger.info(message); + break; + case 'ERROR_SDK_DEPRECATED': + logger.error(message); + break; + default: + logger.error(message); + } + } + + process.env.BS_TESTOPS_BUILD_COMPLETED = false; + setEnvironmentVariablesForRemoteReporter(null, null, null); + } + } +} + +exports.getHookDetails = (hookTitle) => { + if(!hookTitle || typeof(hookTitle) != 'string') return [null, null]; + if(hookTitle.indexOf('hook:') !== -1) { + const hook_details = hookTitle.split('hook:'); + return [hook_details[0].slice(0,-1).split('"')[1], hook_details[1].substring(1)]; + } else if(hookTitle.indexOf('hook') !== -1) { + const hook_details = hookTitle.split('hook'); + return [hook_details[0].slice(0,-1).split('"')[1], hookTitle]; + } else { + return [null, null]; + } +} + +exports.getHooksForTest = (test) => { + if(!test || !test.parent) return []; + const hooksArr = []; + ['_beforeAll','_afterAll','_beforeEach','_afterEach'].forEach(hookType => { + let hooks = test.parent[hookType] || [] + hooks.forEach(testHook => { + if(testHook.hookAnalyticsId) hooksArr.push(testHook.hookAnalyticsId); + }) + }); + return [...hooksArr,...exports.getHooksForTest(test.parent)]; +} + +exports.mapTestHooks = (test) => { + if(!test || !test.parent) return; + ['_beforeAll','_afterAll','_beforeEach','_afterEach'].forEach(hookType => { + let hooks = test.parent[hookType] || [] + hooks.forEach(testHook => { + if(!testHook.hookAnalyticsId) { + testHook.hookAnalyticsId = uuidv4(); + } else if(testHook.markedStatus && hookType == '_afterEach') { + testHook.hookAnalyticsId = uuidv4(); + delete testHook.markedStatus; + } + testHook['test_run_id'] = testHook['test_run_id'] || test.testAnalyticsId; + }) + }); + exports.mapTestHooks(test.parent); +} + +exports.batchAndPostEvents = async (eventUrl, kind, data) => { + const config = { + headers: { + 'Authorization': `Bearer ${process.env.BS_TESTOPS_JWT}`, + 'Content-Type': 'application/json', + 'X-BSTACK-TESTOPS': 'true' + } + }; + + try { + const eventsUuids = data.map(eventData => `${eventData.event_type}:${eventData.test_run ? eventData.test_run.uuid : (eventData.hook_run ? eventData.hook_run.uuid : null)}`).join(', '); + exports.debugOnConsole(`[Request Batch Send] for events:uuids ${eventsUuids}`); + const response = await nodeRequest('POST',eventUrl,data,config); + exports.debugOnConsole(`[Request Batch Response] for events:uuids ${eventsUuids}`); + if(response.data.error) { + throw({message: response.data.error}); + } else { + exports.debug(`${kind} event successfull!`) + exports.pending_test_uploads.count = Math.max(0,exports.pending_test_uploads.count - data.length); + } + } catch(error) { + exports.debugOnConsole(`[Request Error] Error in sending request ${util.format(error)}`); + if (error.response) { + exports.debug(`EXCEPTION IN ${kind} REQUEST TO TEST OBSERVABILITY : ${error.response.status} ${error.response.statusText} ${JSON.stringify(error.response.data)}`, true, error); + } else { + exports.debug(`EXCEPTION IN ${kind} REQUEST TO TEST OBSERVABILITY : ${error.message || error}`, true, error); + } + exports.pending_test_uploads.count = Math.max(0,exports.pending_test_uploads.count - data.length); + } +} + +const RequestQueueHandler = require('./requestQueueHandler'); +exports.requestQueueHandler = new RequestQueueHandler(); + +exports.uploadEventData = async (eventData, run=0) => { + exports.debugOnConsole(`[uploadEventData] ${eventData.event_type}`); + const log_tag = { + ['TestRunStarted']: 'Test_Start_Upload', + ['TestRunFinished']: 'Test_End_Upload', + ['TestRunSkipped']: 'Test_Skipped_Upload', + ['LogCreated']: 'Log_Upload', + ['HookRunStarted']: 'Hook_Start_Upload', + ['HookRunFinished']: 'Hook_End_Upload', + ['CBTSessionCreated']: 'CBT_Upload', + ['BuildUpdate']: 'Build_Update' + }[eventData.event_type]; + + if(run === 0 && process.env.BS_TESTOPS_JWT != "null") exports.pending_test_uploads.count += 1; + + if (process.env.BS_TESTOPS_BUILD_COMPLETED === "true") { + if(process.env.BS_TESTOPS_JWT == "null") { + exports.debug(`EXCEPTION IN ${log_tag} REQUEST TO TEST OBSERVABILITY : missing authentication token`); + exports.pending_test_uploads.count = Math.max(0,exports.pending_test_uploads.count-1); + return { + status: 'error', + message: 'Token/buildID is undefined, build creation might have failed' + }; + } else { + let data = eventData, event_api_url = 'api/v1/event'; + + exports.requestQueueHandler.start(); + const { shouldProceed, proceedWithData, proceedWithUrl } = exports.requestQueueHandler.add(eventData); + exports.debugOnConsole(`[Request Queue] ${eventData.event_type} with uuid ${eventData.test_run ? eventData.test_run.uuid : (eventData.hook_run ? eventData.hook_run.uuid : null)} is added`) + if(!shouldProceed) { + return; + } else if(proceedWithData) { + data = proceedWithData; + event_api_url = proceedWithUrl; + } + + const config = { + headers: { + 'Authorization': `Bearer ${process.env.BS_TESTOPS_JWT}`, + 'Content-Type': 'application/json;charset=utf-8', + 'X-BSTACK-TESTOPS': 'true' + } + }; + + try { + const eventsUuids = data.map(eventData => `${eventData.event_type}:${eventData.test_run ? eventData.test_run.uuid : (eventData.hook_run ? eventData.hook_run.uuid : null)}`).join(', '); + exports.debugOnConsole(`[Request Send] for events:uuids ${eventsUuids}`); + const response = await nodeRequest('POST',event_api_url,data,config); + exports.debugOnConsole(`[Request Repsonse] ${util.format(response.data)} for events:uuids ${eventsUuids}`) + if(response.data.error) { + throw({message: response.data.error}); + } else { + exports.debug(`${event_api_url !== exports.requestQueueHandler.eventUrl ? log_tag : 'Batch-Queue'}[${run}] event successfull!`) + exports.pending_test_uploads.count = Math.max(0,exports.pending_test_uploads.count - (event_api_url === 'api/v1/event' ? 1 : data.length)); + return { + status: 'success', + message: '' + }; + } + } catch(error) { + exports.debugOnConsole(`[Request Error] Error in sending request ${util.format(error)}`); + if (error.response) { + exports.debug(`EXCEPTION IN ${event_api_url !== exports.requestQueueHandler.eventUrl ? log_tag : 'Batch-Queue'} REQUEST TO TEST OBSERVABILITY : ${error.response.status} ${error.response.statusText} ${JSON.stringify(error.response.data)}`, true, error); + } else { + exports.debug(`EXCEPTION IN ${event_api_url !== exports.requestQueueHandler.eventUrl ? log_tag : 'Batch-Queue'} REQUEST TO TEST OBSERVABILITY : ${error.message || error}`, true, error); + } + exports.pending_test_uploads.count = Math.max(0,exports.pending_test_uploads.count - (event_api_url === 'api/v1/event' ? 1 : data.length)); + return { + status: 'error', + message: error.message || (error.response ? `${error.response.status}:${error.response.statusText}` : error) + }; + } + } + } else if (run >= 5) { + exports.debug(`EXCEPTION IN ${log_tag} REQUEST TO TEST OBSERVABILITY : Build Start is not completed and ${log_tag} retry runs exceeded`); + if(process.env.BS_TESTOPS_JWT != "null") exports.pending_test_uploads.count = Math.max(0,exports.pending_test_uploads.count-1); + return { + status: 'error', + message: 'Retry runs exceeded' + }; + } else if(process.env.BS_TESTOPS_BUILD_COMPLETED !== "false") { + setTimeout(function(){ exports.uploadEventData(eventData, run+1) }, 1000); + } +} + +exports.isTestObservabilitySupportedCypressVersion = (cypress_config_filename) => { + const extension = cypress_config_filename.split('.').pop(); + return CYPRESS_V10_AND_ABOVE_CONFIG_FILE_EXTENSIONS.includes(extension); +} + +exports.setTestObservabilityFlags = (bsConfig) => { + /* testObservability */ + let isTestObservabilitySession = false; + try { + /* set default again but under try catch in case of wrong config */ + isTestObservabilitySession = utils.nonEmptyArray(bsConfig.run_settings.downloads) ? false : true; + + if(!utils.isUndefined(bsConfig["testObservability"])) isTestObservabilitySession = ( bsConfig["testObservability"] == true || bsConfig["testObservability"] == 1 ); + if(!utils.isUndefined(process.env.BROWSERSTACK_TEST_OBSERVABILITY)) isTestObservabilitySession = ( process.env.BROWSERSTACK_TEST_OBSERVABILITY == "true" || process.env.BROWSERSTACK_TEST_OBSERVABILITY == "1" ); + if(process.argv.includes('--disable-test-observability')) isTestObservabilitySession = false; + isTestObservabilitySession = isTestObservabilitySession && this.isTestObservabilitySupportedCypressVersion(bsConfig.run_settings.cypress_config_file); + } catch(e) { + isTestObservabilitySession = false; + exports.debug(`EXCEPTION while parsing testObservability capability with error ${e}`, true, e); + } + + /* browserstackAutomation */ + let isBrowserstackInfra = true; + try { + if(!utils.isUndefined(bsConfig["browserstackAutomation"])) isBrowserstackInfra = ( bsConfig["browserstackAutomation"] == true || bsConfig["browserstackAutomation"] == 1 ); + if(!utils.isUndefined(process.env.BROWSERSTACK_AUTOMATION)) isBrowserstackInfra = ( process.env.BROWSERSTACK_AUTOMATION == "true" || process.env.BROWSERSTACK_AUTOMATION == "1" ); + if(process.argv.includes('--disable-browserstack-automation')) isBrowserstackInfra = false; + } catch(e) { + isBrowserstackInfra = true; + exports.debug(`EXCEPTION while parsing browserstackAutomation capability with error ${e}`, true, e); + } + + if(isTestObservabilitySession) logger.warn("testObservability is set to true. Other test reporters you are using will be automatically disabled. Learn more at browserstack.com/docs/test-observability/overview/what-is-test-observability"); + + process.env.BROWSERSTACK_TEST_OBSERVABILITY = isTestObservabilitySession; + process.env.BROWSERSTACK_AUTOMATION = isBrowserstackInfra; + + return [isTestObservabilitySession, isBrowserstackInfra]; +} + +exports.isTestObservabilitySession = () => { + return ( process.env.BROWSERSTACK_TEST_OBSERVABILITY == "true" ); +} + +exports.isBrowserstackInfra = () => { + return ( process.env.BROWSERSTACK_AUTOMATION == "true" ); +} + +exports.shouldReRunObservabilityTests = () => { + return (process.env.BROWSERSTACK_RERUN_TESTS && process.env.BROWSERSTACK_RERUN_TESTS !== "null") ? true : false +} + +exports.stopBuildUpstream = async () => { + if (process.env.BS_TESTOPS_BUILD_COMPLETED === "true") { + if(process.env.BS_TESTOPS_JWT == "null" || process.env.BS_TESTOPS_BUILD_HASHED_ID == "null") { + exports.debug('EXCEPTION IN stopBuildUpstream REQUEST TO TEST OBSERVABILITY : Missing authentication token'); + return { + status: 'error', + message: 'Token/buildID is undefined, build creation might have failed' + }; + } else { + const data = { + 'stop_time': (new Date()).toISOString() + }; + const config = { + headers: { + 'Authorization': `Bearer ${process.env.BS_TESTOPS_JWT}`, + 'Content-Type': 'application/json', + 'X-BSTACK-TESTOPS': 'true' + } + }; + + try { + const response = await nodeRequest('PUT',`api/v1/builds/${process.env.BS_TESTOPS_BUILD_HASHED_ID}/stop`,data,config); + if(response.data && response.data.error) { + throw({message: response.data.error}); + } else { + exports.debug(`stopBuildUpstream event successfull!`) + return { + status: 'success', + message: '' + }; + } + } catch(error) { + if (error.response) { + exports.debug(`EXCEPTION IN stopBuildUpstream REQUEST TO TEST OBSERVABILITY : ${error.response.status} ${error.response.statusText} ${JSON.stringify(error.response.data)}`, true, error); + } else { + exports.debug(`EXCEPTION IN stopBuildUpstream REQUEST TO TEST OBSERVABILITY : ${error.message || error}`, true, error); + } + return { + status: 'error', + message: error.message || error.response ? `${error.response.status}:${error.response.statusText}` : error + }; + } + } + } +} + +exports.getHookSkippedTests = (suite) => { + const subSuitesSkippedTests = suite.suites.reduce((acc, subSuite) => { + const subSuiteSkippedTests = exports.getHookSkippedTests(subSuite); + if (subSuiteSkippedTests) { + acc = acc.concat(subSuiteSkippedTests); + } + return acc; + }, []); + const tests = suite.tests.filter(test => { + const isSkippedTest = test.type != 'hook' && + !test.markedStatus && + test.state != 'passed' && + test.state != 'failed' && + !test.pending + return isSkippedTest; + }); + return tests.concat(subSuitesSkippedTests); +} + +const getPlatformName = () => { + if (process.platform === 'win32') return 'Windows' + if (process.platform === 'darwin') return 'OS X' + if (process.platform === "linux") return 'Linux' + return 'Unknown' +} + +const getMacOSVersion = () => { + return execSync("awk '/SOFTWARE LICENSE AGREEMENT FOR macOS/' '/System/Library/CoreServices/Setup Assistant.app/Contents/Resources/en.lproj/OSXSoftwareLicense.rtf' | awk -F 'macOS ' '{print $NF}' | awk '{print substr($0, 0, length($0)-1)}'").toString().trim() +} + +exports.getOSDetailsFromSystem = async (product) => { + let platformName = getPlatformName(); + let platformVersion = os.release().toString(); + + switch (platformName) { + case 'OS X': + platformVersion = getMacOSVersion(); + break; + case 'Windows': + try { + const windowsRelease = (await import('windows-release')).default; + platformVersion = windowsRelease(); + } catch (e) { + } + break + case 'Linux': + try { + const details = await getLinuxDetails(); + if (details.dist) platformName = details.dist; + if (details.release) platformVersion = details.release.toString(); + } catch (e) { + } + break; + default: + break; + } + + return { + os: product == 'automate' && platformName == 'Linux' ? 'OS X' : platformName, + os_version: platformVersion + }; +} + +let WORKSPACE_MODULE_PATH; + +exports.requireModule = (module) => { + const modulePath = exports.resolveModule(module); + if (modulePath.error) { + throw new Error(`${module} doesn't exist.`); + } + + return require(modulePath.path); +}; + +exports.resolveModule = (module) => { + if (!ALLOWED_MODULES.includes(module)) { + throw new Error('Invalid module name'); + } + + if (WORKSPACE_MODULE_PATH == undefined) { + try { + WORKSPACE_MODULE_PATH = execSync('npm ls').toString().trim(); + WORKSPACE_MODULE_PATH = WORKSPACE_MODULE_PATH.split('\n')[0].split(' ')[1]; + } catch (e) { + WORKSPACE_MODULE_PATH = null; + exports.debug(`Could not locate npm module path with error ${e}`); + } + } + + /* + Modules will be resolved in the following order, + current working dir > workspaces dir > NODE_PATH env var > global node modules path + */ + + try { + exports.debug('requireModuleV2'); + + return {path: require.resolve(module), foundAt: 'resolve'}; + } catch (_) { + /* Find from current working directory */ + exports.debug(`Getting ${module} from ${process.cwd()}`); + let local_path = path.join(process.cwd(), 'node_modules', module); + if (!fs.existsSync(local_path)) { + exports.debug(`${module} doesn't exist at ${process.cwd()}`); + + /* Find from workspaces */ + if (WORKSPACE_MODULE_PATH) { + exports.debug(`Getting ${module} from path ${WORKSPACE_MODULE_PATH}`); + let workspace_path = null; + workspace_path = path.join(WORKSPACE_MODULE_PATH, 'node_modules', module); + if (workspace_path && fs.existsSync(workspace_path)) { + exports.debug(`Found ${module} from ${WORKSPACE_MODULE_PATH}`); + + return {path: workspace_path, foundAt: 'workspaces'}; + } + } + + /* Find from node path */ + let node_path = null; + if (!exports.isUndefined(process.env.NODE_PATH)) { + node_path = path.join(process.env.NODE_PATH, module); + } + if (node_path && fs.existsSync(node_path)) { + exports.debug(`Getting ${module} from ${process.env.NODE_PATH}`); + + return {path: node_path, foundAt: 'nodePath'}; + } + + /* Find from global node modules path */ + exports.debug(`Getting ${module} from ${GLOBAL_MODULE_PATH}`); + + let global_path = path.join(GLOBAL_MODULE_PATH, module); + if (!global_path || !fs.existsSync(global_path)) { + return {error: 'module_not_found'}; + } + + return {path: global_path, foundAt: 'local'}; + } + + return {path: local_path, foundAt: 'global'}; + } +}; + +const getReRunSpecs = (rawArgs) => { + let finalArgs = rawArgs; + if (this.isTestObservabilitySession() && this.shouldReRunObservabilityTests()) { + let startIdx = -1, numEle = 0; + for(let idx=0; idx item !== '--disable-test-observability' && item !== '--disable-browserstack-automation'); +} + +const getLocalSessionReporter = () => { + if(this.isTestObservabilitySession() && process.env.BS_TESTOPS_JWT) { + return ['--reporter', TEST_OBSERVABILITY_REPORTER_LOCAL]; + } else { + return []; + } +} + +const cleanupTestObservabilityFlags = (rawArgs) => { + const newArgs = []; + const aliasMap = Object.keys(runOptions).reduce( (acc, key) => { + const curr = runOptions[key]; + if (curr.alias) { + const aliases = Array.isArray(curr.alias) ? curr.alias : [curr.alias] + for (const alias of aliases) { + acc[alias] = curr; + } + } + return acc; + }, {}) + + const cliArgs = { + ...runOptions, + ...aliasMap + } + + // these flags are present in cypress too, but in some the same cli and + // cypress flags have different meaning. In that case, we assume user has + // given cypress related args + const retain = ['c', 'p', 'b', 'o', 's', 'specs', 'spec'] + + for (let i = 0;i < rawArgs.length;i++) { + const arg = rawArgs[i]; + if (arg.startsWith('-')) { + const argName = arg.length > 1 && arg[1] == '-' ? arg.slice(2) : arg.slice(1); + // If this flag belongs to cli, we omit it and its value + if (cliArgs[argName] && !retain.includes(argName)) { + const nextArg = i + 1 < rawArgs.length ? rawArgs[i+1] : '' + // if the flag is bound to have a value, we ignore it + if (cliArgs[argName].type && cliArgs[argName].type !== 'boolean' && !nextArg.startsWith('-')) { + i++; + } + continue; + } + } + newArgs.push(rawArgs[i]); + } + return newArgs; +} + +exports.runCypressTestsLocally = (bsConfig, args, rawArgs) => { + try { + rawArgs = cleanupTestObservabilityFlags(rawArgs); + logger.info(`Running npx cypress run ${getReRunSpecs(rawArgs.slice(1)).join(' ')} ${getLocalSessionReporter().join(' ')}`); + const cypressProcess = spawn( + 'npx', + ['cypress', 'run', ...getReRunSpecs(rawArgs.slice(1)), ...getLocalSessionReporter()], + { stdio: 'inherit', cwd: process.cwd(), env: process.env, shell: true } + ); + cypressProcess.on('close', async (code) => { + logger.info(`Cypress process exited with code ${code}`); + await this.printBuildLink(true); + }); + + cypressProcess.on('error', (err) => { + logger.info(`Cypress process encountered an error ${err}`); + }); + } catch(e) { + exports.debug(`Encountered an error when trying to spawn a Cypress test locally ${e}`, true, e); + } +} + +class PathHelper { + constructor(config, prefix) { + this.config = config + this.prefix = prefix + } + + relativeTestFilePath(testFilePath) { + // Based upon https://github.com/facebook/jest/blob/49393d01cdda7dfe75718aa1a6586210fa197c72/packages/jest-reporters/src/relativePath.ts#L11 + const dir = this.config.cwd || this.config.rootDir + return path.relative(dir, testFilePath) + } + + prefixTestPath(testFilePath) { + const relativePath = this.relativeTestFilePath(testFilePath) + return this.prefix ? path.join(this.prefix, relativePath) : relativePath + } +} +exports.PathHelper = PathHelper; diff --git a/bin/testObservability/helper/requestQueueHandler.js b/bin/testObservability/helper/requestQueueHandler.js new file mode 100644 index 00000000..424e1a20 --- /dev/null +++ b/bin/testObservability/helper/requestQueueHandler.js @@ -0,0 +1,101 @@ +const fs = require('fs'); +const cp = require('child_process'); +const path = require('path'); + +const { BATCH_SIZE, BATCH_INTERVAL, PENDING_QUEUES_FILE } = require('./constants'); +const { batchAndPostEvents } = require('./helper'); + +class RequestQueueHandler { + constructor() { + this.queue = []; + this.started = false; + this.eventUrl = 'api/v1/batch'; + this.screenshotEventUrl = 'api/v1/screenshots'; + this.BATCH_EVENT_TYPES = ['LogCreated', 'CBTSessionCreated', 'TestRunFinished', 'TestRunSkipped', 'HookRunFinished', 'TestRunStarted', 'HookRunStarted', 'BuildUpdate']; + this.pollEventBatchInterval = null; + } + + start = () => { + if(!this.started) { + this.started = true; + this.startEventBatchPolling(); + } + } + + add = (event) => { + if(this.BATCH_EVENT_TYPES.includes(event.event_type)) { + if(event.logs && event.logs[0] && event.logs[0].kind === 'TEST_SCREENSHOT') { + return { + shouldProceed: true, + proceedWithData: [event], + proceedWithUrl: this.screenshotEventUrl + } + } + + this.queue.push(event); + let data = null, shouldProceed = this.shouldProceed(); + if(shouldProceed) { + data = this.queue.slice(0,BATCH_SIZE); + this.queue.splice(0,BATCH_SIZE); + this.resetEventBatchPolling(); + } + + return { + shouldProceed: shouldProceed, + proceedWithData: data, + proceedWithUrl: this.eventUrl + } + } else { + return { + shouldProceed: true + } + } + } + + shutdownSync = () => { + this.removeEventBatchPolling('REMOVING'); + + fs.writeFileSync(path.join(__dirname, PENDING_QUEUES_FILE), JSON.stringify(this.queue)); + this.queue = []; + cp.spawnSync('node', [path.join(__dirname, 'cleanupQueueSync.js'), path.join(__dirname, PENDING_QUEUES_FILE)], {stdio: 'inherit'}); + fs.unlinkSync(path.join(__dirname, PENDING_QUEUES_FILE)); + } + + shutdown = async () => { + this.removeEventBatchPolling('REMOVING'); + while(this.queue.length > 0) { + const data = this.queue.slice(0,BATCH_SIZE); + this.queue.splice(0,BATCH_SIZE); + await batchAndPostEvents(this.eventUrl,'Shutdown-Queue',data); + } + } + + startEventBatchPolling = () => { + this.pollEventBatchInterval = setInterval(async () => { + if(this.queue.length > 0) { + const data = this.queue.slice(0,BATCH_SIZE); + this.queue.splice(0,BATCH_SIZE); + await batchAndPostEvents(this.eventUrl,'Interval-Queue',data); + } + }, BATCH_INTERVAL); + } + + resetEventBatchPolling = () => { + this.removeEventBatchPolling('RESETTING'); + this.startEventBatchPolling(); + } + + removeEventBatchPolling = (tag) => { + if(this.pollEventBatchInterval) { + clearInterval(this.pollEventBatchInterval); + this.pollEventBatchInterval = null; + if(tag === 'REMOVING') this.started = false; + } + } + + shouldProceed = () => { + return this.queue.length >= BATCH_SIZE; + } +} + +module.exports = RequestQueueHandler; diff --git a/bin/testObservability/plugin/index.js b/bin/testObservability/plugin/index.js new file mode 100644 index 00000000..6880eb75 --- /dev/null +++ b/bin/testObservability/plugin/index.js @@ -0,0 +1,40 @@ +const ipc = require('node-ipc'); +const { connectIPCClient } = require('./ipcClient'); +const { IPC_EVENTS } = require('../helper/constants'); + +const browserstackTestObservabilityPlugin = (on, config, callbacks) => { + connectIPCClient(config); + + on('task', { + test_observability_log(log) { + ipc.of.browserstackTestObservability.emit(IPC_EVENTS.LOG, log); + return null; + }, + test_observability_command(commandObj) { + ipc.of.browserstackTestObservability.emit(IPC_EVENTS.COMMAND, commandObj); + return null; + }, + test_observability_platform_details(platformObj) { + ipc.of.browserstackTestObservability.emit(IPC_EVENTS.PLATFORM_DETAILS, platformObj); + return null; + }, + test_observability_step(log) { + ipc.of.browserstackTestObservability.emit(IPC_EVENTS.CUCUMBER, log); + return null; + } + }); + + on('after:screenshot', (screenshotInfo) => { + let logMessage; + if (callbacks && callbacks.screenshotLogFn && typeof callbacks.screenshotLogFn === 'function') { + logMessage = callbacks.screenshotLogFn(screenshotInfo); + } + ipc.of.browserstackTestObservability.emit(IPC_EVENTS.SCREENSHOT, { + logMessage, + screenshotInfo, + }); + return null; + }); +}; + +module.exports = browserstackTestObservabilityPlugin; diff --git a/bin/testObservability/plugin/ipcClient.js b/bin/testObservability/plugin/ipcClient.js new file mode 100644 index 00000000..5171f861 --- /dev/null +++ b/bin/testObservability/plugin/ipcClient.js @@ -0,0 +1,16 @@ +const ipc = require('node-ipc'); +const { IPC_EVENTS } = require('../helper/constants'); + +exports.connectIPCClient = (config) => { + ipc.config.id = 'browserstackTestObservability'; + ipc.config.retry = 1500; + ipc.config.silent = true; + + ipc.connectTo('browserstackTestObservability', () => { + ipc.of.browserstackTestObservability.on('connect', () => { + ipc.of.browserstackTestObservability.emit(IPC_EVENTS.CONFIG, config); + }); + ipc.of.browserstackTestObservability.on('disconnect', () => { + }); + }); +}; diff --git a/bin/testObservability/plugin/ipcServer.js b/bin/testObservability/plugin/ipcServer.js new file mode 100644 index 00000000..62e84394 --- /dev/null +++ b/bin/testObservability/plugin/ipcServer.js @@ -0,0 +1,38 @@ +const ipc = require('node-ipc'); +const { consoleHolder } = require('../helper/constants'); +const { requestQueueHandler } = require('../helper/helper'); + +exports.startIPCServer = (subscribeServerEvents, unsubscribeServerEvents) => { + if (ipc.server) { + unsubscribeServerEvents(ipc.server); + subscribeServerEvents(ipc.server); + return; + } + ipc.config.id = 'browserstackTestObservability'; + ipc.config.retry = 1500; + ipc.config.silent = true; + + ipc.serve(() => { + + ipc.server.on('socket.disconnected', (socket, destroyedSocketID) => { + ipc.log(`client ${destroyedSocketID} has disconnected!`); + }); + + ipc.server.on('destroy', () => { + ipc.log('server destroyed'); + }); + + subscribeServerEvents(ipc.server); + + process.on('exit', () => { + unsubscribeServerEvents(ipc.server); + ipc.server.stop(); + // Cleaning up all remaining event in request queue handler. Any synchronous operations + // on exit handler will block the process + requestQueueHandler.shutdownSync(); + }); + + }); + + ipc.server.start(); +}; diff --git a/bin/testObservability/reporter/index.js b/bin/testObservability/reporter/index.js new file mode 100644 index 00000000..396ad0e3 --- /dev/null +++ b/bin/testObservability/reporter/index.js @@ -0,0 +1,760 @@ +'use strict'; + +const util = require('util'); +const fs = require('fs'); +const path = require('path'); +const { requireModule } = require('../helper/helper'); +const Base = requireModule('mocha/lib/reporters/base.js'), + utils = requireModule('mocha/lib/utils.js'); +const color = Base.color; +const Mocha = requireModule('mocha'); +// const Runnable = requireModule('mocha/lib/runnable'); +const Runnable = require('mocha/lib/runnable'); // need to handle as this isn't present in older mocha versions +const { v4: uuidv4 } = require('uuid'); + +const { IPC_EVENTS } = require('../helper/constants'); +const { startIPCServer } = require('../plugin/ipcServer'); + +const HOOK_TYPES_MAP = { + "before all": "BEFORE_ALL", + "after all": "AFTER_ALL", + "before each": "BEFORE_EACH", + "after each": "AFTER_EACH", +} + +const { + EVENT_RUN_END, + EVENT_TEST_BEGIN, + EVENT_TEST_END, + EVENT_TEST_PENDING, + EVENT_RUN_BEGIN, + EVENT_TEST_FAIL, + EVENT_TEST_PASS, + EVENT_SUITE_BEGIN, + EVENT_SUITE_END, + EVENT_HOOK_BEGIN, + EVENT_HOOK_END +} = Mocha.Runner.constants; + +const { + STATE_PASSED, + STATE_PENDING, + STATE_FAILED, +} = Runnable.constants; + +const { + uploadEventData, + failureData, + PathHelper, + getTestEnv, + getHookDetails, + getHooksForTest, + mapTestHooks, + debug, + isBrowserstackInfra, + requestQueueHandler, + getHookSkippedTests, + getOSDetailsFromSystem, + findGitConfig, + getFileSeparatorData, + setCrashReportingConfigFromReporter, + debugOnConsole +} = require('../helper/helper'); + +const { consoleHolder } = require('../helper/constants'); + +// this reporter outputs test results, indenting two spaces per suite +class MyReporter { + constructor(runner, options) { + this.testObservability = true; + Base.call(this, runner, options); + this._testEnv = getTestEnv(); + this._paths = new PathHelper({ cwd: process.cwd() }, this._testEnv.location_prefix); + this.currentTestSteps = []; + this.currentTestCucumberSteps = []; + this.hooksStarted = {}; + this.beforeHooks = []; + this.platformDetailsMap = {}; + this.runStatusMarkedHash = {}; + this.haveSentBuildUpdate = false; + this.registerListeners(); + setCrashReportingConfigFromReporter(null, process.env.OBS_CRASH_REPORTING_BS_CONFIG_PATH, process.env.OBS_CRASH_REPORTING_CYPRESS_CONFIG_PATH); + + runner + .once(EVENT_RUN_BEGIN, async () => { + }) + + .on(EVENT_SUITE_BEGIN, (suite) => { + }) + + .on(EVENT_HOOK_BEGIN, async (hook) => { + debugOnConsole(`[MOCHA EVENT] EVENT_HOOK_BEGIN`); + if(this.testObservability == true) { + if(!hook.hookAnalyticsId) { + hook.hookAnalyticsId = uuidv4(); + } else if(this.runStatusMarkedHash[hook.hookAnalyticsId]) { + delete this.runStatusMarkedHash[hook.hookAnalyticsId]; + hook.hookAnalyticsId = uuidv4(); + } + debugOnConsole(`[MOCHA EVENT] EVENT_HOOK_BEGIN for uuid: ${hook.hookAnalyticsId}`); + hook.hook_started_at = (new Date()).toISOString(); + hook.started_at = (new Date()).toISOString(); + this.current_hook = hook; + await this.sendTestRunEvent(hook,undefined,false,"HookRunStarted"); + } + }) + + .on(EVENT_HOOK_END, async (hook) => { + debugOnConsole(`[MOCHA EVENT] EVENT_HOOK_END`); + if(this.testObservability == true) { + if(!this.runStatusMarkedHash[hook.hookAnalyticsId]) { + if(!hook.hookAnalyticsId) { + /* Hook objects don't maintain uuids in Cypress-Mocha */ + hook.hookAnalyticsId = this.current_hook.hookAnalyticsId; + this.runStatusMarkedHash[this.current_hook.hookAnalyticsId] = true; + } else { + this.runStatusMarkedHash[hook.hookAnalyticsId] = true; + } + + // Remove hooks added at hook start + delete this.hooksStarted[hook.hookAnalyticsId]; + + debugOnConsole(`[MOCHA EVENT] EVENT_HOOK_END for uuid: ${hook.hookAnalyticsId}`); + + await this.sendTestRunEvent(hook,undefined,false,"HookRunFinished"); + } + } + }) + + .on(EVENT_SUITE_END, (suite) => { + }) + + .on(EVENT_TEST_PASS, async (test) => { + debugOnConsole(`[MOCHA EVENT] EVENT_TEST_PASS`); + if(this.testObservability == true) { + debugOnConsole(`[MOCHA EVENT] EVENT_TEST_PASS for uuid: ${test.testAnalyticsId}`); + if(!this.runStatusMarkedHash[test.testAnalyticsId]) { + if(test.testAnalyticsId) this.runStatusMarkedHash[test.testAnalyticsId] = true; + await this.sendTestRunEvent(test); + } + } + }) + + .on(EVENT_TEST_FAIL, async (test, err) => { + debugOnConsole(`[MOCHA EVENT] EVENT_TEST_FAIL`); + if(this.testObservability == true) { + debugOnConsole(`[MOCHA EVENT] EVENT_TEST_FAIL for uuid: ${test.testAnalyticsId}`); + if((test.testAnalyticsId && !this.runStatusMarkedHash[test.testAnalyticsId]) || (test.hookAnalyticsId && !this.runStatusMarkedHash[test.hookAnalyticsId])) { + if(test.testAnalyticsId) { + this.runStatusMarkedHash[test.testAnalyticsId] = true; + await this.sendTestRunEvent(test,err); + } else if(test.hookAnalyticsId) { + this.runStatusMarkedHash[test.hookAnalyticsId] = true; + await this.sendTestRunEvent(test,err,false,"HookRunFinished"); + } + } + } + }) + + .on(EVENT_TEST_PENDING, async (test) => { + debugOnConsole(`[MOCHA EVENT] EVENT_TEST_PENDING`); + if(this.testObservability == true) { + if(!test.testAnalyticsId) test.testAnalyticsId = uuidv4(); + debugOnConsole(`[MOCHA EVENT] EVENT_TEST_PENDING for uuid: ${test.testAnalyticsId}`); + if(!this.runStatusMarkedHash[test.testAnalyticsId]) { + this.runStatusMarkedHash[test.testAnalyticsId] = true; + await this.sendTestRunEvent(test,undefined,false,"TestRunSkipped"); + } + } + }) + + .on(EVENT_TEST_BEGIN, async (test) => { + debugOnConsole(`[MOCHA EVENT] EVENT_TEST_BEGIN for uuid: ${test.testAnalyticsId}`); + if (this.runStatusMarkedHash[test.testAnalyticsId]) return; + if(this.testObservability == true) { + await this.testStarted(test); + } + }) + + .on(EVENT_TEST_END, async (test) => { + debugOnConsole(`[MOCHA EVENT] EVENT_TEST_BEGIN for uuid: ${test.testAnalyticsId}`); + if (this.runStatusMarkedHash[test.testAnalyticsId]) return; + if(this.testObservability == true) { + if(!this.runStatusMarkedHash[test.testAnalyticsId]) { + if(test.testAnalyticsId) this.runStatusMarkedHash[test.testAnalyticsId] = true; + await this.sendTestRunEvent(test); + } + } + }) + + .once(EVENT_RUN_END, async () => { + try { + debugOnConsole(`[MOCHA EVENT] EVENT_RUN_END`); + if(this.testObservability == true) { + const hookSkippedTests = getHookSkippedTests(this.runner.suite); + for(const test of hookSkippedTests) { + if(!test.testAnalyticsId) test.testAnalyticsId = uuidv4(); + debugOnConsole(`[MOCHA EVENT] EVENT_RUN_END TestRunSkipped for uuid: ${test.testAnalyticsId}`); + await this.sendTestRunEvent(test,undefined,false,"TestRunSkipped"); + } + } + } catch(err) { + debug(`Exception in populating test data for hook skipped test with error : ${err}`, true, err); + } + + await this.uploadTestSteps(); + }); + } + + registerListeners() { + startIPCServer( + (server) => { + server.on(IPC_EVENTS.CONFIG, this.cypressConfigListener.bind(this)); + server.on(IPC_EVENTS.LOG, this.cypressLogListener.bind(this)); + server.on(IPC_EVENTS.SCREENSHOT, this.cypressScreenshotListener.bind(this)); + server.on(IPC_EVENTS.COMMAND, this.cypressCommandListener.bind(this)); + server.on(IPC_EVENTS.CUCUMBER, this.cypressCucumberStepListener.bind(this)); + server.on(IPC_EVENTS.PLATFORM_DETAILS, this.cypressPlatformDetailsListener.bind(this)); + }, + (server) => { + server.off(IPC_EVENTS.CONFIG, '*'); + server.off(IPC_EVENTS.LOG, '*'); + server.off(IPC_EVENTS.SCREENSHOT, '*'); + }, + ); + } + + testStarted = async (test) => { + try { + const lastTest = this.current_test; + this.current_test = test; + test.retryOf = null; + test.testAnalyticsId = uuidv4(); + test.started_at = (new Date()).toISOString(); + test.test_started_at = test.started_at; + if(test._currentRetry > 0 && lastTest && lastTest.title == test.title) { + /* Sending async to current test start to avoid current test end call getting fired before its start call */ + test.retryOf = lastTest.testAnalyticsId + await this.sendTestRunEvent(test, undefined, false, "TestRunStarted"); + lastTest.state = STATE_FAILED; + await this.sendTestRunEvent(lastTest, undefined, true); + } else { + await this.sendTestRunEvent(test, undefined, false, "TestRunStarted"); + } + this.lastTest = lastTest; + } catch(err) { + debug(`Exception in populating test data for test start with error : ${err}`, true, err); + } + } + + uploadTestSteps = async (shouldClearCurrentSteps = true, cypressSteps = null) => { + const currentTestSteps = cypressSteps ? cypressSteps : JSON.parse(JSON.stringify(this.currentTestSteps)); + /* TODO - Send as test logs */ + const allStepsAsLogs = []; + currentTestSteps.forEach(step => { + const currentStepAsLog = { + test_run_uuid : step.test_run_uuid, + hook_run_uuid : step.hook_run_uuid, + timestamp: step.started_at, + duration: step.duration, + level: step.result, + message: step.text, + failure: step.failure, + failure_reason: step.failure_reason, + failure_type: step.failure_type, + kind: 'TEST_STEP', + http_response: {} + }; + allStepsAsLogs.push(currentStepAsLog); + }); + await uploadEventData({ + event_type: 'LogCreated', + logs: allStepsAsLogs + }); + if(shouldClearCurrentSteps) this.currentTestSteps = []; + } + + sendTestRunEvent = async (test, err = undefined, customFinished=false, eventType = "TestRunFinished") => { + try { + if(test.body && test.body.match(/browserstack internal helper hook/)) return; + let failureArgs = []; + if(test.state === STATE_FAILED || eventType.match(/HookRun/)) { + if(test.err !== undefined) { + failureArgs = test.err.multiple ? [test.err.multiple, 'test'] : [test.err, 'err']; + } else if(err !== undefined) { + failureArgs = [err, 'err']; + } else { + failureArgs = []; + } + } + + const failureReason = test.err !== undefined ? test.err.toString() : err !== undefined ? err.toString() : undefined; + if(eventType == 'TestRunFinished' && failureReason && this.currentTestCucumberSteps.length) { + this.currentTestCucumberSteps[this.currentTestCucumberSteps.length - 1] = { + ...this.currentTestCucumberSteps[this.currentTestCucumberSteps.length - 1], + result: 'failed' + } + } + + let rootParentFile; + try { + rootParentFile = this.getRootParentFile(test) + } catch(e) { + rootParentFile = null; + } + let gitConfigPath = process.env.OBSERVABILITY_GIT_CONFIG_PATH ? process.env.OBSERVABILITY_GIT_CONFIG_PATH.toString() : (rootParentFile ? findGitConfig(rootParentFile) : null); + if(!isBrowserstackInfra()) gitConfigPath = process.env.OBSERVABILITY_GIT_CONFIG_PATH_LOCAL ? process.env.OBSERVABILITY_GIT_CONFIG_PATH_LOCAL.toString() : null; + const prefixedTestPath = rootParentFile ? this._paths.prefixTestPath(rootParentFile) : 'File path could not be found'; + + const fileSeparator = getFileSeparatorData(); + + let testData = { + 'framework': 'Cypress', + 'uuid': (eventType.includes("Test") ? test.testAnalyticsId : test.hookAnalyticsId) || uuidv4(), + 'name': test.title, + 'body': { + 'lang': 'javascript', + 'code': test.body + }, + 'scope': this.scope(test), + 'scopes': this.scopes(test), + 'identifier': test.fullTitle(), + 'file_name': prefixedTestPath.replaceAll("\\", "/"), + 'vc_filepath': !isBrowserstackInfra() ? ( gitConfigPath ? path.relative(gitConfigPath, rootParentFile) : null ) : ( gitConfigPath ? ((gitConfigPath == 'DEFAULT' ? '' : gitConfigPath) + fileSeparator + rootParentFile).replaceAll("\\", "/") : null ), + 'location': prefixedTestPath.replaceAll("\\", "/"), + 'result': eventType === "TestRunSkipped" ? 'skipped' : ( eventType === "TestRunStarted" ? 'pending' : this.analyticsResult(test, eventType, err) ), + 'failure_reason': failureReason, + 'duration_in_ms': test.duration || (eventType.match(/Finished/) || eventType.match(/Skipped/) ? Date.now() - (new Date(test.started_at)).getTime() : null), + 'started_at': ( ( (eventType.match(/TestRun/) ? test.test_started_at : test.hook_started_at) || test.started_at ) || (new Date()).toISOString() ), + 'finished_at': eventType.match(/Finished/) || eventType.match(/Skipped/) ? (new Date()).toISOString() : null, + 'failure': failureData(...failureArgs), + 'failure_type': !failureReason ? null : failureReason.match(/AssertionError/) ? 'AssertionError' : 'UnhandledError', + 'retry_of': test.retryOf, + 'meta': { + steps: [] + } + }; + + debugOnConsole(`${eventType} for uuid: ${testData.uuid}`); + + if(eventType.match(/TestRunFinished/) || eventType.match(/TestRunSkipped/)) { + testData['meta'].steps = JSON.parse(JSON.stringify(this.currentTestCucumberSteps)); + this.currentTestCucumberSteps = []; + } + + const { os, os_version } = await getOSDetailsFromSystem(process.env.observability_product); + if(process.env.observability_integration) { + testData = {...testData, integrations: { + [process.env.observability_integration || 'local_grid' ]: { + 'build_id': process.env.observability_build_id, + 'session_id': process.env.observability_automate_session_id + btoa(prefixedTestPath.replaceAll("\\", "/")), + 'capabilities': {}, + 'product': process.env.observability_product, + 'platform': process.env.observability_os || os, + 'platform_version': process.env.observability_os_version || os_version, + 'browser': process.env.observability_browser, + 'browser_version': process.env.observability_browser_version + } + }}; + } else if(this.platformDetailsMap[process.pid] && this.platformDetailsMap[process.pid][test.title]) { + const {browser, platform} = this.platformDetailsMap[process.pid][test.title]; + testData = {...testData, integrations: { + 'local_grid': { + 'capabilities': {}, + 'platform': os, + 'platform_version': os_version, + 'browser': browser.name, + 'browser_version': browser.majorVersion + } + }}; + if(eventType === "TestRunFinished" || eventType === "TestRunSkipped") { + delete this.platformDetailsMap[process.pid][test.title]; + } + } + + if (eventType === "TestRunSkipped" && !testData['started_at']) { + testData['started_at'] = testData['finished_at']; + } + + try { + if(eventType.match(/HookRun/)) { + [testData.hook_type, testData.name] = getHookDetails(test.fullTitle() || test.originalTitle || test.title); + if(eventType === "HookRunFinished") { + if(testData.result === 'pending') testData.result = 'passed'; + if(testData.hook_type == 'before each' && testData.result === 'failed' && ( !this.runStatusMarkedHash[test.ctx.currentTest.testAnalyticsId] )) { + if(test.ctx.currentTest.testAnalyticsId) this.runStatusMarkedHash[test.ctx.currentTest.testAnalyticsId] = true; + test.ctx.currentTest.state = STATE_FAILED; + await this.sendTestRunEvent(test.ctx.currentTest,undefined,true); + } + } + if(testData.hook_type.includes('each')) { + testData['test_run_id'] = testData['test_run_id'] || test.testAnalyticsId; + } else if(testData.hook_type.includes('after')) { + testData['test_run_id'] = this.lastTest ? this.lastTest.testAnalyticsId : testData['test_run_id']; + } + } else if(eventType.match(/TestRun/)) { + mapTestHooks(test); + } + } catch(e) { + debugOnConsole(`Exception in processing hook data for event ${eventType} with error : ${e}`); + debug(`Exception in processing hook data for event ${eventType} with error : ${e}`, true, e); + } + + const failure_data = testData['failure'][0]; + if (failure_data) { + testData['failure_backtrace'] = failure_data['backtrace'] + testData['failure_reason_expanded'] = failure_data['expanded'] + } + + if(["TestRunFinished","TestRunSkipped"].includes(eventType)) { + testData.hooks = getHooksForTest(test); + } + + let uploadData = { + event_type: eventType === "TestRunSkipped" ? "TestRunFinished" : eventType, + } + + if(eventType == "HookRunFinished") delete testData.started_at; + + if(eventType.match(/HookRun/)) { + testData['hook_type'] = HOOK_TYPES_MAP[testData['hook_type']]; + uploadData['hook_run'] = testData; + } else { + uploadData['test_run'] = testData; + } + + if(eventType == 'HookRunFinished' && testData['hook_type'] == 'BEFORE_ALL') { + uploadData.cypressSteps = JSON.parse(JSON.stringify(this.currentTestSteps)); + this.beforeHooks.push(uploadData); + this.currentTestSteps = []; + } else { + await uploadEventData(uploadData); + + if(eventType.match(/Finished/)) { + await this.uploadTestSteps(); + } + + if(eventType.match(/TestRun/)) { + this.beforeHooks.forEach(async(hookUploadObj) => { + const currentTestSteps = hookUploadObj.cypressSteps; + delete hookUploadObj.cypressSteps; + hookUploadObj['hook_run']['test_run_id'] = test.testAnalyticsId; + await uploadEventData(hookUploadObj); + await this.uploadTestSteps(false, currentTestSteps); + }); + this.beforeHooks = []; + } + } + + if(!this.haveSentBuildUpdate && (process.env.observability_framework_version || this.currentCypressVersion)) { + this.shouldSendBuildUpdate = true; + const buildUpdateData = { + event_type: 'BuildUpdate', + 'misc': { + observability_version: { + frameworkName: "Cypress", + sdkVersion: process.env.OBSERVABILITY_LAUNCH_SDK_VERSION, + frameworkVersion: ( process.env.observability_framework_version || this.currentCypressVersion ) + } + } + }; + await uploadEventData(buildUpdateData); + } + + // Add started hooks to the hash + if(eventType === 'HookRunStarted' && ['BEFORE_EACH', 'AFTER_EACH', 'BEFORE_ALL'].includes(testData['hook_type'])) { + this.hooksStarted[testData.uuid] = uploadData; + } + + // Send pending hook finsihed events for hook starts + if (eventType === 'TestRunFinished' || eventType === 'TestRunSkipped') { + Object.values(this.hooksStarted).forEach(async hookData => { + hookData['event_type'] = 'HookRunFinished'; + hookData['hook_run'] = { + ...hookData['hook_run'], + result: uploadData['test_run'].result, + failure: uploadData['test_run'].failure, + failure_type: uploadData['test_run'].failure_type, + failure_reason: uploadData['test_run'].failure_reason, + failure_reason_expanded: uploadData['test_run'].failure_reason_expanded, + failure_backtrace: uploadData['test_run'].failure_backtrace + + } + + if (hookData['hook_run']['hook_type'] === 'BEFORE_ALL') { + hookData['hook_run'].finished_at = uploadData['test_run'].finished_at; + hookData['hook_run'].duration_in_ms = new Date(hookData['hook_run'].finished_at).getTime() - new Date(hookData['hook_run'].started_at).getTime(); + } else { + hookData['hook_run'].finished_at = hookData['hook_run'].started_at; + hookData['hook_run'].duration_in_ms = 0; + } + await uploadEventData(hookData); + }) + this.hooksStarted = {}; + } + } catch(error) { + debugOnConsole(`Exception in populating test data for event ${eventType} with error : ${error}`); + debug(`Exception in populating test data for event ${eventType} with error : ${error}`, true, error); + } + } + + appendTestItemLog = async (log) => { + try { + if(this.current_hook && ( this.current_hook.hookAnalyticsId && !this.runStatusMarkedHash[this.current_hook.hookAnalyticsId] )) { + log.hook_run_uuid = this.current_hook.hookAnalyticsId; + } + if(!log.hook_run_uuid && this.current_test && ( this.current_test.testAnalyticsId && !this.runStatusMarkedHash[this.current_test.testAnalyticsId] )) log.test_run_uuid = this.current_test.testAnalyticsId; + if(log.hook_run_uuid || log.test_run_uuid) { + await uploadEventData({ + event_type: 'LogCreated', + logs: [log] + }); + } + } catch(error) { + debug(`Exception in uploading log data to Observability with error : ${error}`, true, error); + } + } + + cypressConfigListener = async (config) => { + } + + cypressCucumberStepListener = async ({log}) => { + if(log.name == 'step' && log.consoleProps && log.consoleProps.step && log.consoleProps.step.keyword) { + this.currentTestCucumberSteps = [ + ...this.currentTestCucumberSteps, + { + id: log.chainerId, + keyword: log.consoleProps.step.keyword, + text: log.consoleProps.step.text, + started_at: new Date().toISOString(), + finished_at: new Date().toISOString(), + duration: 0, + result: 'passed' + } + ]; + } else if(log.name == 'then' && log.type == 'child' && log.chainerId) { + this.currentTestCucumberSteps.forEach((gherkinStep, idx) => { + if(gherkinStep.id == log.chainerId) { + this.currentTestCucumberSteps[idx] = { + ...gherkinStep, + finished_at: new Date().toISOString(), + duration: Date.now() - (new Date(gherkinStep.started_at)).getTime(), + result: log.state, + failure: log.err?.stack || log.err?.message, + failure_reason: log.err?.stack || log.err?.message, + failure_type: log.err?.name || 'UnhandledError' + } + } + }) + } + } + + cypressLogListener = async ({level, message, file}) => { + this.appendTestItemLog({ + timestamp: new Date().toISOString(), + level: level.toUpperCase(), + message, + kind: 'TEST_LOG', + http_response: {} + }); + } + + cypressScreenshotListener = async ({logMessage, screenshotInfo}) => { + if(screenshotInfo.path) { + const screenshotAsBase64 = fs.readFileSync(screenshotInfo.path, {encoding: 'base64'}); + if(screenshotAsBase64) { + this.appendTestItemLog({ + timestamp: screenshotInfo.takenAt || new Date().toISOString(), + message: screenshotAsBase64, + kind: 'TEST_SCREENSHOT' + }); + } + } + } + + cypressPlatformDetailsListener = async({testTitle, browser, platform, cypressVersion}) => { + if(!process.env.observability_integration) { + this.platformDetailsMap[process.pid] = this.platformDetailsMap[process.pid] || {}; + if(testTitle) this.platformDetailsMap[process.pid][testTitle] = { browser, platform }; + } + this.currentCypressVersion = cypressVersion; + } + + getFormattedArgs = (args) => { + if(!args) return ''; + let res = ''; + args.forEach((val) => { + res = res + (res.length ? ', ' : '') + JSON.stringify(val); + }); + return res; + } + + cypressCommandListener = async ({type, command}) => { + if(!command || command?.attributes?.name == 'then') return; + + if(type == 'COMMAND_RETRY') { + command.id = command._log.chainerId; + } + + if(type == 'COMMAND_START') { + let isCommandPresent = null; + for(let idx=0; idx { + if(val.id == command.attributes.id) { + this.currentTestSteps[idx] = { + ...val, + finished_at: new Date().toISOString(), + duration: Date.now() - (new Date(val.started_at)).getTime(), + result: command.state + }; + stepUpdated = true; + } + }); + + if(!stepUpdated) { + /* COMMAND_END reported before COMMAND_START */ + const currentStepObj = { + id: command.attributes.id, + text: 'cy.' + command.attributes.name + '(' + this.getFormattedArgs(command.attributes.args) + ')', + started_at: new Date().toISOString(), + finished_at: new Date().toISOString(), + duration: 0, + result: command.state, + test_run_uuid: this.current_test?.testAnalyticsId && !this.runStatusMarkedHash[this.current_test.testAnalyticsId] ? this.current_test.testAnalyticsId : null, + hook_run_uuid : this.current_hook?.hookAnalyticsId && !this.runStatusMarkedHash[this.current_hook.hookAnalyticsId] ? this.current_hook.hookAnalyticsId : null + }; + if(currentStepObj.hook_run_uuid && currentStepObj.test_run_uuid) delete currentStepObj.test_run_uuid; + this.currentTestSteps = [ + ...this.currentTestSteps, + currentStepObj + ]; + } + } else if(type == 'COMMAND_RETRY') { + if(!command.id) return; + + let isRetryStepFound = false; + /* Parse steps array in reverse and update the last step with common chainerId */ + for(let idx=this.currentTestSteps.length-1; idx>=0; idx--) { + const val = this.currentTestSteps[idx]; + if(val.id.includes(command.id)) { + this.currentTestSteps[idx] = { + ...val, + failure: command?.error?.message, + failure_reason: command?.error?.message, + failure_type: command?.error?.isDefaultAssertionErr ? 'AssertionError' : 'UnhandledError', + finished_at: new Date().toISOString(), + duration: Date.now() - (new Date(val.started_at)).getTime(), + result: command?.error?.message ? 'failed' : 'pending' + }; + isRetryStepFound = true; + break; + } + } + + /* As a backup, parse steps array in reverse and update the last step with pending status */ + if(!isRetryStepFound) { + for(let idx=this.currentTestSteps.length-1; idx>=0; idx--) { + const val = this.currentTestSteps[idx]; + if(val.state == 'pending') { + this.currentTestSteps[idx] = { + ...val, + failure: command?.error?.message, + failure_reason: command?.error?.message, + failure_type: command?.error?.isDefaultAssertionErr ? 'AssertionError' : 'UnhandledError', + finished_at: new Date().toISOString(), + duration: Date.now() - (new Date(val.started_at)).getTime(), + result: command?.error?.message ? 'failed' : 'pending' + }; + isRetryStepFound = true; + break; + } + } + } + } + } + + analyticsResult(test, eventType, err) { + if(eventType.match(/HookRun/)) { + if(test.isFailed() || test.err || err) { + return 'failed'; + } else if(eventType == 'HookRunFinished') { + return 'passed'; + } else { + return 'pending'; + } + } else { + return { + [STATE_PASSED]: 'passed', + [STATE_PENDING]: 'pending', + [STATE_FAILED]: 'failed', + }[test.state] + } + } + + scope(test) { + const titlePath = test.titlePath() + // titlePath returns an array of the scope + the test title. + // as the test title is the last array item, we just remove it + // and then join the rest of the array as a space separated string + return titlePath.slice(0, titlePath.length - 1).join(' ') + } + + scopes(test) { + const titlePath = test.titlePath() + return titlePath.slice(0, titlePath.length - 1) + } + + // Recursively find the root parent, and return the parents file + // This is required as test.file can be undefined in some tests on cypress + getRootParentFile(test) { + if (test.file) { + return test.file + } + if(test.ctx) { + const ctxRes = (test.ctx.currentTest ? this.getRootParentFile(test.ctx.currentTest) : null); + if(ctxRes) return ctxRes; + } + if (test.parent) { + const parentRes = this.getRootParentFile(test.parent) || (test.parent.ctx && test.parent.ctx.currentTest ? this.getRootParentFile(test.parent.ctx.currentTest) : null); + if(parentRes) return parentRes; + + if(test.parent.suites && test.parent.suites.length > 0) { + test.parent.suites.forEach(suite => { + const suiteRes = suite.ctx ? this.getRootParentFile(suite.ctx) : null; + if(suiteRes) return suiteRes; + }); + } + } + + return null + } +} + +module.exports = MyReporter; diff --git a/package.json b/package.json index ef4718e9..69bf3859 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "browserstack-cypress-cli", - "version": "1.31.9", + "version": "1.31.10", "description": "BrowserStack Cypress CLI for Cypress integration with BrowserStack's remote devices.", "main": "index.js", "scripts": { diff --git a/test/unit/bin/helpers/atsHelper.js b/test/unit/bin/helpers/atsHelper.js index a8abae97..eda17dbf 100644 --- a/test/unit/bin/helpers/atsHelper.js +++ b/test/unit/bin/helpers/atsHelper.js @@ -2,12 +2,12 @@ const { expect } = require("chai"); const chai = require("chai"), chaiAsPromised = require("chai-as-promised"), sinon = require('sinon'), - request = require('request'); + request = require('axios'); const logger = require("../../../../bin/helpers/logger").winstonLogger, testObjects = require("../../support/fixtures/testObjects"), utils = require('../../../../bin/helpers/utils'), - atsHelper = require('../../../../bin/helpers/atsHelper'); + atsHelper = require('../../../../bin/helpers/atsHelper.js'); chai.use(chaiAsPromised); logger.transports["console.info"].silent = true; diff --git a/test/unit/bin/helpers/hashUtil.js b/test/unit/bin/helpers/hashUtil.js index 0b974c2f..0d8ab39c 100644 --- a/test/unit/bin/helpers/hashUtil.js +++ b/test/unit/bin/helpers/hashUtil.js @@ -121,7 +121,6 @@ describe("md5util", () => { sinon.assert.calledOnce(digestStub); }) .catch((error) => { - console.log("error is ",error) chai.assert.fail("Promise error"); }); }); diff --git a/test/unit/bin/helpers/reporterHTML.js b/test/unit/bin/helpers/reporterHTML.js index 1a0043e6..b5489053 100644 --- a/test/unit/bin/helpers/reporterHTML.js +++ b/test/unit/bin/helpers/reporterHTML.js @@ -6,15 +6,9 @@ const chai = require('chai'), const fs = require('fs'), path = require('path'), - request = require('request'), - unzipper = require('unzipper'), - decompress = require('decompress'); Constants = require("../../../../bin/helpers/constants"), logger = require("../../../../bin/helpers/logger").winstonLogger, - testObjects = require("../../support/fixtures/testObjects"), - formatRequest = require("../../../../bin/helpers/utils").formatRequest; - -const proxyquire = require("proxyquire").noCallThru(); + testObjects = require("../../support/fixtures/testObjects") const utils = require('../../../../bin/helpers/utils'); const reporterHTML = require('../../../../bin/helpers/reporterHTML'); @@ -232,28 +226,6 @@ describe('reporterHTML', () => { sendUsageReportStub.calledOnceWithExactly(bsConfig, args, message, messageType, errorCode, {}, rawArgs); }); }); - - describe('generateCypressBuildReport', () => { - it('calls cypress build report with report download url', () => { - let pathStub = sinon.stub(path, 'join'); - let fileExistStub = sinon.stub(fs, 'existsSync'); - let rewireReporterHTML = rewire('../../../../bin/helpers/reporterHTML'); - let generateCypressBuildReport = rewireReporterHTML.__get__('generateCypressBuildReport'); - let getReportResponseStub = sinon.stub(); - getReportResponseStub.calledOnceWith('abc/efg', 'report.zip', 'url'); - rewireReporterHTML.__set__('getReportResponse', getReportResponseStub); - pathStub.returns('abc/efg'); - fileExistStub.returns(true); - generateCypressBuildReport({ report_data: 'url' }); - pathStub.restore(); - }); - - reporterHTML.reportGenerator(bsConfig, buildId, args, rawArgs, {}); - - sinon.assert.calledOnce(requestStub); - sinon.assert.calledOnce(getUserAgentStub); - sendUsageReportStub.calledOnceWithExactly(bsConfig, args, message, messageType, errorCode, {}, rawArgs); - }); }); describe("unzipFile", () => {