diff --git a/bin/accessibility-automation/cypress/index.js b/bin/accessibility-automation/cypress/index.js index d5e6a0bd..78e9c388 100644 --- a/bin/accessibility-automation/cypress/index.js +++ b/bin/accessibility-automation/cypress/index.js @@ -336,26 +336,23 @@ afterEach(() => { } else if (attributes.prevAttempts && attributes.prevAttempts.length > 0) { filePath = (attributes.prevAttempts[0].invocationDetails && attributes.prevAttempts[0].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 + + let testRunUuid = null; + cy.task('get_test_run_uuid', { testIdentifier: attributes.title }) + .then((response) => { + if (response && response.testRunUuid) { + testRunUuid = response.testRunUuid; } - }; - browserStackLog(`Saving accessibility test results`); - cy.wrap(saveTestResults(win, payloadToSend), {timeout: 30000}).then(() => { + + const payloadToSend = { + "thTestRunUuid": testRunUuid, + "thBuildUuid": Cypress.env("BROWSERSTACK_TESTHUB_UUID"), + "thJwtToken": Cypress.env("BROWSERSTACK_TESTHUB_JWT") + }; + browserStackLog(`Payload to send: ${JSON.stringify(payloadToSend)}`); + + return cy.wrap(saveTestResults(win, payloadToSend), {timeout: 30000}); + }).then(() => { browserStackLog(`Saved accessibility test results`); }) diff --git a/bin/accessibility-automation/plugin/index.js b/bin/accessibility-automation/plugin/index.js index 4c35ef99..7e580001 100644 --- a/bin/accessibility-automation/plugin/index.js +++ b/bin/accessibility-automation/plugin/index.js @@ -1,6 +1,7 @@ const path = require("node:path"); const { decodeJWTToken } = require("../../helpers/utils"); const utils = require('../../helpers/utils'); +const http = require('http'); const browserstackAccessibility = (on, config) => { let browser_validation = true; @@ -14,6 +15,50 @@ const browserstackAccessibility = (on, config) => { return null }, + get_test_run_uuid({ testIdentifier, retries = 15, interval = 300 } = {}) { + return new Promise((resolve) => { + if(!testIdentifier) return resolve(null); + const port = process.env.REPORTER_API_PORT_NO; + let attempt = 0; + const fetchUuid = () => { + const options = { + hostname: '127.0.0.1', + port, + path: `/test-uuid?testIdentifier=${encodeURIComponent(testIdentifier)}`, + method: 'GET', + timeout: 2000 + }; + const req = http.request(options, (res) => { + let data = ''; + res.on('data', (chunk) => data += chunk); + res.on('end', () => { + if(res.statusCode === 200) { + try { + const json = JSON.parse(data || '{}'); + return resolve({ testRunUuid: json.testRunUuid || null }); + } catch(e) { + return resolve(null); + } + } else if (res.statusCode === 404) { + // Server up but endpoint not responding as expected – stop retrying. + return resolve(null); + } else { + retryOrResolve(); + } + }); + }); + req.on('error', () => retryOrResolve()); + req.on('timeout', () => { req.destroy(); retryOrResolve(); }); + req.end(); + }; + const retryOrResolve = () => { + attempt += 1; + if(attempt >= retries) return resolve(null); + setTimeout(fetchUuid, interval); + }; + fetchUuid(); + }); + } }) on('before:browser:launch', (browser = {}, launchOptions) => { try { @@ -51,6 +96,10 @@ const browserstackAccessibility = (on, config) => { 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.BROWSERSTACK_TESTHUB_UUID = process.env.BROWSERSTACK_TESTHUB_UUID + config.env.BROWSERSTACK_TESTHUB_JWT = process.env.BROWSERSTACK_TESTHUB_JWT + config.env.BROWSERSTACK_TESTHUB_API_PORT = process.env.BROWSERSTACK_TESTHUB_API_PORT + config.env.REPORTER_API_PORT_NO = process.env.REPORTER_API_PORT_NO config.env.IS_ACCESSIBILITY_EXTENSION_LOADED = browser_validation.toString() diff --git a/bin/commands/runs.js b/bin/commands/runs.js index 103edfba..8e7fba91 100644 --- a/bin/commands/runs.js +++ b/bin/commands/runs.js @@ -37,7 +37,8 @@ const { supportFileCleanup } = require('../accessibility-automation/helper'); const { isTurboScaleSession, getTurboScaleGridDetails, patchCypressConfigFileContent, atsFileCleanup } = require('../helpers/atsHelper'); - +const { shouldProcessEventForTesthub, checkAndSetAccessibility, findAvailablePort } = require('../testhub/utils'); +const TestHubHandler = require('../testhub/testhubHandler'); module.exports = function run(args, rawArgs) { utils.normalizeTestReportingEnvVars(); @@ -112,9 +113,15 @@ module.exports = function run(args, rawArgs) { // set build tag caps utils.setBuildTags(bsConfig, args); + checkAndSetAccessibility(bsConfig, isAccessibilitySession); + + const preferredPort = 5348; + const port = await findAvailablePort(preferredPort); + process.env.REPORTER_API_PORT_NO = port + // Send build start to TEST REPORTING AND ANALYTICS - if(isTestObservabilitySession) { - await launchTestSession(bsConfig, bsConfigPath); + if(shouldProcessEventForTesthub()) { + await TestHubHandler.launchBuild(bsConfig, bsConfigPath); utils.setO11yProcessHooks(null, bsConfig, args, null, buildReportData); } @@ -149,10 +156,6 @@ module.exports = function run(args, rawArgs) { // add cypress dependency if missing utils.setCypressNpmDependency(bsConfig); - if (isAccessibilitySession && isBrowserstackInfra) { - await createAccessibilityTestRun(bsConfig); - } - if (turboScaleSession) { const gridDetails = await getTurboScaleGridDetails(bsConfig, args, rawArgs); diff --git a/bin/helpers/helper.js b/bin/helpers/helper.js index b839c76b..e7c32f49 100644 --- a/bin/helpers/helper.js +++ b/bin/helpers/helper.js @@ -182,6 +182,17 @@ exports.getGitMetaData = () => { } }) } + +exports.getHostInfo = () => { + return { + hostname: os.hostname(), + platform: os.platform(), + type: os.type(), + version: os.version(), + arch: os.arch() + } +} + exports.getCiInfo = () => { var env = process.env; // Jenkins diff --git a/bin/helpers/utils.js b/bin/helpers/utils.js index c451378b..4be9125f 100644 --- a/bin/helpers/utils.js +++ b/bin/helpers/utils.js @@ -27,6 +27,7 @@ const usageReporting = require("./usageReporting"), { OBSERVABILITY_ENV_VARS, TEST_OBSERVABILITY_REPORTER } = require('../testObservability/helper/constants'); const { default: axios } = require("axios"); +const { shouldProcessEventForTesthub } = require("../testhub/utils"); exports.validateBstackJson = (bsConfigPath) => { return new Promise(function (resolve, reject) { @@ -1499,7 +1500,7 @@ 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) { + if(shouldProcessEventForTesthub()) { bsConfig["run_settings"]["reporter"] = TEST_OBSERVABILITY_REPORTER; return; } diff --git a/bin/testObservability/helper/helper.js b/bin/testObservability/helper/helper.js index 69a1a2b9..8d7ad133 100644 --- a/bin/testObservability/helper/helper.js +++ b/bin/testObservability/helper/helper.js @@ -116,7 +116,7 @@ exports.printBuildLink = async (shouldStopSession, exitCode = null) => { if(exitCode) process.exit(exitCode); } -const nodeRequest = (type, url, data, config) => { +exports.nodeRequest = (type, url, data, config) => { const requestQueueHandler = require('./requestQueueHandler'); return new Promise(async (resolve, reject) => { const options = { @@ -269,7 +269,7 @@ exports.getPackageVersion = (package_, bsConfig = null) => { return packageVersion; } -const setEnvironmentVariablesForRemoteReporter = (BS_TESTOPS_JWT, BS_TESTOPS_BUILD_HASHED_ID, BS_TESTOPS_ALLOW_SCREENSHOTS, OBSERVABILITY_LAUNCH_SDK_VERSION) => { +exports.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; @@ -343,7 +343,7 @@ exports.setCrashReportingConfigFromReporter = (credentialsStr, bsConfigPath, cyp } } -const setCrashReportingConfig = (bsConfig, bsConfigPath) => { +exports.setCrashReportingConfig = (bsConfig, bsConfigPath) => { try { const browserstackConfigFile = utils.readBsConfigJSON(bsConfigPath); const cypressConfigFile = getCypressConfigFileContent(bsConfig, null); @@ -414,10 +414,10 @@ exports.launchTestSession = async (user_config, bsConfigPath) => { } }; - const response = await nodeRequest('POST','api/v1/builds',data,config); + const response = await exports.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); + exports.setEnvironmentVariablesForRemoteReporter(response.data.jwt, response.data.build_hashed_id, response.data.allow_screenshots, data.observability_version.sdkVersion); if(this.isBrowserstackInfra() && (user_config.run_settings.auto_import_dev_dependencies != true)) helper.setBrowserstackCypressCliDependency(user_config); } catch(error) { if(!error.errorType) { @@ -444,7 +444,7 @@ exports.launchTestSession = async (user_config, bsConfigPath) => { } process.env.BS_TESTOPS_BUILD_COMPLETED = false; - setEnvironmentVariablesForRemoteReporter(null, null, null); + exports.setEnvironmentVariablesForRemoteReporter(null, null, null); } } } @@ -503,7 +503,7 @@ exports.batchAndPostEvents = async (eventUrl, kind, data) => { 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); + const response = await exports.nodeRequest('POST',eventUrl,data,config); exports.debugOnConsole(`[Request Batch Response] for events:uuids ${eventsUuids}`); if(response.data.error) { throw({message: response.data.error}); @@ -570,7 +570,7 @@ exports.uploadEventData = async (eventData, run=0) => { 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); + const response = await exports.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}); @@ -681,7 +681,7 @@ exports.stopBuildUpstream = async () => { }; try { - const response = await nodeRequest('PUT',`api/v1/builds/${process.env.BS_TESTOPS_BUILD_HASHED_ID}/stop`,data,config); + const response = await exports.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 { diff --git a/bin/testObservability/reporter/index.js b/bin/testObservability/reporter/index.js index 5885e020..3231d012 100644 --- a/bin/testObservability/reporter/index.js +++ b/bin/testObservability/reporter/index.js @@ -11,6 +11,7 @@ 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 http = require('http'); const { IPC_EVENTS, TEST_REPORTING_ANALYTICS } = require('../helper/constants'); const { startIPCServer } = require('../plugin/ipcServer'); @@ -61,6 +62,7 @@ const { } = require('../helper/helper'); const { consoleHolder } = require('../helper/constants'); +const { shouldProcessEventForTesthub } = require('../../testhub/utils'); // this reporter outputs test results, indenting two spaces per suite class MyReporter { @@ -70,12 +72,15 @@ class MyReporter { this._testEnv = getTestEnv(); this._paths = new PathHelper({ cwd: process.cwd() }, this._testEnv.location_prefix); this.currentTestSteps = []; + this.httpServer = null; this.currentTestCucumberSteps = []; this.hooksStarted = {}; this.beforeHooks = []; + this.testIdMap = {}; this.platformDetailsMap = {}; this.runStatusMarkedHash = {}; this.haveSentBuildUpdate = false; + this.startHttpServer(); this.registerListeners(); setCrashReportingConfigFromReporter(null, process.env.OBS_CRASH_REPORTING_BS_CONFIG_PATH, process.env.OBS_CRASH_REPORTING_CYPRESS_CONFIG_PATH); @@ -89,7 +94,7 @@ class MyReporter { .on(EVENT_HOOK_BEGIN, async (hook) => { if (this.isInternalHook(hook)) return; debugOnConsole(`[MOCHA EVENT] EVENT_HOOK_BEGIN`); - if(this.testObservability == true) { + if(shouldProcessEventForTesthub()) { if(!hook.hookAnalyticsId) { hook.hookAnalyticsId = uuidv4(); } else if(this.runStatusMarkedHash[hook.hookAnalyticsId]) { @@ -107,7 +112,7 @@ class MyReporter { .on(EVENT_HOOK_END, async (hook) => { if (this.isInternalHook(hook)) return; debugOnConsole(`[MOCHA EVENT] EVENT_HOOK_END`); - if(this.testObservability == true) { + if(shouldProcessEventForTesthub()) { if(!this.runStatusMarkedHash[hook.hookAnalyticsId]) { if(!hook.hookAnalyticsId) { /* Hook objects don't maintain uuids in Cypress-Mocha */ @@ -132,7 +137,7 @@ class MyReporter { .on(EVENT_TEST_PASS, async (test) => { debugOnConsole(`[MOCHA EVENT] EVENT_TEST_PASS`); - if(this.testObservability == true) { + if(shouldProcessEventForTesthub()) { debugOnConsole(`[MOCHA EVENT] EVENT_TEST_PASS for uuid: ${test.testAnalyticsId}`); if(!this.runStatusMarkedHash[test.testAnalyticsId]) { if(test.testAnalyticsId) this.runStatusMarkedHash[test.testAnalyticsId] = true; @@ -143,7 +148,7 @@ class MyReporter { .on(EVENT_TEST_FAIL, async (test, err) => { debugOnConsole(`[MOCHA EVENT] EVENT_TEST_FAIL`); - if(this.testObservability == true) { + if(shouldProcessEventForTesthub()) { 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) { @@ -159,7 +164,7 @@ class MyReporter { .on(EVENT_TEST_PENDING, async (test) => { debugOnConsole(`[MOCHA EVENT] EVENT_TEST_PENDING`); - if(this.testObservability == true) { + if(shouldProcessEventForTesthub()) { if(!test.testAnalyticsId) test.testAnalyticsId = uuidv4(); debugOnConsole(`[MOCHA EVENT] EVENT_TEST_PENDING for uuid: ${test.testAnalyticsId}`); if(!this.runStatusMarkedHash[test.testAnalyticsId]) { @@ -172,7 +177,7 @@ class MyReporter { .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) { + if(shouldProcessEventForTesthub()) { await this.testStarted(test); } }) @@ -180,7 +185,7 @@ class MyReporter { .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(shouldProcessEventForTesthub()) { if(!this.runStatusMarkedHash[test.testAnalyticsId]) { if(test.testAnalyticsId) this.runStatusMarkedHash[test.testAnalyticsId] = true; await this.sendTestRunEvent(test); @@ -191,7 +196,7 @@ class MyReporter { .once(EVENT_RUN_END, async () => { try { debugOnConsole(`[MOCHA EVENT] EVENT_RUN_END`); - if(this.testObservability == true) { + if(shouldProcessEventForTesthub()) { const hookSkippedTests = getHookSkippedTests(this.runner.suite); for(const test of hookSkippedTests) { if(!test.testAnalyticsId) test.testAnalyticsId = uuidv4(); @@ -214,6 +219,65 @@ class MyReporter { return false; } + async startHttpServer() { + if(this.httpServer !== null) return; + + try { + this.httpServer = http.createServer(async(req, res) => { + try { + // Set CORS headers + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); + + if (req.method === 'OPTIONS') { + res.writeHead(200); + res.end(); + return; + } + const parsedUrl = new URL(req.url, `http://${req.headers.host}`); + const pathname = parsedUrl.pathname; + const query = parsedUrl.searchParams; + + if (pathname === '/test-uuid' && req.method === 'GET') { + const testIdentifier = query.get('testIdentifier'); + + if (!testIdentifier) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + error: 'testIdentifier parameter is required', + testRunUuid: null + })); + return; + } + const testRunUuid = this.getTestId(testIdentifier); + + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ testRunUuid: testRunUuid })); + } else { + res.writeHead(404, { 'Content-Type': 'text/plain' }); + res.end('Not Found'); + } + } catch (error) { + debugOnConsole(`Exception in handling HTTP request : ${error}`); + debug(`Exception in handling HTTP request : ${error}`, true, error); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ testRunUuid: null })); + } + }); + + const port = process.env.REPORTER_API_PORT_NO; + + this.httpServer.listen(port, '127.0.0.1', async () => { + console.log(`Reporter HTTP server listening on port ${port}`); + }); + } catch (error) { + debugOnConsole(`Exception in starting reporter server : ${error}`); + debug(`Exception in starting reporter server : ${error}`, true, error); + } + } + registerListeners() { startIPCServer( (server) => { @@ -345,6 +409,8 @@ class MyReporter { debugOnConsole(`${eventType} for uuid: ${testData.uuid}`); + this.mapTestId(testData, eventType); + if(eventType.match(/TestRunFinished/) || eventType.match(/TestRunSkipped/)) { testData['meta'].steps = JSON.parse(JSON.stringify(this.currentTestCucumberSteps)); this.currentTestCucumberSteps = []; @@ -506,6 +572,16 @@ class MyReporter { } } + mapTestId = (testData, eventType) => { + if (!eventType.match(/TestRun/)) {return} + + this.testIdMap[testData.name] = testData.uuid; + } + + getTestId = (testIdentifier) => { + return this.testIdMap[testIdentifier] || null; + } + appendTestItemLog = async (log) => { try { if(this.current_hook && ( this.current_hook.hookAnalyticsId && !this.runStatusMarkedHash[this.current_hook.hookAnalyticsId] )) { diff --git a/bin/testhub/constants.js b/bin/testhub/constants.js new file mode 100644 index 00000000..6be9945f --- /dev/null +++ b/bin/testhub/constants.js @@ -0,0 +1,10 @@ +module.exports = { + 'TESTHUB_BUILD_API': 'api/v2/builds', + 'ACCESSIBILITY': 'accessibility', + 'OBSERVABILITY': 'observability', + 'ERROR': { + 'INVALID_CREDENTIALS': 'ERROR_INVALID_CREDENTIALS', + 'DEPRECATED': 'ERROR_SDK_DEPRECATED', + 'ACCESS_DENIED': 'ERROR_ACCESS_DENIED' + }, +}; diff --git a/bin/testhub/testhubHandler.js b/bin/testhub/testhubHandler.js new file mode 100644 index 00000000..a6b4724a --- /dev/null +++ b/bin/testhub/testhubHandler.js @@ -0,0 +1,135 @@ +const { + setCrashReportingConfig, + isTestObservabilitySession, + nodeRequest, +} = require("../testObservability/helper/helper"); +const helper = require("../helpers/helper"); +const testhubUtils = require("../testhub/utils"); +const TESTHUB_CONSTANTS = require("./constants"); +const logger = require('../../bin/helpers/logger').winstonLogger; + +class TestHubHandler { + static async launchBuild(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 === "") { + // if olly true + if (isTestObservabilitySession()) { + logger.debug( + "EXCEPTION IN BUILD START EVENT : Missing authentication token" + ); + process.env.BS_TESTOPS_BUILD_COMPLETED = false; + } + + if (testhubUtils.isAccessibilityEnabled()) { + logger.debug( + "Exception while creating test run for BrowserStack Accessibility Automation: Missing authentication token" + ); + process.env.BROWSERSTACK_TEST_ACCESSIBILITY = "false"; + } + + return [null, null]; + } + + try { + const data = await this.generateBuildUpstreamData(user_config); + const config = this.getConfig(obsUserName, obsAccessKey); + const response = await nodeRequest( "POST", TESTHUB_CONSTANTS.TESTHUB_BUILD_API, data, config); + const launchData = this.extractDataFromResponse(user_config, data, response, config); + } catch (error) { + console.log(error); + if (error.success === false) { // non 200 response + testhubUtils.logBuildError(error); + return; + } + + } + } + + static async generateBuildUpstreamData(user_config) { + const { buildName, projectName, buildDescription, buildTags } = helper.getBuildDetails(user_config, true); + const productMap = testhubUtils.getProductMap(user_config); + const data = { + project_name: projectName, + name: buildName, + build_identifier: "", // no build identifier in cypress + description: buildDescription || "", + started_at: new Date().toISOString(), + tags: buildTags, + host_info: helper.getHostInfo(), + 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(), + accessibility: testhubUtils.getAccessibilityOptions(user_config), + framework_details: testhubUtils.getFrameworkDetails(), + product_map: productMap, + browserstackAutomation: productMap["automate"], + }; + + return data; + } + + static async extractDataFromResponse( + user_config, + requestData, + response, + config + ) { + const launchData = {}; + + if (isTestObservabilitySession()) { + const [jwt, buildHashedId, allowScreenshot] = + testhubUtils.setTestObservabilityVariables( + user_config, + requestData, + response.data + ); + if (jwt && buildHashedId) { + launchData[TESTHUB_CONSTANTS.OBSERVABILITY] = { + jwt, + buildHashedId, + allowScreenshot, + }; + process.env.BROWSERSTACK_TEST_OBSERVABILITY = "true"; + } else { + launchData[TESTHUB_CONSTANTS.OBSERVABILITY] = {}; + process.env.BROWSERSTACK_TEST_OBSERVABILITY = "false"; + } + } else { + process.env.BROWSERSTACK_TEST_OBSERVABILITY = "false"; + } + + if(testhubUtils.isAccessibilityEnabled()) { + testhubUtils.setAccessibilityVariables(user_config, response.data); + } else { + process.env.BROWSERSTACK_ACCESSIBILITY = 'false'; + testhubUtils.checkAndSetAccessibility(user_config, false) + } + + if (testhubUtils.shouldProcessEventForTesthub()) { + testhubUtils.setTestHubCommonMetaInfo(user_config, response.data); + } + + return launchData; + } + + static getConfig(obsUserName, obsAccessKey) { + return { + auth: { + username: obsUserName, + password: obsAccessKey, + }, + headers: { + "Content-Type": "application/json", + "X-BSTACK-TESTOPS": "true", + }, + }; + } +} + +module.exports = TestHubHandler; diff --git a/bin/testhub/utils.js b/bin/testhub/utils.js new file mode 100644 index 00000000..642ecb62 --- /dev/null +++ b/bin/testhub/utils.js @@ -0,0 +1,286 @@ +const os = require("os"); + +const logger = require("../../bin/helpers/logger").winstonLogger; +const TESTHUB_CONSTANTS = require("./constants"); +const testObservabilityHelper = require("../../bin/testObservability/helper/helper"); +const helper = require("../helpers/helper"); +const accessibilityHelper = require("../accessibility-automation/helper"); +const { detect } = require('detect-port'); + + +const isUndefined = (value) => value === undefined || value === null; + +exports.getFrameworkDetails = (user_config) => { + return { + frameworkName: "Cypress", + frameworkVersion: testObservabilityHelper.getPackageVersion( + "cypress", + user_config + ), + sdkVersion: helper.getAgentVersion(), + language: "javascript", + testFramework: { + name: "cypress", + version: helper.getPackageVersion("cypress", user_config), + }, + }; +}; + +exports.isAccessibilityEnabled = () => { + if (process.env.BROWSERSTACK_TEST_ACCESSIBILITY !== undefined) { + return process.env.BROWSERSTACK_TEST_ACCESSIBILITY === "true"; + } + logger.debug('Accessibility is disabled'); + return false; +}; + +// app-automate and percy support is not present for cypress +exports.getProductMap = (user_config) => { + return { + observability: testObservabilityHelper.isTestObservabilitySession(), + accessibility: exports.isAccessibilityEnabled(user_config), + percy: false, + automate: testObservabilityHelper.isBrowserstackInfra(), + app_automate: false, + }; +}; + +exports.shouldProcessEventForTesthub = () => { + return ( + testObservabilityHelper.isTestObservabilitySession() || + exports.isAccessibilityEnabled() + ); +}; + +exports.setTestObservabilityVariables = ( + user_config, + requestData, + responseData +) => { + if (!responseData.observability) { + exports.handleErrorForObservability(); + + return [null, null, null]; + } + + if (!responseData.observability.success) { + exports.handleErrorForObservability(responseData.observability); + + return [null, null, null]; + } + + if (responseData.observability && responseData.observability.success) { + process.env.BS_TESTOPS_BUILD_COMPLETED = true; + testObservabilityHelper.setEnvironmentVariablesForRemoteReporter( + responseData.jwt, + responseData.build_hashed_id, + responseData.observability.options.allow_screenshots.toString(), + requestData.framework_details.sdkVersion + ); + helper.setBrowserstackCypressCliDependency(user_config); + return [ + responseData.jwt, + responseData.build_hashed_id, + process.env.BS_TESTOPS_ALLOW_SCREENSHOTS, + ]; + } + return [null, null, null]; +}; + +exports.handleErrorForObservability = (error = null) => { + process.env.BROWSERSTACK_TESTHUB_UUID = "null"; + process.env.BROWSERSTACK_TESTHUB_JWT = "null"; + process.env.BS_TESTOPS_BUILD_COMPLETED = "false"; + process.env.BS_TESTOPS_JWT = "null"; + process.env.BS_TESTOPS_BUILD_HASHED_ID = "null"; + process.env.BS_TESTOPS_ALLOW_SCREENSHOTS = "null"; + exports.logBuildError(error, TESTHUB_CONSTANTS.OBSERVABILITY); +}; + +exports.setAccessibilityVariables = (user_config, responseData) => { + if (!responseData.accessibility) { + exports.handleErrorForAccessibility(user_config); + + return [null, null]; + } + + if (!responseData.accessibility.success) { + exports.handleErrorForAccessibility( + user_config, + responseData.accessibility + ); + + return [null, null]; + } + + if (responseData.accessibility.options) { + logger.debug( + `BrowserStack Accessibility Automation Build Hashed ID: ${responseData.build_hashed_id}` + ); + setAccessibilityCypressCapabilities(user_config, responseData); + helper.setBrowserstackCypressCliDependency(user_config); + } +}; + +const setAccessibilityCypressCapabilities = (user_config, responseData) => { + if (isUndefined(user_config.run_settings.accessibilityOptions)) { + user_config.run_settings.accessibilityOptions = {}; + } + const { accessibilityToken, scannerVersion } = jsonifyAccessibilityArray( + responseData.accessibility.options.capabilities, + "name", + "value" + ); + process.env.ACCESSIBILITY_AUTH = accessibilityToken; + process.env.BS_A11Y_JWT = accessibilityToken; + process.env.ACCESSIBILITY_SCANNERVERSION = scannerVersion; + + if (accessibilityToken && responseData.build_hashed_id) { + this.checkAndSetAccessibility(user_config, true); + } + + user_config.run_settings.accessibilityOptions["authToken"] = accessibilityToken; + user_config.run_settings.accessibilityOptions["auth"] = accessibilityToken; + user_config.run_settings.accessibilityOptions["scannerVersion"] = scannerVersion; + user_config.run_settings.system_env_vars.push(`ACCESSIBILITY_AUTH=${accessibilityToken}`) + user_config.run_settings.system_env_vars.push(`ACCESSIBILITY_SCANNERVERSION=${scannerVersion}`) +}; + +// To handle array of json, eg: [{keyName : '', valueName : ''}] +const jsonifyAccessibilityArray = (dataArray, keyName, valueName) => { + const result = {}; + dataArray.forEach((element) => { + result[element[keyName]] = element[valueName]; + }); + + return result; +}; + +exports.handleErrorForAccessibility = (user_config, error = null) => { + exports.checkAndSetAccessibility(user_config, false); + process.env.BROWSERSTACK_TESTHUB_UUID = "null"; + process.env.BROWSERSTACK_TESTHUB_JWT = "null"; + exports.logBuildError(error, TESTHUB_CONSTANTS.ACCESSIBILITY); +}; + +exports.logBuildError = (error, product = "") => { + if (error === undefined) { + logger.error(`${product.toUpperCase()} Build creation failed`); + + return; + } + + try { + for (const errorJson of error.errors) { + const errorType = errorJson.key; + const errorMessage = errorJson.message; + if (errorMessage) { + switch (errorType) { + case TESTHUB_CONSTANTS.ERROR.INVALID_CREDENTIALS: + logger.error(errorMessage); + break; + case TESTHUB_CONSTANTS.ERROR.ACCESS_DENIED: + logger.info(errorMessage); + break; + case TESTHUB_CONSTANTS.ERROR.DEPRECATED: + logger.error(errorMessage); + break; + default: + logger.error(errorMessage); + } + } + } + } catch (e) { + logger.error(error); + } +}; + +exports.findAvailablePort = async (preferredPort, maxAttempts = 10) => { + let port = preferredPort; + for (let attempts = 0; attempts < maxAttempts; attempts++) { + try { + const availablePort = await detect(port); + + if (availablePort === port) { + return port; + } else { + // Double-check suggested port + const verify = await detect(availablePort); + if (verify === availablePort) { + return availablePort; + } + } + + // Try next port + port++; + } catch (err) { + logger.warn(`Error checking port ${port}:`, err.message); + + // If permission denied, jump to dynamic range + if (err.code === "EACCES") { + port = 49152; + } else { + port++; + } + } + } + + const fallbackPort = Math.floor(Math.random() * (65535 - 49152)) + 49152; + logger.warn(`Could not find available port. Using fallback port: ${fallbackPort}`); + return fallbackPort; +} + +exports.setTestHubCommonMetaInfo = (user_config, responseData) => { + process.env.BROWSERSTACK_TESTHUB_JWT = responseData.jwt; + process.env.BROWSERSTACK_TESTHUB_UUID = responseData.build_hashed_id; + user_config.run_settings.system_env_vars.push(`BROWSERSTACK_TESTHUB_JWT`); + user_config.run_settings.system_env_vars.push(`BROWSERSTACK_TESTHUB_UUID`); + user_config.run_settings.system_env_vars.push(`REPORTER_API_PORT_NO`); +}; + +exports.checkAndSetAccessibility = (user_config, accessibilityFlag) => { + if (!accessibilityHelper.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; + } + + if (!user_config.run_settings.system_env_vars) { + user_config.run_settings.system_env_vars = []; + } + + if (!isUndefined(accessibilityFlag)) { + process.env.BROWSERSTACK_TEST_ACCESSIBILITY = accessibilityFlag.toString(); + user_config.run_settings.accessibility = accessibilityFlag; + if ( + !user_config.run_settings.system_env_vars.includes("BROWSERSTACK_TEST_ACCESSIBILITY") + ) { + user_config.run_settings.system_env_vars.push(`BROWSERSTACK_TEST_ACCESSIBILITY=${accessibilityFlag}`); + } + return; + } + return; +}; + +exports.getAccessibilityOptions = (user_config) => { + const settings = isUndefined(user_config.run_settings.accessibilityOptions) + ? {} + : user_config.run_settings.accessibilityOptions; + return { settings: settings }; +}; + +exports.appendTestHubParams = (testData, eventType, accessibilityScanInfo) => { + if ( + exports.isAccessibilityEnabled() && + !["HookRunStarted", "HookRunFinished", "TestRunStarted"].includes( + eventType + ) && + !isUndefined(accessibilityScanInfo[testData.name]) + ) { + testData["product_map"] = { + accessibility: accessibilityScanInfo[testData.name], + }; + } +}; diff --git a/package.json b/package.json index 0ac41b32..7be0bf80 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "chalk": "4.1.2", "cli-progress": "^3.10.0", "decompress": "4.2.1", + "detect-port": "^2.1.0", "form-data": "^4.0.0", "fs-extra": "8.1.0", "getmac": "5.20.0",