From 0fc92f16ae74b4cefacc94876f718c77778ee3fa Mon Sep 17 00:00:00 2001 From: YASH JAIN Date: Mon, 27 Jul 2026 18:46:41 +0530 Subject: [PATCH 1/2] fix(ci-info): detect GitHub Actions before presence-only CI providers [SDK-7081] getCiInfo() checked Jenkins first on mere presence of JENKINS_URL/ JENKINS_HOME, so a self-hosted GitHub Actions runner with leftover Jenkins env vars was classified as Jenkins with build_url undefined. obs-api force-disables the TRA Re-run control for builds without a CI build_url, greying the button. Move the explicit GITHUB_ACTIONS check ahead of the presence-only providers. Also return after resolve({}) in getGitMetaData when no git directory is found, instead of falling through. Adds getCiInfo unit coverage: GHA detection, the Jenkins-shadowing regression, pure Jenkins, and no-CI. Co-Authored-By: Claude Fable 5 --- bin/helpers/helper.js | 21 ++++++------ test/unit/bin/helpers/helper.js | 59 +++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 10 deletions(-) diff --git a/bin/helpers/helper.js b/bin/helpers/helper.js index 93933022..d8853606 100644 --- a/bin/helpers/helper.js +++ b/bin/helpers/helper.js @@ -102,7 +102,7 @@ exports.getGitMetaData = () => { if(!info.commonGitDir) { logger.debug(`Unable to find a Git directory`); exports.debug(`Unable to find a Git directory`); - resolve({}); + return resolve({}); } if(!info.author && exports.findGitConfig(process.cwd())) { /* commit objects are packed */ @@ -196,6 +196,16 @@ exports.getHostInfo = () => { exports.getCiInfo = () => { var env = process.env; + // GitHub Actions — checked before the presence-only providers (Jenkins, + // Bitbucket) so leftover env vars on self-hosted runners can't shadow it + if (env.GITHUB_ACTIONS === "true" || (env.CI === "true" && env.GITHUB_RUN_ID)) { + return { + name: "GitHub Actions", + build_url: `${env.GITHUB_SERVER_URL || 'https://github.com'}/${env.GITHUB_REPOSITORY}/actions/runs/${env.GITHUB_RUN_ID}`, + job_name: env.GITHUB_WORKFLOW || env.GITHUB_JOB, + build_number: env.GITHUB_RUN_ID + }; + } // Jenkins if ((typeof env.JENKINS_URL === "string" && env.JENKINS_URL.length > 0) || (typeof env.JENKINS_HOME === "string" && env.JENKINS_HOME.length > 0)) { return { @@ -268,15 +278,6 @@ exports.getCiInfo = () => { build_number: env.CI_JOB_ID }; } - // GitHub Actions - if (env.GITHUB_ACTIONS === "true" || env.CI === "true" && env.GITHUB_RUN_ID) { - return { - name: "GitHub Actions", - build_url: `${env.GITHUB_SERVER_URL || 'https://github.com'}/${env.GITHUB_REPOSITORY}/actions/runs/${env.GITHUB_RUN_ID}`, - job_name: env.GITHUB_WORKFLOW || env.GITHUB_JOB, - build_number: env.GITHUB_RUN_ID - }; - } // Buildkite if (env.CI === "true" && env.BUILDKITE === "true") { return { diff --git a/test/unit/bin/helpers/helper.js b/test/unit/bin/helpers/helper.js index 3a3c1de8..c86c4729 100644 --- a/test/unit/bin/helpers/helper.js +++ b/test/unit/bin/helpers/helper.js @@ -38,4 +38,63 @@ describe('helper', () => { expect(helper.getSizeOfJsonObjectInBytes(truncatedVCSInfo)).to.be.lessThanOrEqual(64 * 1024); }); }); + + describe('getCiInfo', () => { + const CI_VARS = ['CI', 'GITHUB_ACTIONS', 'GITHUB_SERVER_URL', 'GITHUB_REPOSITORY', 'GITHUB_RUN_ID', 'GITHUB_RUN_NUMBER', 'GITHUB_WORKFLOW', 'GITHUB_JOB', 'JENKINS_URL', 'JENKINS_HOME', 'BUILD_URL', 'JOB_NAME', 'BUILD_NUMBER']; + let savedEnv = {}; + + beforeEach(() => { + CI_VARS.forEach((k) => { + savedEnv[k] = process.env[k]; + delete process.env[k]; + }); + }); + + afterEach(() => { + CI_VARS.forEach((k) => { + if (savedEnv[k] === undefined) delete process.env[k]; + else process.env[k] = savedEnv[k]; + }); + savedEnv = {}; + }); + + const setGithubActionsEnv = () => { + process.env.CI = 'true'; + process.env.GITHUB_ACTIONS = 'true'; + process.env.GITHUB_SERVER_URL = 'https://github.com'; + process.env.GITHUB_REPOSITORY = 'org/repo'; + process.env.GITHUB_RUN_ID = '27412345678'; + process.env.GITHUB_WORKFLOW = 'CI Workflow'; + }; + + it('detects GitHub Actions with build_url and run-id build_number', () => { + setGithubActionsEnv(); + const ciInfo = helper.getCiInfo(); + expect(ciInfo.name).to.eq('GitHub Actions'); + expect(ciInfo.build_url).to.eq('https://github.com/org/repo/actions/runs/27412345678'); + expect(ciInfo.build_number).to.eq('27412345678'); + }); + + it('prefers GitHub Actions over leaked Jenkins env vars on self-hosted runners', () => { + setGithubActionsEnv(); + process.env.JENKINS_HOME = '/var/lib/jenkins'; + const ciInfo = helper.getCiInfo(); + expect(ciInfo.name).to.eq('GitHub Actions'); + expect(ciInfo.build_url).to.eq('https://github.com/org/repo/actions/runs/27412345678'); + }); + + it('detects Jenkins when no explicit CI marker is set', () => { + process.env.JENKINS_URL = 'https://ci.internal/job/x'; + process.env.BUILD_URL = 'https://ci.internal/job/x/42/'; + process.env.JOB_NAME = 'x'; + process.env.BUILD_NUMBER = '42'; + const ciInfo = helper.getCiInfo(); + expect(ciInfo.name).to.eq('Jenkins'); + expect(ciInfo.build_url).to.eq('https://ci.internal/job/x/42/'); + }); + + it('returns null when no CI is detected', () => { + expect(helper.getCiInfo()).to.be.null; + }); + }); }); From 23d6c0517bea327e5a4bf93f5d0bf970b44f5743 Mon Sep 17 00:00:00 2001 From: YASH JAIN Date: Thu, 30 Jul 2026 11:34:23 +0530 Subject: [PATCH 2/2] fix(observability): inject TRA support-file listener synchronously (SDK-7121) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit setEventListeners deferred the support-file write to an async glob callback while runs.js proceeded synchronously to md5 hashing and zip archiving. The injection raced the archive: a lost race shipped an un-instrumented suite, and md5 caching ("Skipping zip upload...") made the bad zip sticky, so the new Automate dashboard (TRA) received zero test events while the old dashboard (independent of the injected plugin) kept working. This is why the symptom was environment-specific — a slower CI pipeline loses the race. Switch setEventListeners (and the identical race in the accessibility setAccessibilityEventListeners glob branch) to glob.sync so the writes complete before the caller reads the files. Adds a regression test asserting the observability require is present synchronously after the call returns. Co-Authored-By: Claude Opus 4.8 --- bin/accessibility-automation/helper.js | 45 +++++++++--------- bin/testObservability/helper/helper.js | 40 ++++++++-------- .../testObservability/setEventListeners.js | 46 +++++++++++++++++++ 3 files changed, 88 insertions(+), 43 deletions(-) create mode 100644 test/unit/bin/testObservability/setEventListeners.js diff --git a/bin/accessibility-automation/helper.js b/bin/accessibility-automation/helper.js index 0cd0850f..9538f33e 100644 --- a/bin/accessibility-automation/helper.js +++ b/bin/accessibility-automation/helper.js @@ -378,32 +378,29 @@ exports.setAccessibilityEventListeners = (bsConfig) => { } const globPattern = process.cwd() + supportFilesData.supportFile; - glob(globPattern, {}, (err, files) => { - if(err) { - logger.debug('EXCEPTION IN BUILD START EVENT : Unable to parse cypress support files'); - return; - } - - files.forEach(file => { - try { - const fileName = path.basename(file); - if(['e2e.js', 'e2e.ts', 'component.ts', 'component.js'].includes(fileName) && !file.includes('node_modules')) { - - 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; - } + // Synchronous for the same reason as testObservability setEventListeners + // (SDK-7121): the caller archives the suite right after this returns, so an + // async glob callback would race the archive and ship un-instrumented specs. + const files = glob.sync(globPattern, {}); + files.forEach(file => { + try { + const fileName = path.basename(file); + if(['e2e.js', 'e2e.ts', 'component.ts', 'component.js'].includes(fileName) && !file.includes('node_modules')) { + + 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 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/testObservability/helper/helper.js b/bin/testObservability/helper/helper.js index 49d625a1..acc165a9 100644 --- a/bin/testObservability/helper/helper.js +++ b/bin/testObservability/helper/helper.js @@ -301,27 +301,29 @@ 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 (isE2ESupportFile(file) || !files.some(f => isE2ESupportFile(f))) { - 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; - } + // Must be synchronous: runs.js proceeds to md5 hashing and zip archiving + // immediately after this returns. An async glob callback races the archive + // (SDK-7121) — a lost race ships an un-instrumented suite, and md5 caching + // makes it sticky, so TRA receives no test events. + const files = glob.sync(process.cwd() + supportFilesData.supportFile, {}); + files.forEach(file => { + try { + if (isE2ESupportFile(file) || !files.some(f => isE2ESupportFile(f))) { + 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 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); diff --git a/test/unit/bin/testObservability/setEventListeners.js b/test/unit/bin/testObservability/setEventListeners.js new file mode 100644 index 00000000..50f4431e --- /dev/null +++ b/test/unit/bin/testObservability/setEventListeners.js @@ -0,0 +1,46 @@ +'use strict'; +const chai = require('chai'); +const expect = chai.expect; +const sinon = require('sinon'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const o11yHelper = require('../../../../bin/testObservability/helper/helper'); +const baseHelper = require('../../../../bin/helpers/helper'); + +// Regression guard for SDK-7121: the TRA support-file injection MUST land +// synchronously. runs.js calls setEventListeners(bsConfig) and then proceeds +// immediately to md5 hashing + zip archiving. When the injection was deferred to +// an async glob callback, it raced the archive — a lost race shipped an +// un-instrumented suite, and md5 caching made it sticky, so the new Automate +// dashboard (TRA) received zero test events. +describe('testObservability setEventListeners', () => { + let tmpDir, cwdStub, getSupportFilesStub; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sdk7121-')); + fs.mkdirSync(path.join(tmpDir, 'cypress', 'support'), { recursive: true }); + fs.writeFileSync(path.join(tmpDir, 'cypress', 'support', 'e2e.js'), '// user original support file\n'); + + cwdStub = sinon.stub(process, 'cwd').returns(tmpDir); + getSupportFilesStub = sinon.stub(baseHelper, 'getSupportFiles').returns({ + supportFile: '/cypress/support/e2e.js', + cleanupParams: {} + }); + }); + + afterEach(() => { + cwdStub.restore(); + getSupportFilesStub.restore(); + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('injects the observability require synchronously before returning', () => { + o11yHelper.setEventListeners({ run_settings: {} }); + + // Read exactly as md5/archive would — synchronously, right after the call. + const content = fs.readFileSync(path.join(tmpDir, 'cypress', 'support', 'e2e.js'), 'utf-8'); + expect(content).to.include("browserstack-cypress-cli/bin/testObservability/cypress"); + }); +});