From 149854d165591ca1f22f274df795ef518e667884 Mon Sep 17 00:00:00 2001 From: harshit-bstack Date: Wed, 15 Oct 2025 11:22:21 +0530 Subject: [PATCH 01/27] feat: Implement Nightwatch Test Orchestration Module - Added helpers.js for utility functions related to test orchestration. - Created index.js to serve as the main entry point for the orchestration module. - Introduced orchestrationUtils.js for managing orchestration settings and operations. - Developed requestUtils.js for handling API requests to the BrowserStack orchestration API. - Implemented testOrchestrationHandler.js to manage test orchestration operations. - Added testOrchestrationIntegration.js for integrating orchestration with Nightwatch. - Created testOrderingServer.js to handle communication with the BrowserStack server for test ordering. - Updated requestHelper.js to improve error handling for API requests. --- nightwatch/globals.js | 88 +++- src/testorchestration/applyOrchestration.js | 69 +++ src/testorchestration/helpers.js | 362 ++++++++++++++ src/testorchestration/index.js | 113 +++++ src/testorchestration/orchestrationUtils.js | 454 ++++++++++++++++++ src/testorchestration/requestUtils.js | 119 +++++ .../testOrchestrationHandler.js | 139 ++++++ .../testOrchestrationIntegration.js | 94 ++++ src/testorchestration/testOrderingServer.js | 200 ++++++++ src/utils/helper.js | 101 ++++ src/utils/requestHelper.js | 4 +- 11 files changed, 1740 insertions(+), 3 deletions(-) create mode 100644 src/testorchestration/applyOrchestration.js create mode 100644 src/testorchestration/helpers.js create mode 100644 src/testorchestration/index.js create mode 100644 src/testorchestration/orchestrationUtils.js create mode 100644 src/testorchestration/requestUtils.js create mode 100644 src/testorchestration/testOrchestrationHandler.js create mode 100644 src/testorchestration/testOrchestrationIntegration.js create mode 100644 src/testorchestration/testOrderingServer.js diff --git a/nightwatch/globals.js b/nightwatch/globals.js index 5704b3b..da6a5ee 100644 --- a/nightwatch/globals.js +++ b/nightwatch/globals.js @@ -8,6 +8,8 @@ const {v4: uuidv4} = require('uuid'); const path = require('path'); const AccessibilityAutomation = require('../src/accessibilityAutomation'); const eventHelper = require('../src/utils/eventHelper'); +const OrchestrationUtils = require('../src/testorchestration/orchestrationUtils'); +const { type } = require('os'); const localTunnel = new LocalTunnel(); const testObservability = new TestObservability(); const accessibilityAutomation = new AccessibilityAutomation(); @@ -270,7 +272,7 @@ module.exports = { } }, - async before(settings) { + async before(settings, testEnvSettings) { if (!settings.desiredCapabilities['bstack:options']) { settings.desiredCapabilities['bstack:options'] = {}; } @@ -322,6 +324,78 @@ module.exports = { } catch (error) { Logger.error(`Could not configure or launch test reporting and analytics - ${error}`); } + + // Initialize and configure test orchestration + try { + const orchestrationUtils = OrchestrationUtils.getInstance(settings); + if (orchestrationUtils && orchestrationUtils.testOrderingEnabled()) { + Logger.info('Test orchestration is enabled and configured.'); + + // Apply test orchestration to reorder test files before execution + const TestOrchestrationIntegration = require('../src/testorchestration/testOrchestrationIntegration'); + const orchestrationIntegration = TestOrchestrationIntegration.getInstance(); + orchestrationIntegration.configure(settings); + + // Check if we have test files to reorder from various sources + let allTestFiles = []; + + if (settings.src_folders && Array.isArray(settings.src_folders) && settings.src_folders.length > 0) { + Logger.debug('Getting test files from src_folders configuration...'); + + settings.src_folders.forEach(folder => { + const files = helper.collectTestFiles(folder, 'src_folders config'); + allTestFiles = allTestFiles.concat(files); + }); + } + + // Remove duplicates and ensure all paths are relative to cwd + allTestFiles = [...new Set(allTestFiles)].map(file => { + return path.isAbsolute(file) ? path.relative(process.cwd(), file) : file; + }); + + + if (allTestFiles.length > 0) { + Logger.info(`Applying test orchestration to reorder test files... Found ${allTestFiles.length} test files`); + Logger.debug(`Test files: ${JSON.stringify(allTestFiles)}`); + + // Apply orchestration to get ordered test files (synchronously) + try { + const orderedFiles = await orchestrationIntegration.applyOrchestration(allTestFiles, settings); + if (orderedFiles && orderedFiles.length > 0) { + Logger.info(`✅ Test files reordered by orchestration: ${orderedFiles.length} files`); + Logger.info(`📋 Split test API called successfully - tests will run in optimized order`); + + Logger.info(`🔄 Test orchestration recommended order change:`); + Logger.info(` Original: ${allTestFiles.join(', ')}`); + Logger.info(` Optimized: ${orderedFiles.join(', ')}`); + + try { + settings.src_folders = orderedFiles; + for (const envName in testEnvSettings) { + testEnvSettings[envName].src_folders = orderedFiles; + testEnvSettings[envName].test_runner.src_folders = orderedFiles; + } + if (settings.test_runner && typeof settings.test_runner === 'object' && !Array.isArray(settings.test_runner)) { + settings.test_runner.src_folders = orderedFiles; + } + + } catch (reorderError) { + Logger.error(`❌ Runtime reordering failed: ${reorderError.message}`); + Logger.info(` Falling back to original order for current execution.`); + } + } else { + Logger.info('📋 Split test API called - no reordering available'); + } + } catch (error) { + Logger.error(`❌ Error applying test orchestration: ${error}`); + } + } else { + Logger.debug('No test files found for orchestration - skipping split test API call'); + } + } + } catch (error) { + Logger.error(`Could not configure test orchestration - ${error}`); + } try { accessibilityAutomation.configure(settings); @@ -344,6 +418,18 @@ module.exports = { async after() { localTunnel.stop(); + + // Collect build data for test orchestration if enabled + try { + const orchestrationUtils = OrchestrationUtils.getInstance(); + if (orchestrationUtils && orchestrationUtils.testOrderingEnabled()) { + Logger.info('Collecting build data for test orchestration...'); + await orchestrationUtils.collectBuildData(this.settings || {}); + } + } catch (error) { + Logger.error(`Error collecting build data for test orchestration: ${error}`); + } + if (helper.isTestObservabilitySession()) { process.env.NIGHTWATCH_RERUN_FAILED = nightwatchRerun; process.env.NIGHTWATCH_RERUN_REPORT_FILE = nightwatchRerunFile; diff --git a/src/testorchestration/applyOrchestration.js b/src/testorchestration/applyOrchestration.js new file mode 100644 index 0000000..b10be05 --- /dev/null +++ b/src/testorchestration/applyOrchestration.js @@ -0,0 +1,69 @@ +const path = require('path'); +const { performance } = require('perf_hooks'); +const Logger = require('../utils/logger'); +const TestOrchestrationHandler = require('./testOrchestrationHandler'); + +/** + * Applies test orchestration to the Nightwatch test run + * This function is the main entry point for the orchestration integration + */ +async function applyOrchestrationIfEnabled(specs, config) { + // Initialize orchestration handler + const orchestrationHandler = TestOrchestrationHandler.getInstance(config); + Logger.info('Orchestration handler is initialized'); + + if (!orchestrationHandler) { + Logger.warn('Orchestration handler is not initialized. Skipping orchestration.'); + return specs; + } + + // Check if runSmartSelection is enabled in config + const testOrchOptions = config.testOrchestrationOptions || config['@nightwatch/browserstack']?.testOrchestrationOptions || {}; + const runSmartSelectionEnabled = Boolean(testOrchOptions?.runSmartSelection?.enabled); + + if (!runSmartSelectionEnabled) { + Logger.info('runSmartSelection is not enabled in config. Skipping orchestration.'); + return specs; + } + + // Check if orchestration is enabled + let testOrderingApplied = false; + orchestrationHandler.addToOrderingInstrumentationData('enabled', orchestrationHandler.testOrderingEnabled()); + + const startTime = performance.now(); + + Logger.info('Test orchestration is enabled. Attempting to reorder test files.'); + + // Get the test files from the specs + const testFiles = specs; + testOrderingApplied = true; + Logger.info(`Test files to be reordered: ${testFiles.join(', ')}`); + + // Reorder the test files + const orderedFiles = await orchestrationHandler.reorderTestFiles(testFiles); + + if (orderedFiles && orderedFiles.length > 0) { + orchestrationHandler.setTestOrderingApplied(testOrderingApplied); + Logger.info(`Tests reordered using orchestration: ${orderedFiles.join(', ')}`); + + // Return the ordered files as the new specs + orchestrationHandler.addToOrderingInstrumentationData( + 'timeTakenToApply', + Math.floor(performance.now() - startTime) // Time in milliseconds + ); + + return orderedFiles; + } else { + Logger.info('No test files were reordered by orchestration.'); + orchestrationHandler.addToOrderingInstrumentationData( + 'timeTakenToApply', + Math.floor(performance.now() - startTime) // Time in milliseconds + ); + } + + return specs; +} + +module.exports = { + applyOrchestrationIfEnabled +}; \ No newline at end of file diff --git a/src/testorchestration/helpers.js b/src/testorchestration/helpers.js new file mode 100644 index 0000000..08c967c --- /dev/null +++ b/src/testorchestration/helpers.js @@ -0,0 +1,362 @@ +const os = require('os'); +const path = require('path'); +const { execSync } = require('child_process'); +const fs = require('fs'); +const Logger = require('../utils/logger'); + +// Constants +const MAX_GIT_META_DATA_SIZE_IN_BYTES = 512 * 1024; // 512 KB + +/** + * Get host information for the test orchestration + */ +function getHostInfo() { + return { + hostname: os.hostname(), + platform: process.platform, + architecture: process.arch, + release: os.release(), + username: os.userInfo().username + }; +} + +/** + * Format git author information + */ +function gitAuthor(name, email) { + if (!name && !email) { + return ''; + } + return `${name} (${email})`; +} + +/** + * Get the size of a JSON object in bytes + */ +function getSizeOfJsonObjectInBytes(obj) { + try { + const jsonString = JSON.stringify(obj); + return Buffer.byteLength(jsonString, 'utf8'); + } catch (e) { + Logger.error(`Error calculating object size: ${e}`); + return 0; + } +} + +/** + * Truncate a string to reduce its size by the specified number of bytes + */ +function truncateString(str, bytesToTruncate) { + if (!str || bytesToTruncate <= 0) { + return str; + } + + const originalBytes = Buffer.byteLength(str, 'utf8'); + const targetBytes = Math.max(0, originalBytes - bytesToTruncate); + + if (targetBytes >= originalBytes) { + return str; + } + + // Perform binary search to find the right truncation point + let left = 0; + let right = str.length; + + while (left < right) { + const mid = Math.floor((left + right) / 2); + const truncated = str.substring(0, mid); + const bytes = Buffer.byteLength(truncated, 'utf8'); + + if (bytes <= targetBytes) { + left = mid + 1; + } else { + right = mid; + } + } + + return str.substring(0, left - 1) + '...'; +} + +/** + * Check and truncate VCS info if needed + */ +function checkAndTruncateVcsInfo(gitMetaData) { + const gitMetaDataSizeInBytes = getSizeOfJsonObjectInBytes(gitMetaData); + + if (gitMetaDataSizeInBytes && gitMetaDataSizeInBytes > MAX_GIT_META_DATA_SIZE_IN_BYTES) { + const truncateSize = gitMetaDataSizeInBytes - MAX_GIT_META_DATA_SIZE_IN_BYTES; + const truncatedCommitMessage = truncateString(gitMetaData.commit_message, truncateSize); + gitMetaData.commit_message = truncatedCommitMessage; + Logger.info(`The commit has been truncated. Size of commit after truncation is ${getSizeOfJsonObjectInBytes(gitMetaData) / 1024} KB`); + } + return gitMetaData; +} + +/** + * Check if a git metadata result is valid + */ +function isValidGitResult(result) { + return ( + Array.isArray(result.filesChanged) && + result.filesChanged.length > 0 && + Array.isArray(result.authors) && + result.authors.length > 0 + ); +} + +/** + * Get base branch from repository + */ +function getBaseBranch() { + try { + // Try to get the default branch from origin/HEAD symbolic ref (works for most providers) + try { + const originHeadOutput = execSync('git symbolic-ref refs/remotes/origin/HEAD').toString().trim(); + if (originHeadOutput.startsWith('refs/remotes/origin/')) { + return originHeadOutput.replace('refs/remotes/', ''); + } + } catch (e) { + // Symbolic ref might not exist + } + + // Fallback: use the first branch in local heads + try { + const branchesOutput = execSync('git branch').toString().trim(); + const branches = branchesOutput.split('\n').filter(Boolean); + if (branches.length > 0) { + // Remove the '* ' from current branch if present and return first branch + const firstBranch = branches[0].replace(/^\*\s+/, '').trim(); + return firstBranch; + } + } catch (e) { + // Branches might not exist + } + + // Fallback: use the first remote branch if available + try { + const remoteBranchesOutput = execSync('git branch -r').toString().trim(); + const remoteBranches = remoteBranchesOutput.split('\n').filter(Boolean); + for (const branch of remoteBranches) { + const cleanBranch = branch.trim(); + if (cleanBranch.startsWith('origin/') && !cleanBranch.includes('HEAD')) { + return cleanBranch; + } + } + } catch (e) { + // Remote branches might not exist + } + } catch (e) { + Logger.debug(`Error finding base branch: ${e}`); + } + + return null; +} + +/** + * Get changed files from commits + */ +function getChangedFilesFromCommits(commitHashes) { + const changedFiles = new Set(); + + try { + for (const commit of commitHashes) { + try { + // Check if commit has parents + const parentsOutput = execSync(`git log -1 --pretty=%P ${commit}`).toString().trim(); + const parents = parentsOutput.split(' ').filter(Boolean); + + for (const parent of parents) { + const diffOutput = execSync(`git diff --name-only ${parent} ${commit}`).toString().trim(); + const files = diffOutput.split('\n').filter(Boolean); + + for (const file of files) { + changedFiles.add(file); + } + } + } catch (e) { + Logger.debug(`Error processing commit ${commit}: ${e}`); + } + } + } catch (e) { + Logger.debug(`Error getting changed files from commits: ${e}`); + } + + return Array.from(changedFiles); +} + +/** + * Get Git metadata for AI selection + * @param multiRepoSource Array of repository paths for multi-repo setup + */ +function getGitMetadataForAiSelection(folders = []) { + if (folders && folders.length === 0) { + folders = [process.cwd()]; + } + + const results = []; + + for (const folder of folders) { + const originalDir = process.cwd(); + try { + // Initialize the result structure + const result = { + prId: '', + filesChanged: [], + authors: [], + prDate: '', + commitMessages: [], + prTitle: '', + prDescription: '', + prRawDiff: '' + }; + + // Change directory to the folder + process.chdir(folder); + + // Get current branch and latest commit + const currentBranch = execSync('git rev-parse --abbrev-ref HEAD').toString().trim(); + const latestCommit = execSync('git rev-parse HEAD').toString().trim(); + result.prId = latestCommit; + + // Find base branch + const baseBranch = getBaseBranch(); + Logger.debug(`Base branch for comparison: ${baseBranch}`); + + let commits = []; + + if (baseBranch) { + try { + // Get changed files between base branch and current branch + const changedFilesOutput = execSync(`git diff --name-only ${baseBranch}...${currentBranch}`).toString().trim(); + Logger.debug(`Changed files between ${baseBranch} and ${currentBranch}: ${changedFilesOutput}`); + result.filesChanged = changedFilesOutput.split('\n').filter(f => f.trim()); + + // Get commits between base branch and current branch + const commitsOutput = execSync(`git log ${baseBranch}..${currentBranch} --pretty=%H`).toString().trim(); + commits = commitsOutput.split('\n').filter(Boolean); + } catch (e) { + Logger.debug('Failed to get changed files from branch comparison. Falling back to recent commits.'); + // Fallback to recent commits + const recentCommitsOutput = execSync('git log -10 --pretty=%H').toString().trim(); + commits = recentCommitsOutput.split('\n').filter(Boolean); + + if (commits.length > 0) { + result.filesChanged = getChangedFilesFromCommits(commits.slice(0, 5)); + } + } + } else { + // Fallback to recent commits + const recentCommitsOutput = execSync('git log -10 --pretty=%H').toString().trim(); + commits = recentCommitsOutput.split('\n').filter(Boolean); + + if (commits.length > 0) { + result.filesChanged = getChangedFilesFromCommits(commits.slice(0, 5)); + } + } + + // Process commit authors and messages + const authorsSet = new Set(); + const commitMessages = []; + + // Only process commits if we have them + if (commits.length > 0) { + for (const commit of commits) { + try { + const commitMessage = execSync(`git log -1 --pretty=%B ${commit}`).toString().trim(); + Logger.debug(`Processing commit: ${commitMessage}`); + + const authorName = execSync(`git log -1 --pretty=%an ${commit}`).toString().trim(); + authorsSet.add(authorName || 'Unknown'); + + commitMessages.push({ + message: commitMessage.trim(), + user: authorName || 'Unknown' + }); + } catch (e) { + Logger.debug(`Error processing commit ${commit}: ${e}`); + } + } + } + + // If we have no commits but have changed files, add a fallback author + if (commits.length === 0 && result.filesChanged.length > 0) { + try { + // Try to get current git user as fallback + const fallbackAuthor = execSync('git config user.name').toString().trim() || 'Unknown'; + authorsSet.add(fallbackAuthor); + Logger.debug(`Added fallback author: ${fallbackAuthor}`); + } catch (e) { + authorsSet.add('Unknown'); + Logger.debug('Added Unknown as fallback author'); + } + } + + result.authors = Array.from(authorsSet); + result.commitMessages = commitMessages; + + // Get commit date + if (latestCommit) { + const commitDate = execSync(`git log -1 --pretty=%cd --date=format:'%Y-%m-%d' ${latestCommit}`).toString().trim(); + result.prDate = commitDate.replace(/'/g, ''); + } + + // Set PR title and description from latest commit if not already set + if ((!result.prTitle || result.prTitle.trim() === '') && latestCommit) { + try { + const latestCommitMessage = execSync(`git log -1 --pretty=%B ${latestCommit}`).toString().trim(); + const messageLines = latestCommitMessage.trim().split('\n'); + result.prTitle = messageLines[0] || ''; + + if (messageLines.length > 2) { + result.prDescription = messageLines.slice(2).join('\n').trim(); + } + } catch (e) { + Logger.debug(`Error extracting commit message for PR title: ${e}`); + } + } + + // Reset directory + process.chdir(originalDir); + + results.push(result); + } catch (e) { + Logger.error(`Exception in populating Git metadata for AI selection (folder: ${folder}): ${e}`); + + // Reset directory if needed + try { + process.chdir(originalDir); + } catch (dirError) { + Logger.error(`Error resetting directory: ${dirError}`); + } + } + } + + // Filter out results with empty filesChanged + const filteredResults = results.filter(isValidGitResult); + + // Map to required output format + const formattedResults = filteredResults.map((result) => ({ + prId: result.prId || '', + filesChanged: Array.isArray(result.filesChanged) ? result.filesChanged : [], + authors: Array.isArray(result.authors) ? result.authors : [], + prDate: result.prDate || '', + commitMessages: Array.isArray(result.commitMessages) + ? result.commitMessages.map((cm) => ({ + message: cm.message || '', + user: cm.user || '' + })) + : [], + prTitle: result.prTitle || '', + prDescription: result.prDescription || '', + prRawDiff: result.prRawDiff || '' + })); + + return formattedResults; +} + +module.exports = { + getHostInfo, + getGitMetadataForAiSelection, + gitAuthor, + checkAndTruncateVcsInfo +}; \ No newline at end of file diff --git a/src/testorchestration/index.js b/src/testorchestration/index.js new file mode 100644 index 0000000..7cf2123 --- /dev/null +++ b/src/testorchestration/index.js @@ -0,0 +1,113 @@ +/** + * Nightwatch Test Orchestration Module + * + * This module provides test orchestration functionality for Nightwatch tests + * using BrowserStack's AI-powered test selection and ordering capabilities. + * + * @module nightwatch-test-orchestration + */ + +// Core orchestration classes +const TestOrchestrationHandler = require('./testOrchestrationHandler'); +const TestOrderingServer = require('./testOrderingServer'); +const OrchestrationUtils = require('./orchestrationUtils'); +const TestOrchestrationIntegration = require('./testOrchestrationIntegration'); + +// Utility classes +const RequestUtils = require('./requestUtils'); +const { getHostInfo, getGitMetadataForAiSelection } = require('./helpers'); + +// Main API and application functions +const { applyOrchestrationIfEnabled } = require('./applyOrchestration'); + +/** + * Main Test Orchestration class that provides the primary interface + */ +class NightwatchTestOrchestration { + constructor() { + this.handler = null; + this.integration = TestOrchestrationIntegration.getInstance(); + } + + /** + * Initialize test orchestration with configuration + * @param {Object} config - Nightwatch configuration object + */ + initialize(config) { + this.handler = TestOrchestrationHandler.getInstance(config); + this.integration.configure(config); + return this; + } + + /** + * Apply orchestration to test specs + * @param {Array} specs - Array of test file paths + * @param {Object} config - Configuration object + * @returns {Promise} - Ordered test specs + */ + async applyOrchestration(specs, config) { + if (!this.handler) { + this.initialize(config); + } + return await applyOrchestrationIfEnabled(specs, config); + } + + /** + * Collect build data after test execution + * @param {Object} config - Configuration object + * @returns {Promise} - Build data response + */ + async collectBuildData(config) { + if (!this.handler) { + this.initialize(config); + } + const utils = OrchestrationUtils.getInstance(config); + return await utils.collectBuildData(config); + } + + /** + * Check if test orchestration is enabled + * @param {Object} config - Configuration object + * @returns {boolean} - True if orchestration is enabled + */ + isEnabled(config) { + if (!this.handler) { + this.initialize(config); + } + return this.handler.testOrderingEnabled(); + } +} + +// Create main instance +const testOrchestration = new NightwatchTestOrchestration(); + +// Export main interface +module.exports = { + // Main class + NightwatchTestOrchestration, + + // Primary instance + testOrchestration, + + // Core classes + TestOrchestrationHandler, + TestOrderingServer, + OrchestrationUtils, + TestOrchestrationIntegration, + + // Utilities + RequestUtils, + helpers: { + getHostInfo, + getGitMetadataForAiSelection + }, + + // Main functions + applyOrchestrationIfEnabled, + + // API methods (convenient access) + initialize: (config) => testOrchestration.initialize(config), + applyOrchestration: (specs, config) => testOrchestration.applyOrchestration(specs, config), + collectBuildData: (config) => testOrchestration.collectBuildData(config), + isEnabled: (config) => testOrchestration.isEnabled(config) +}; \ No newline at end of file diff --git a/src/testorchestration/orchestrationUtils.js b/src/testorchestration/orchestrationUtils.js new file mode 100644 index 0000000..80295af --- /dev/null +++ b/src/testorchestration/orchestrationUtils.js @@ -0,0 +1,454 @@ +const path = require('path'); +const fs = require('fs'); +const os = require('os'); +const { tmpdir } = require('os'); +const Logger = require('../utils/logger'); +const { getHostInfo, getGitMetadataForAiSelection } = require('./helpers'); +const RequestUtils = require('./requestUtils'); + +// Constants +const RUN_SMART_SELECTION = 'runSmartSelection'; +const ALLOWED_ORCHESTRATION_KEYS = [RUN_SMART_SELECTION]; + +/** + * Class to handle test ordering functionality + */ +class TestOrdering { + constructor() { + this.enabled = false; + this.name = null; + } + + enable(name) { + this.enabled = true; + this.name = name; + } + + disable() { + this.enabled = false; + this.name = null; + } + + getEnabled() { + return this.enabled; + } + + getName() { + return this.name; + } +} + +/** + * Utility class for test orchestration + */ +class OrchestrationUtils { + static _instance = null; + + /** + * @param config Configuration object + */ + constructor(config) { + this.logger = Logger; + this.runSmartSelection = false; + this.smartSelectionMode = 'relevantFirst'; + this.testOrdering = new TestOrdering(); + this.smartSelectionSource = null; // Store source paths if provided + + // Check both possible configuration paths: direct or nested in browserstack options + let testOrchOptions = this._getTestOrchestrationOptions(config); + + // Try to get runSmartSelection options + const runSmartSelectionOpts = testOrchOptions[RUN_SMART_SELECTION] || {}; + + this._setRunSmartSelection( + runSmartSelectionOpts.enabled || false, + runSmartSelectionOpts.mode || 'relevantFirst', + runSmartSelectionOpts.source || null + ); + + // Extract build details from config + this._extractBuildDetails(config); + } + + /** + * Extract test orchestration options from config + */ + _getTestOrchestrationOptions(config) { + // Check direct config path + let testOrchOptions = config.testOrchestrationOptions || {}; + + // If not found at top level, check if it's in the browserstack plugin config + if (Object.keys(testOrchOptions).length === 0 && config['@nightwatch/browserstack']) { + const bsOptions = config['@nightwatch/browserstack']; + if (bsOptions.testOrchestrationOptions) { + testOrchOptions = bsOptions.testOrchestrationOptions; + this.logger.debug('[constructor] Found testOrchestrationOptions in browserstack plugin config'); + } + } + + return testOrchOptions; + } + + /** + * Extract build details from config + */ + _extractBuildDetails(config) { + try { + const bsOptions = config['@nightwatch/browserstack']; + const bstackOptions = config.desiredCapabilities?.['bstack:options']; + + this.buildName = bsOptions?.test_observability?.buildName || + bstackOptions?.buildName || + path.basename(process.cwd()); + + this.projectName = bsOptions?.test_observability?.projectName || + bstackOptions?.projectName || + ''; + + this.buildIdentifier = bsOptions?.test_observability?.buildIdentifier || + bstackOptions?.buildIdentifier || + ''; + + this.logger.debug(`[_extractBuildDetails] Extracted - projectName: ${this.projectName}, buildName: ${this.buildName}, buildIdentifier: ${this.buildIdentifier}`); + } catch (e) { + this.logger.error(`[_extractBuildDetails] ${e}`); + } + } + + /** + * Get or create an instance of OrchestrationUtils + */ + static getInstance(config) { + if (!OrchestrationUtils._instance && config) { + OrchestrationUtils._instance = new OrchestrationUtils(config); + } + return OrchestrationUtils._instance; + } + + /** + * Get orchestration data from config + */ + static getOrchestrationData(config) { + const orchestrationData = config.testOrchestrationOptions || + config['@nightwatch/browserstack']?.testOrchestrationOptions || + {}; + const result = {}; + + Object.entries(orchestrationData).forEach(([key, value]) => { + if (ALLOWED_ORCHESTRATION_KEYS.includes(key)) { + result[key] = value; + } + }); + + return result; + } + + /** + * Check if the abort build file exists + */ + static checkAbortBuildFileExists() { + const buildUuid = process.env.BS_TESTOPS_BUILD_HASHED_ID; + const filePath = path.join(tmpdir(), `abort_build_${buildUuid}`); + return fs.existsSync(filePath); + } + + /** + * Write failure to file + */ + static writeFailureToFile(testName) { + const buildUuid = process.env.BS_TESTOPS_BUILD_HASHED_ID; + const failedTestsFile = path.join(tmpdir(), `failed_tests_${buildUuid}.txt`); + + fs.appendFileSync(failedTestsFile, `${testName}\n`); + } + + /** + * Get run smart selection setting + */ + getRunSmartSelection() { + return this.runSmartSelection; + } + + /** + * Get smart selection mode + */ + getSmartSelectionMode() { + return this.smartSelectionMode; + } + + /** + * Get smart selection source + */ + getSmartSelectionSource() { + return this.smartSelectionSource; + } + + /** + * Get project name + */ + getProjectName() { + return this.projectName; + } + + /** + * Get build name + */ + getBuildName() { + return this.buildName; + } + + /** + * Get build identifier + */ + getBuildIdentifier() { + return this.buildIdentifier; + } + + /** + * Set build details + */ + setBuildDetails(projectName, buildName, buildIdentifier) { + this.projectName = projectName; + this.buildName = buildName; + this.buildIdentifier = buildIdentifier; + this.logger.debug(`[setBuildDetails] Set - projectName: ${this.projectName}, buildName: ${this.buildName}, buildIdentifier: ${this.buildIdentifier}`); + } + + /** + * Set run smart selection + */ + _setRunSmartSelection(enabled, mode, source = null) { + try { + this.runSmartSelection = Boolean(enabled); + this.smartSelectionMode = mode; + + // Log the configuration for debugging + this.logger.debug(`Setting runSmartSelection: enabled=${this.runSmartSelection}, mode=${this.smartSelectionMode}`); + + // Normalize source to always be a list of paths + if (source === null) { + this.smartSelectionSource = None; + } else if (Array.isArray(source)) { + this.smartSelectionSource = source; + } else if(typeof source === 'string' && source.endsWith('.json')) { + this.smartSelectionSource = this._loadSourceFromFile(source) || []; + } + + this._setTestOrdering(); + } catch (e) { + this.logger.error(`[_setRunSmartSelection] ${e}`); + } + } + + _loadSourceFromFile(filePath) { + /** + * Parse JSON source configuration file and format it for smart selection. + * + * @param {string} filePath - Path to the JSON configuration file + * @returns {Array} Formatted list of repository configurations + */ + if (!fs.existsSync(filePath)) { + this.logger.error(`Source file '${filePath}' does not exist.`); + return []; + } + + let data = null; + try { + const fileContent = fs.readFileSync(filePath, 'utf8'); + data = JSON.parse(fileContent); + } catch (error) { + this.logger.error(`Error parsing JSON from source file '${filePath}': ${error.message}`); + return []; + } + + // Cache feature branch mappings from env to avoid repeated parsing + let featureBranchEnvMap = null; + + const loadFeatureBranchMaps = () => { + let envMap = {}; + + try { + const envVar = process.env.BROWSERSTACK_ORCHESTRATION_SMART_SELECTION_FEATURE_BRANCHES || ''; + + if (envVar.startsWith('{') && envVar.endsWith('}')) { + envMap = JSON.parse(envVar); + } else { + // Parse comma-separated key:value pairs + envMap = envVar.split(',') + .filter(item => item.includes(':')) + .reduce((acc, item) => { + const [key, value] = item.split(':'); + if (key && value) { + acc[key.trim()] = value.trim(); + } + return acc; + }, {}); + } + } catch (error) { + this.logger.error(`Error parsing feature branch mappings: ${error.message}`); + } + + this.logger.debug(`Feature branch mappings from env: ${JSON.stringify(envMap)}`); + return envMap; + }; + + if (featureBranchEnvMap === null) { + featureBranchEnvMap = loadFeatureBranchMaps(); + } + + const getFeatureBranch = (name, repoInfo) => { + // 1. Check in environment variable map + if (featureBranchEnvMap[name]) { + return featureBranchEnvMap[name]; + } + // 2. Check in repo_info + if (repoInfo.featureBranch) { + return repoInfo.featureBranch; + } + return null; + }; + + if (typeof data === 'object' && data !== null && !Array.isArray(data)) { + const formattedData = []; + const namePattern = /^[A-Z0-9_]+$/; + + for (const [name, repoInfo] of Object.entries(data)) { + if (typeof repoInfo !== 'object' || repoInfo === null) { + continue; + } + + if (!repoInfo.url) { + this.logger.warn(`Repository URL is missing for source '${name}': ${JSON.stringify(repoInfo)}`); + continue; + } + + // Validate name + if (!namePattern.test(name)) { + this.logger.warn(`Invalid source identifier format for '${name}': ${JSON.stringify(repoInfo)}`); + continue; + } + + // Validate length + if (name.length > 30 || name.length < 1) { + this.logger.warn(`Source identifier '${name}' must have a length between 1 and 30 characters.`); + continue; + } + + const repoInfoCopy = { ...repoInfo }; + repoInfoCopy.name = name; + repoInfoCopy.featureBranch = getFeatureBranch(name, repoInfo); + + if (!repoInfoCopy.featureBranch) { + this.logger.warn(`Feature branch not specified for source '${name}': ${JSON.stringify(repoInfo)}`); + continue; + } + + if (repoInfoCopy.baseBranch && repoInfoCopy.baseBranch === repoInfoCopy.featureBranch) { + this.logger.warn(`Feature branch and base branch cannot be the same for source '${name}': ${JSON.stringify(repoInfo)}`); + continue; + } + + formattedData.push(repoInfoCopy); + } + + return formattedData; + } + + return Array.isArray(data) ? data : []; + } + + /** + * Set test ordering based on priorities + */ + _setTestOrdering() { + if (this.runSmartSelection) { // Highest priority + this.testOrdering.enable(RUN_SMART_SELECTION); + } else { + this.testOrdering.disable(); + } + } + + /** + * Check if test ordering is enabled + */ + testOrderingEnabled() { + return this.testOrdering.getEnabled(); + } + + /** + * Get test ordering name + */ + getTestOrderingName() { + if (this.testOrdering.getEnabled()) { + return this.testOrdering.getName(); + } + return null; + } + + /** + * Get test orchestration metadata + */ + getTestOrchestrationMetadata() { + const data = { + 'run_smart_selection': { + 'enabled': this.getRunSmartSelection(), + 'mode': this.getSmartSelectionMode(), + 'source': this.getSmartSelectionSource() + } + }; + return data; + } + + /** + * Get build start data + */ + getBuildStartData(config) { + const testOrchestrationData = {}; + + testOrchestrationData['run_smart_selection'] = { + 'enabled': this.getRunSmartSelection(), + 'mode': this.getSmartSelectionMode() + // Not sending "source" to TH builds + }; + + return testOrchestrationData; + } + + /** + * Collects build data by making a call to the collect-build-data endpoint + */ + async collectBuildData(config) { + const buildUuid = process.env.BS_TESTOPS_BUILD_HASHED_ID; + this.logger.debug(`[collectBuildData] Collecting build data for build UUID: ${buildUuid}`); + + try { + const endpoint = `testorchestration/api/v1/builds/${buildUuid}/collect-build-data`; + + const payload = { + projectName: this.getProjectName(), + buildName: this.getBuildName(), + buildRunIdentifier: process.env.BROWSERSTACK_BUILD_RUN_IDENTIFIER || '', + nodeIndex: parseInt(process.env.BROWSERSTACK_NODE_INDEX || '0', 10), + totalNodes: parseInt(process.env.BROWSERSTACK_TOTAL_NODE_COUNT || '1', 10), + hostInfo: getHostInfo(), + }; + + this.logger.debug(`[collectBuildData] Sending build data payload: ${JSON.stringify(payload)}`); + + const response = await RequestUtils.postCollectBuildData(endpoint, payload); + + if (response) { + this.logger.debug(`[collectBuildData] Build data collection response: ${JSON.stringify(response)}`); + return response; + } else { + this.logger.error(`[collectBuildData] Failed to collect build data for build UUID: ${buildUuid}`); + return null; + } + } catch (e) { + this.logger.error(`[collectBuildData] Exception in collecting build data for build UUID ${buildUuid}: ${e}`); + return null; + } + } +} + +module.exports = OrchestrationUtils; \ No newline at end of file diff --git a/src/testorchestration/requestUtils.js b/src/testorchestration/requestUtils.js new file mode 100644 index 0000000..9b87e06 --- /dev/null +++ b/src/testorchestration/requestUtils.js @@ -0,0 +1,119 @@ +const {makeRequest} = require('../utils/requestHelper'); +const Logger = require('../utils/logger'); + +/** + * Utility class for making API requests to the BrowserStack orchestration API + */ +class RequestUtils { + /** + * Makes a request to the collect build data endpoint + */ + static async postCollectBuildData(reqEndpoint, data) { + Logger.debug('Processing Request for postCollectBuildData'); + return RequestUtils.makeOrchestrationRequest('POST', reqEndpoint, { data }); + } + + /** + * Makes a request to the test orchestration split tests endpoint + */ + static async testOrchestrationSplitTests(reqEndpoint, data) { + Logger.debug('Processing Request for testOrchestrationSplitTests'); + return RequestUtils.makeOrchestrationRequest('POST', reqEndpoint, { data }); + } + + /** + * Gets ordered tests from the test orchestration + */ + static async getTestOrchestrationOrderedTests(reqEndpoint, data) { + Logger.debug('Processing Request for getTestOrchestrationOrderedTests'); + return RequestUtils.makeOrchestrationRequest('GET', reqEndpoint, {}); + } + + /** + * Makes an orchestration request with the given method and data + */ + static async makeOrchestrationRequest(method, reqEndpoint, options) { + const jwtToken = process.env.BS_TESTOPS_JWT || ''; + + // Validate JWT token + if (!jwtToken) { + Logger.error('BROWSERSTACK_TESTHUB_JWT environment variable is not set. This is required for test orchestration.'); + return null; + } + + const config = { + headers: { + 'authorization': `Bearer ${jwtToken}`, + 'Content-Type': 'application/json', + 'Accept': 'application/json' + }, + timeout: 30000, // 30 second timeout + retry: 3 // Retry failed requests + }; + + if (options.extraHeaders) { + Object.assign(config.headers, options.extraHeaders); + } + + const ORCHESTRATION_API_URL = 'https://collector-observability.browserstack.com'; + const fullUrl = `${ORCHESTRATION_API_URL}/${reqEndpoint}`; + + try { + Logger.debug(`Orchestration request: ${method} ${fullUrl}`); + Logger.debug(`Request payload size: ${options.data ? JSON.stringify(options.data).length : 0} bytes`); + + const response = await makeRequest(method, reqEndpoint, options.data, config, ORCHESTRATION_API_URL, false); + + Logger.debug(`Orchestration request completed successfully: ${reqEndpoint}`); + + let responseObj = {}; + try { + responseObj = response.data || response; + } catch (e) { + Logger.debug(`Failed to parse JSON response: ${e}`); + } + + if (responseObj && response.headers) { + responseObj.next_poll_time = response.headers['next_poll_time'] || String(Date.now()); + responseObj.status = response.status; + } + + return responseObj; + } catch (e) { + // Enhanced error logging for better diagnosis + if (e.code === 'EPIPE') { + Logger.error(`❌ Network connection error (EPIPE) when calling orchestration API`); + Logger.error(` URL: ${fullUrl}`); + Logger.error(` This usually indicates a network connectivity issue or the connection was closed unexpectedly`); + Logger.error(` Please check your internet connection and BrowserStack service status`); + } else if (e.code === 'ECONNREFUSED') { + Logger.error(`❌ Connection refused when calling orchestration API`); + Logger.error(` URL: ${fullUrl}`); + Logger.error(` The BrowserStack orchestration service may be unavailable`); + } else if (e.code === 'ENOTFOUND') { + Logger.error(`❌ DNS resolution failed for orchestration API`); + Logger.error(` URL: ${fullUrl}`); + Logger.error(` Please check your DNS settings and network connectivity`); + } else if (e.response && e.response.status === 401) { + Logger.error(`❌ Authentication failed for orchestration API`); + Logger.error(` Please check your BROWSERSTACK_TESTHUB_JWT token`); + } else if (e.response && e.response.status === 403) { + Logger.error(`❌ Access forbidden for orchestration API`); + Logger.error(` Your account may not have access to test orchestration features`); + } else { + Logger.error(`❌ Orchestration request failed: ${e.message || e} - ${reqEndpoint}`); + if (e.response) { + Logger.error(` Response status: ${e.response.status}`); + Logger.error(` Response data: ${JSON.stringify(e.response.data)}`); + } + } + + // Log stack trace for debugging + Logger.debug(`Error stack trace: ${e.stack}`); + + return null; + } + } +} + +module.exports = RequestUtils; \ No newline at end of file diff --git a/src/testorchestration/testOrchestrationHandler.js b/src/testorchestration/testOrchestrationHandler.js new file mode 100644 index 0000000..d5a4c56 --- /dev/null +++ b/src/testorchestration/testOrchestrationHandler.js @@ -0,0 +1,139 @@ +const path = require('path'); +const {performance} = require('perf_hooks'); +const Logger = require('../utils/logger'); +const TestOrderingServer = require('./testOrderingServer'); +const OrchestrationUtils = require('./orchestrationUtils'); + +/** + * Handles test orchestration operations for Nightwatch + */ +class TestOrchestrationHandler { + static _instance = null; + + constructor(config) { + this.config = config; + this.logger = Logger; + this.testOrderingServerHandler = new TestOrderingServer(this.config, this.logger); + this.orchestrationUtils = new OrchestrationUtils(config); + this.orderingInstrumentationData = {}; + this.testOrderingApplied = false; + + // Check if test orchestration is enabled + this.isTestOrderingEnabled = this._checkTestOrderingEnabled(); + } + + /** + * Get or create an instance of TestOrchestrationHandler + */ + static getInstance(config) { + if (TestOrchestrationHandler._instance === null && config !== null) { + TestOrchestrationHandler._instance = new TestOrchestrationHandler(config); + } + return TestOrchestrationHandler._instance; + } + + /** + * Checks if test ordering is enabled + */ + _checkTestOrderingEnabled() { + // Extract test orchestration options from config + const testOrchOptions = this._getTestOrchestrationOptions(); + const runSmartSelection = testOrchOptions?.runSmartSelection; + + return Boolean(runSmartSelection?.enabled); + } + + /** + * Extract test orchestration options from various config paths + */ + _getTestOrchestrationOptions() { + // Check direct config path + if (this.config.testOrchestrationOptions) { + return this.config.testOrchestrationOptions; + } + + // Check browserstack plugin options + const bsOptions = this.config['@nightwatch/browserstack']; + if (bsOptions?.testOrchestrationOptions) { + return bsOptions.testOrchestrationOptions; + } + + return {}; + } + + /** + * Checks if test ordering is enabled + */ + testOrderingEnabled() { + return this.isTestOrderingEnabled; + } + + /** + * Checks if test ordering is applied + */ + isTestOrderingApplied() { + return this.testOrderingApplied; + } + + /** + * Sets whether test ordering is applied + */ + setTestOrderingApplied(orderingApplied) { + this.testOrderingApplied = orderingApplied; + this.addToOrderingInstrumentationData('applied', orderingApplied); + } + + /** + * Reorders test files based on the orchestration strategy + */ + async reorderTestFiles(testFiles) { + try { + if (!testFiles || testFiles.length === 0) { + this.logger.debug('[reorderTestFiles] No test files provided for ordering.'); + return null; + } + + const orchestrationStrategy = this.orchestrationUtils.getTestOrderingName(); + const orchestrationMetadata = this.orchestrationUtils.getTestOrchestrationMetadata(); + + if (orchestrationStrategy === null) { + this.logger.error('Orchestration strategy is None. Cannot proceed with test orchestration session.'); + return null; + } + + this.logger.info(`Reordering test files with orchestration strategy: ${orchestrationStrategy}`); + + // Use server handler approach for test file orchestration + this.logger.debug('Using SDK flow for test files orchestration.'); + await this.testOrderingServerHandler.splitTests(testFiles, orchestrationStrategy, orchestrationMetadata); + const orderedTestFiles = await this.testOrderingServerHandler.getOrderedTestFiles() || []; + + this.addToOrderingInstrumentationData('uploadedTestFilesCount', testFiles.length); + this.addToOrderingInstrumentationData('nodeIndex', parseInt(process.env.BROWSERSTACK_NODE_INDEX || '0')); + this.addToOrderingInstrumentationData('totalNodes', parseInt(process.env.BROWSERSTACK_NODE_COUNT || '1')); + this.addToOrderingInstrumentationData('downloadedTestFilesCount', orderedTestFiles.length); + this.addToOrderingInstrumentationData('splitTestsAPICallCount', this.testOrderingServerHandler.getSplitTestsApiCallCount()); + + return orderedTestFiles; + } catch (e) { + this.logger.debug(`[reorderTestFiles] Error in ordering test classes: ${e}`); + } + return null; + } + + /** + * Adds data to the ordering instrumentation data + */ + addToOrderingInstrumentationData(key, value) { + this.orderingInstrumentationData[key] = value; + } + + /** + * Gets the ordering instrumentation data + */ + getOrderingInstrumentationData() { + return this.orderingInstrumentationData; + } +} + +module.exports = TestOrchestrationHandler; \ No newline at end of file diff --git a/src/testorchestration/testOrchestrationIntegration.js b/src/testorchestration/testOrchestrationIntegration.js new file mode 100644 index 0000000..e215694 --- /dev/null +++ b/src/testorchestration/testOrchestrationIntegration.js @@ -0,0 +1,94 @@ +const { applyOrchestrationIfEnabled } = require('./applyOrchestration'); +const OrchestrationUtils = require('./orchestrationUtils'); +const Logger = require('../utils/logger'); + +/** + * Test Orchestration integration for Nightwatch + * This module provides functionality to apply test orchestration before test execution + */ +class TestOrchestrationIntegration { + static _instance = null; + + constructor() { + this.orchestrationUtils = null; + } + + static getInstance() { + if (!TestOrchestrationIntegration._instance) { + TestOrchestrationIntegration._instance = new TestOrchestrationIntegration(); + } + return TestOrchestrationIntegration._instance; + } + + /** + * Initialize test orchestration with the given settings + */ + configure(settings) { + try { + this.orchestrationUtils = OrchestrationUtils.getInstance(settings); + if (this.orchestrationUtils && this.orchestrationUtils.testOrderingEnabled()) { + Logger.info('Test orchestration is configured and enabled.'); + } else { + Logger.debug('Test orchestration is not enabled.'); + } + } catch (error) { + Logger.error(`Error configuring test orchestration: ${error}`); + } + } + + /** + * Apply test orchestration to specs if enabled + */ + async applyOrchestration(specs, settings) { + if (!specs || !Array.isArray(specs) || specs.length === 0) { + Logger.debug('No specs provided for test orchestration.'); + return specs; + } + + try { + Logger.info('Applying test orchestration...'); + const orderedSpecs = await applyOrchestrationIfEnabled(specs, settings); + + if (orderedSpecs && orderedSpecs.length > 0 && orderedSpecs !== specs) { + Logger.info(`Test orchestration applied. Spec order changed from [${specs.join(', ')}] to [${orderedSpecs.join(', ')}]`); + return orderedSpecs; + } else { + Logger.info('Test orchestration completed. No changes to spec order.'); + return specs; + } + } catch (error) { + Logger.error(`Error applying test orchestration: ${error}`); + return specs; + } + } + + /** + * Collect build data after test execution + */ + async collectBuildData(settings) { + try { + if (this.orchestrationUtils) { + Logger.info('Collecting build data...'); + const response = await this.orchestrationUtils.collectBuildData(settings); + if (response) { + Logger.debug('Build data collection completed successfully.'); + } else { + Logger.debug('Build data collection returned no response.'); + } + return response; + } + } catch (error) { + Logger.error(`Error collecting build data: ${error}`); + } + return null; + } + + /** + * Check if test orchestration is enabled + */ + isEnabled() { + return this.orchestrationUtils && this.orchestrationUtils.testOrderingEnabled(); + } +} + +module.exports = TestOrchestrationIntegration; \ No newline at end of file diff --git a/src/testorchestration/testOrderingServer.js b/src/testorchestration/testOrderingServer.js new file mode 100644 index 0000000..0a6f2fe --- /dev/null +++ b/src/testorchestration/testOrderingServer.js @@ -0,0 +1,200 @@ +const path = require('path'); +const Logger = require('../utils/logger'); +const { getHostInfo, getGitMetadataForAiSelection } = require('./helpers'); +const RequestUtils = require('./requestUtils'); + +const ORCHESTRATION_API_URL = 'https://collector-observability.browserstack.com'; + +/** + * Handles test ordering orchestration with the BrowserStack server. + */ +class TestOrderingServer { + /** + * @param config Test orchestration config + * @param logger Logger instance + */ + constructor(config, logger) { + this.config = config; + this.logger = logger || Logger; + this.ORDERING_ENDPOINT = 'testorchestration/api/v1/split-tests'; + this.requestData = null; + this.defaultTimeout = 60; + this.defaultTimeoutInterval = 5; + this.splitTestsApiCallCount = 0; + } + + /** + * Initiates the split tests request and stores the response data for polling. + */ + async splitTests(testFiles, orchestrationStrategy, orchestrationMetadata = {}) { + this.logger.debug(`[splitTests] Initiating split tests with strategy: ${orchestrationStrategy}`); + try { + let prDetails = []; + const source = orchestrationMetadata['run_smart_selection']?.source; + const isGithubAppApproach = Array.isArray(source) && source.length > 0 && source.every(src => src && typeof src === 'object' && !Array.isArray(src)); + if (orchestrationMetadata['run_smart_selection']?.enabled && !isGithubAppApproach) { + const multiRepoSource = orchestrationMetadata['run_smart_selection']?.source; + prDetails = getGitMetadataForAiSelection(multiRepoSource); + } + + const payload = { + tests: testFiles.map(f => ({ filePath: f })), + orchestrationStrategy, + orchestrationMetadata, + nodeIndex: parseInt(process.env.BROWSERSTACK_NODE_INDEX || '0'), + totalNodes: parseInt(process.env.BROWSERSTACK_TOTAL_NODE_COUNT || '1'), + projectName: this._getProjectName(), + buildName: this._getBuildName(), + buildRunIdentifier: process.env.BROWSERSTACK_BUILD_RUN_IDENTIFIER || '', + hostInfo: getHostInfo(), + prDetails + }; + this.logger.debug(`[splitTests] Split tests payload: ${JSON.stringify(payload)}`); + const response = await RequestUtils.testOrchestrationSplitTests(this.ORDERING_ENDPOINT, payload); + if (response) { + this.requestData = this._processSplitTestsResponse(response); + this.logger.debug(`[splitTests] Split tests response: ${JSON.stringify(this.requestData)}`); + } else { + this.logger.error('[splitTests] Failed to get split tests response.'); + } + } catch (e) { + this.logger.error(`[splitTests] Exception in sending test files:: ${e}`); + } + } + + /** + * Get project name from configuration + */ + _getProjectName() { + const bsOptions = this.config['@nightwatch/browserstack']; + return bsOptions?.test_observability?.projectName || + this.config.desiredCapabilities?.['bstack:options']?.projectName || + ''; + } + + /** + * Get build name from configuration + */ + _getBuildName() { + const bsOptions = this.config['@nightwatch/browserstack']; + return bsOptions?.test_observability?.buildName || + this.config.desiredCapabilities?.['bstack:options']?.buildName || + path.basename(process.cwd()); + } + + /** + * Processes the split tests API response and extracts relevant fields. + */ + _processSplitTestsResponse(response) { + const responseData = {}; + responseData.timeout = response.timeout !== undefined ? response.timeout : this.defaultTimeout; + responseData.timeoutInterval = response.timeoutInterval !== undefined ? response.timeoutInterval : this.defaultTimeoutInterval; + + const resultUrl = response.resultUrl; + const timeoutUrl = response.timeoutUrl; + + // Remove the API prefix if present + if (resultUrl) { + responseData.resultUrl = resultUrl.includes(`${ORCHESTRATION_API_URL}/`) + ? resultUrl.split(`${ORCHESTRATION_API_URL}/`)[1] + : resultUrl; + } else { + responseData.resultUrl = null; + } + + if (timeoutUrl) { + responseData.timeoutUrl = timeoutUrl.includes(`${ORCHESTRATION_API_URL}/`) + ? timeoutUrl.split(`${ORCHESTRATION_API_URL}/`)[1] + : timeoutUrl; + } else { + responseData.timeoutUrl = null; + } + + if ( + response.timeout === undefined || + response.timeoutInterval === undefined || + response.timeoutUrl === undefined || + response.resultUrl === undefined + ) { + this.logger.debug('[process_split_tests_response] Received null value(s) for some attributes in split tests API response'); + } + + return responseData; + } + + /** + * Retrieves the ordered test files from the orchestration server + */ + async getOrderedTestFiles() { + if (!this.requestData) { + this.logger.error('[getOrderedTestFiles] No request data available to fetch ordered test files.'); + return null; + } + + let testFilesJsonList = null; + let testFiles = []; + const startTimeMillis = Date.now(); + const timeoutInterval = parseInt(String(this.requestData.timeoutInterval || this.defaultTimeoutInterval), 10); + const timeoutMillis = parseInt(String(this.requestData.timeout || this.defaultTimeout), 10) * 1000; + const timeoutUrl = this.requestData.timeoutUrl; + const resultUrl = this.requestData.resultUrl; + + if (resultUrl === null && timeoutUrl === null) { + return null; + } + + try { + // Poll resultUrl until timeout or until tests are available + while (resultUrl && (Date.now() - startTimeMillis) < timeoutMillis) { + const response = await RequestUtils.getTestOrchestrationOrderedTests(resultUrl, {}); + if (response && response.tests) { + testFilesJsonList = response.tests; + } + this.splitTestsApiCallCount++; + if (testFilesJsonList) { + break; + } + await new Promise(resolve => setTimeout(resolve, timeoutInterval * 1000)); + this.logger.debug(`[getOrderedTestFiles] Fetching ordered tests from result URL after waiting for ${timeoutInterval} seconds.`); + } + + // If still not available, try timeoutUrl + if (timeoutUrl && !testFilesJsonList) { + this.logger.debug('[getOrderedTestFiles] Fetching ordered tests from timeout URL'); + const response = await RequestUtils.getTestOrchestrationOrderedTests(timeoutUrl, {}); + if (response && response.tests) { + testFilesJsonList = response.tests; + } + } + + // Extract file paths + if (testFilesJsonList && testFilesJsonList.length > 0) { + for (const testData of testFilesJsonList) { + const filePath = testData.filePath; + if (filePath) { + testFiles.push(filePath); + } + } + } + + if (!testFilesJsonList) { + return null; + } + + this.logger.debug(`[getOrderedTestFiles] Ordered test files received: ${JSON.stringify(testFiles)}`); + return testFiles; + } catch (e) { + this.logger.error(`[getOrderedTestFiles] Exception in fetching ordered test files: ${e}`); + return null; + } + } + + /** + * Returns the count of split tests API calls made. + */ + getSplitTestsApiCallCount() { + return this.splitTestsApiCallCount; + } +} + +module.exports = TestOrderingServer; \ No newline at end of file diff --git a/src/utils/helper.js b/src/utils/helper.js index fbe7128..3442e6b 100644 --- a/src/utils/helper.js +++ b/src/utils/helper.js @@ -849,3 +849,104 @@ exports.truncateString = (field, truncateSizeInBytes) => { return field; }; + +// Helper function to check if a pattern contains glob characters +exports.isGlobPattern = (pattern) => { + return pattern.includes('*') || pattern.includes('?') || pattern.includes('['); +}; + +// Helper function to recursively find files matching a pattern +exports.findFilesRecursively = (dir, pattern) => { + const files = []; + try { + if (!fs.existsSync(dir)) { + return files; + } + + const entries = fs.readdirSync(dir, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + + if (entry.isDirectory()) { + // Recursively search subdirectories + files.push(...exports.findFilesRecursively(fullPath, pattern)); + } else if (entry.isFile() && entry.name.endsWith('.js')) { + const relativePath = path.relative(process.cwd(), fullPath); + + // Simple pattern matching for common glob patterns + if (pattern.includes('**')) { + // Match any nested structure + files.push(relativePath); + } else if (pattern.includes('*')) { + // Simple wildcard matching + const regexPattern = pattern.replace(/\*/g, '.*').replace(/\?/g, '.'); + const regex = new RegExp(regexPattern); + if (regex.test(relativePath)) { + files.push(relativePath); + } + } else { + files.push(relativePath); + } + } + } + } catch (err) { + Logger.debug(`Error reading directory ${dir}: ${err.message}`); + } + + return files; +}; + +// Helper function to resolve and collect test files from a path/pattern +exports.collectTestFiles = (testPath, source) => { + try { + Logger.debug(`Collecting test files from ${source}: ${testPath}`); + const resolvedPath = path.resolve(testPath); + + // Check if it's a glob pattern + if (exports.isGlobPattern(testPath)) { + Logger.debug(`Processing glob pattern: ${testPath}`); + + // Handle common glob patterns + if (testPath.includes('**')) { + // For patterns like "tests/**/*.js", start from the base directory + const basePath = testPath.split('**')[0].replace(/[\/\\]$/, ''); + const baseDir = basePath || '.'; + const files = exports.findFilesRecursively(baseDir, testPath); + Logger.debug(`Found ${files.length} files from glob pattern: ${testPath}`); + return files; + } else { + // For simpler patterns, try to resolve the directory part + const dirPart = path.dirname(testPath); + if (fs.existsSync(dirPart)) { + const files = exports.findFilesRecursively(dirPart, testPath); + Logger.debug(`Found ${files.length} files from glob pattern: ${testPath}`); + return files; + } + } + return []; + } + + // Check if path exists + if (fs.existsSync(resolvedPath)) { + const stats = fs.statSync(resolvedPath); + + if (stats.isFile() && testPath.endsWith('.js')) { + const relativePath = path.relative(process.cwd(), resolvedPath); + Logger.debug(`Found test file: ${relativePath}`); + return [relativePath]; + } else if (stats.isDirectory()) { + const files = fs.readdirSync(resolvedPath, { recursive: true }) + .filter(file => typeof file === 'string' && file.endsWith('.js')) + .map(file => path.relative(process.cwd(), path.join(resolvedPath, file))); + Logger.debug(`Found ${files.length} test files in directory: ${testPath}`); + return files; + } + } else { + Logger.debug(`Path does not exist: ${testPath}`); + } + } catch (err) { + Logger.debug(`Could not collect test files from ${testPath} (${source}): ${err.message}`); + } + return []; +}; diff --git a/src/utils/requestHelper.js b/src/utils/requestHelper.js index 7900d3c..9ada379 100644 --- a/src/utils/requestHelper.js +++ b/src/utils/requestHelper.js @@ -34,12 +34,12 @@ exports.makeRequest = (type, url, data, config, requestUrl=API_URL, jsonResponse json: config.headers['Content-Type'] === 'application/json', agent }; - + const acceptedStatusCodes = [200, 201, 202]; return new Promise((resolve, reject) => { request(options, function callback(error, response, body) { if (error) { reject(error); - } else if (response.statusCode !== 200 && response.statusCode !== 201) { + } else if (!acceptedStatusCodes.includes(response.statusCode)) { reject(response && response.body ? response.body : `Received response from BrowserStack Server with status : ${response.statusCode}`); } else { if (jsonResponse) { From fd1a87e5702edcf2f3a7d7d184bef0749c645dff Mon Sep 17 00:00:00 2001 From: harshit-bstack Date: Wed, 15 Oct 2025 15:16:09 +0530 Subject: [PATCH 02/27] feat: Enhance test file collection and orchestration logic with improved glob pattern matching --- nightwatch/globals.js | 53 ++++--- src/testorchestration/testOrderingServer.js | 2 +- src/utils/helper.js | 154 +++++++++++++++----- 3 files changed, 151 insertions(+), 58 deletions(-) diff --git a/nightwatch/globals.js b/nightwatch/globals.js index da6a5ee..d8a3ebc 100644 --- a/nightwatch/globals.js +++ b/nightwatch/globals.js @@ -339,9 +339,20 @@ module.exports = { // Check if we have test files to reorder from various sources let allTestFiles = []; - if (settings.src_folders && Array.isArray(settings.src_folders) && settings.src_folders.length > 0) { + // Checking either for Feature Path or src_folders, feature path take priority + if(helper.isCucumberTestSuite(settings) && settings?.test_runner?.options?.feature_path){ + Logger.debug('Getting test files from feature_path configuration...'); + if(Array.isArray(settings.test_runner.options.feature_path)){ + settings.test_runner.options.feature_path.forEach(featurePath => { + const files = helper.collectTestFiles(featurePath, 'feature_path config'); + allTestFiles = allTestFiles.concat(files); + }); + } else if (typeof settings.test_runner.options.feature_path === 'string'){ + const files = helper.collectTestFiles(settings.test_runner.options.feature_path, 'feature_path config'); + allTestFiles = allTestFiles.concat(files); + } + }else if (settings.src_folders && Array.isArray(settings.src_folders) && settings.src_folders.length > 0) { Logger.debug('Getting test files from src_folders configuration...'); - settings.src_folders.forEach(folder => { const files = helper.collectTestFiles(folder, 'src_folders config'); allTestFiles = allTestFiles.concat(files); @@ -362,32 +373,36 @@ module.exports = { try { const orderedFiles = await orchestrationIntegration.applyOrchestration(allTestFiles, settings); if (orderedFiles && orderedFiles.length > 0) { - Logger.info(`✅ Test files reordered by orchestration: ${orderedFiles.length} files`); - Logger.info(`📋 Split test API called successfully - tests will run in optimized order`); - - Logger.info(`🔄 Test orchestration recommended order change:`); - Logger.info(` Original: ${allTestFiles.join(', ')}`); - Logger.info(` Optimized: ${orderedFiles.join(', ')}`); + Logger.info(`Test files reordered by orchestration: ${orderedFiles.length} files`); + + Logger.info(`Test orchestration recommended order change:`); + Logger.info(`Original: ${allTestFiles.join(', ')}`); + Logger.info(`Optimized: ${orderedFiles.join(', ')}`); try { - settings.src_folders = orderedFiles; - for (const envName in testEnvSettings) { - testEnvSettings[envName].src_folders = orderedFiles; - testEnvSettings[envName].test_runner.src_folders = orderedFiles; - } - if (settings.test_runner && typeof settings.test_runner === 'object' && !Array.isArray(settings.test_runner)) { - settings.test_runner.src_folders = orderedFiles; + if(helper.isCucumberTestSuite(settings) && settings?.test_runner?.options?.feature_path){ + // For cucumber, we override the feature_path option with ordered files + settings.test_runner.options['feature_path'] = orderedFiles; + }else{ + settings.src_folders = orderedFiles; + for (const envName in testEnvSettings) { + testEnvSettings[envName].src_folders = orderedFiles; + testEnvSettings[envName].test_runner.src_folders = orderedFiles; + } + if (settings.test_runner && typeof settings.test_runner === 'object' && !Array.isArray(settings.test_runner)) { + settings.test_runner.src_folders = orderedFiles; + } } } catch (reorderError) { - Logger.error(`❌ Runtime reordering failed: ${reorderError.message}`); - Logger.info(` Falling back to original order for current execution.`); + Logger.error(`Runtime reordering failed: ${reorderError.message}`); + Logger.info(`Falling back to original order for current execution.`); } } else { - Logger.info('📋 Split test API called - no reordering available'); + Logger.info('Split test API called - no reordering available'); } } catch (error) { - Logger.error(`❌ Error applying test orchestration: ${error}`); + Logger.error(`Error applying test orchestration: ${error}`); } } else { Logger.debug('No test files found for orchestration - skipping split test API call'); diff --git a/src/testorchestration/testOrderingServer.js b/src/testorchestration/testOrderingServer.js index 0a6f2fe..e468433 100644 --- a/src/testorchestration/testOrderingServer.js +++ b/src/testorchestration/testOrderingServer.js @@ -159,7 +159,7 @@ class TestOrderingServer { } // If still not available, try timeoutUrl - if (timeoutUrl && !testFilesJsonList) { + if (timeoutUrl && (!testFilesJsonList || testFilesJsonList.length === 0)) { this.logger.debug('[getOrderedTestFiles] Fetching ordered tests from timeout URL'); const response = await RequestUtils.getTestOrchestrationOrderedTests(timeoutUrl, {}); if (response && response.tests) { diff --git a/src/utils/helper.js b/src/utils/helper.js index 3442e6b..ecdc0af 100644 --- a/src/utils/helper.js +++ b/src/utils/helper.js @@ -871,21 +871,11 @@ exports.findFilesRecursively = (dir, pattern) => { if (entry.isDirectory()) { // Recursively search subdirectories files.push(...exports.findFilesRecursively(fullPath, pattern)); - } else if (entry.isFile() && entry.name.endsWith('.js')) { + } else if (entry.isFile()) { const relativePath = path.relative(process.cwd(), fullPath); - // Simple pattern matching for common glob patterns - if (pattern.includes('**')) { - // Match any nested structure - files.push(relativePath); - } else if (pattern.includes('*')) { - // Simple wildcard matching - const regexPattern = pattern.replace(/\*/g, '.*').replace(/\?/g, '.'); - const regex = new RegExp(regexPattern); - if (regex.test(relativePath)) { - files.push(relativePath); - } - } else { + // Enhanced pattern matching for glob patterns + if (exports.matchesGlobPattern(relativePath, pattern)) { files.push(relativePath); } } @@ -897,48 +887,74 @@ exports.findFilesRecursively = (dir, pattern) => { return files; }; +// Helper function to match a file path against a glob pattern +exports.matchesGlobPattern = (filePath, pattern) => { + // Normalize paths to use forward slashes + const normalizedPath = filePath.replace(/\\/g, '/'); + const normalizedPattern = pattern.replace(/\\/g, '/'); + + // Convert glob pattern to regex step by step + let regexPattern = normalizedPattern; + + // First, handle ** patterns (must be done before single *) + // ** should match zero or more directories + regexPattern = regexPattern.replace(/\*\*/g, '§DOUBLESTAR§'); + + // Escape regex special characters except the placeholders + regexPattern = regexPattern.replace(/[.+^${}()|[\]\\]/g, '\\$&'); + + // Now handle single * and ? patterns + regexPattern = regexPattern.replace(/\*/g, '[^/]*'); // * matches anything except path separators + regexPattern = regexPattern.replace(/\?/g, '[^/]'); // ? matches single character except path separator + + // Finally, replace ** placeholder with regex for any path (including zero directories) + regexPattern = regexPattern.replace(/§DOUBLESTAR§/g, '.*?'); + + // Special case: if pattern ends with /**/* we need to handle direct files in the base directory + // Convert patterns like "dir/**/*" to also match "dir/*" + if (normalizedPattern.includes('/**/')) { + const baseRegex = regexPattern; + const alternativeRegex = regexPattern.replace(/\/\.\*\?\//g, '/'); + regexPattern = `(?:${baseRegex}|${alternativeRegex})`; + } + + // Ensure pattern matches from start to end + regexPattern = '^' + regexPattern + '$'; + + try { + const regex = new RegExp(regexPattern); + return regex.test(normalizedPath); + } catch (err) { + Logger.debug(`Error in glob pattern matching: ${err.message}`); + return false; + } +}; + // Helper function to resolve and collect test files from a path/pattern -exports.collectTestFiles = (testPath, source) => { +exports.collectTestFiles = (testPath, source = 'unknown') => { try { Logger.debug(`Collecting test files from ${source}: ${testPath}`); - const resolvedPath = path.resolve(testPath); // Check if it's a glob pattern if (exports.isGlobPattern(testPath)) { Logger.debug(`Processing glob pattern: ${testPath}`); - - // Handle common glob patterns - if (testPath.includes('**')) { - // For patterns like "tests/**/*.js", start from the base directory - const basePath = testPath.split('**')[0].replace(/[\/\\]$/, ''); - const baseDir = basePath || '.'; - const files = exports.findFilesRecursively(baseDir, testPath); - Logger.debug(`Found ${files.length} files from glob pattern: ${testPath}`); - return files; - } else { - // For simpler patterns, try to resolve the directory part - const dirPart = path.dirname(testPath); - if (fs.existsSync(dirPart)) { - const files = exports.findFilesRecursively(dirPart, testPath); - Logger.debug(`Found ${files.length} files from glob pattern: ${testPath}`); - return files; - } - } - return []; + return exports.expandGlobPattern(testPath); } + // Handle regular path + const resolvedPath = path.resolve(testPath); + // Check if path exists if (fs.existsSync(resolvedPath)) { const stats = fs.statSync(resolvedPath); - if (stats.isFile() && testPath.endsWith('.js')) { + if (stats.isFile()) { const relativePath = path.relative(process.cwd(), resolvedPath); Logger.debug(`Found test file: ${relativePath}`); return [relativePath]; } else if (stats.isDirectory()) { - const files = fs.readdirSync(resolvedPath, { recursive: true }) - .filter(file => typeof file === 'string' && file.endsWith('.js')) - .map(file => path.relative(process.cwd(), path.join(resolvedPath, file))); + // For directories, find all supported test files + const files = exports.findTestFilesInDirectory(resolvedPath); Logger.debug(`Found ${files.length} test files in directory: ${testPath}`); return files; } @@ -950,3 +966,65 @@ exports.collectTestFiles = (testPath, source) => { } return []; }; + +// Helper function to find test files in a directory +exports.findTestFilesInDirectory = (dir) => { + const files = []; + const supportedExtensions = ['.js', '.feature']; + + try { + const entries = fs.readdirSync(dir, { recursive: true }); + + for (const entry of entries) { + if (typeof entry === 'string') { + const fullPath = path.join(dir, entry); + const ext = path.extname(entry); + + if (supportedExtensions.includes(ext) && fs.statSync(fullPath).isFile()) { + const relativePath = path.relative(process.cwd(), fullPath); + files.push(relativePath); + } + } + } + } catch (err) { + Logger.debug(`Error reading directory ${dir}: ${err.message}`); + } + + return files; +}; + +// Helper function to expand glob patterns +exports.expandGlobPattern = (pattern) => { + Logger.debug(`Expanding glob pattern: ${pattern}`); + + // Extract the base directory from the pattern + const parts = pattern.split(/[\/\\]/); + let baseDir = '.'; + let patternStart = 0; + + // Find the first part that contains glob characters + for (let i = 0; i < parts.length; i++) { + if (exports.isGlobPattern(parts[i])) { + patternStart = i; + break; + } + if (i === 0 && parts[i] !== '.') { + baseDir = parts[i]; + } else if (i > 0) { + baseDir = path.join(baseDir, parts[i]); + } + } + + // If baseDir doesn't exist, try current directory + if (!fs.existsSync(baseDir)) { + Logger.debug(`Base directory ${baseDir} doesn't exist, using current directory`); + baseDir = '.'; + } + + Logger.debug(`Base directory: ${baseDir}, Pattern: ${pattern}`); + + const files = exports.findFilesRecursively(baseDir, pattern); + Logger.debug(`Found ${files.length} files matching pattern: ${pattern}`); + + return files; +}; From 7e7615e27d4a7a3b74bfc9dde4e967e6fa9eda79 Mon Sep 17 00:00:00 2001 From: harshit-bstack Date: Wed, 15 Oct 2025 18:36:25 +0530 Subject: [PATCH 03/27] feat: Refactor test orchestration logic to apply specific feature path for cucumber tests --- nightwatch/globals.js | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/nightwatch/globals.js b/nightwatch/globals.js index d8a3ebc..18f0b4e 100644 --- a/nightwatch/globals.js +++ b/nightwatch/globals.js @@ -328,9 +328,7 @@ module.exports = { // Initialize and configure test orchestration try { const orchestrationUtils = OrchestrationUtils.getInstance(settings); - if (orchestrationUtils && orchestrationUtils.testOrderingEnabled()) { - Logger.info('Test orchestration is enabled and configured.'); - + if (orchestrationUtils && orchestrationUtils.testOrderingEnabled()) { // Apply test orchestration to reorder test files before execution const TestOrchestrationIntegration = require('../src/testorchestration/testOrchestrationIntegration'); const orchestrationIntegration = TestOrchestrationIntegration.getInstance(); @@ -382,7 +380,7 @@ module.exports = { try { if(helper.isCucumberTestSuite(settings) && settings?.test_runner?.options?.feature_path){ // For cucumber, we override the feature_path option with ordered files - settings.test_runner.options['feature_path'] = orderedFiles; + settings.test_runner.options['feature_path'] = ["src/functional_tests/features/app_live/dashboard/dashboard_features/dashboard.feature"]; }else{ settings.src_folders = orderedFiles; for (const envName in testEnvSettings) { From 363326589bff30cfaa379bb8c448d147a815113d Mon Sep 17 00:00:00 2001 From: harshit-bstack Date: Wed, 15 Oct 2025 18:42:26 +0530 Subject: [PATCH 04/27] feat: Update cucumber feature path for API tests in Nightwatch globals --- nightwatch/globals.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nightwatch/globals.js b/nightwatch/globals.js index 18f0b4e..8de4d16 100644 --- a/nightwatch/globals.js +++ b/nightwatch/globals.js @@ -380,7 +380,7 @@ module.exports = { try { if(helper.isCucumberTestSuite(settings) && settings?.test_runner?.options?.feature_path){ // For cucumber, we override the feature_path option with ordered files - settings.test_runner.options['feature_path'] = ["src/functional_tests/features/app_live/dashboard/dashboard_features/dashboard.feature"]; + settings.test_runner.options['feature_path'] = ["src/functional_tests/features/app_automate/apis/api.feature"]; }else{ settings.src_folders = orderedFiles; for (const envName in testEnvSettings) { From 3b1ceacef3c4e4745ea46f2e25f52b88018763f5 Mon Sep 17 00:00:00 2001 From: harshit-bstack Date: Wed, 15 Oct 2025 18:53:53 +0530 Subject: [PATCH 05/27] feat: Update cucumber feature path handling to use ordered files --- nightwatch/globals.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nightwatch/globals.js b/nightwatch/globals.js index 8de4d16..9c9a053 100644 --- a/nightwatch/globals.js +++ b/nightwatch/globals.js @@ -380,7 +380,7 @@ module.exports = { try { if(helper.isCucumberTestSuite(settings) && settings?.test_runner?.options?.feature_path){ // For cucumber, we override the feature_path option with ordered files - settings.test_runner.options['feature_path'] = ["src/functional_tests/features/app_automate/apis/api.feature"]; + settings.test_runner.options['feature_path'] = orderedFiles; }else{ settings.src_folders = orderedFiles; for (const envName in testEnvSettings) { From 37873fae1f115d44a286851d5e15669b4cd2bbaf Mon Sep 17 00:00:00 2001 From: harshit-bstack Date: Thu, 16 Oct 2025 22:19:04 +0530 Subject: [PATCH 06/27] feat: Add framework name to build data payload in OrchestrationUtils --- src/testorchestration/orchestrationUtils.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/testorchestration/orchestrationUtils.js b/src/testorchestration/orchestrationUtils.js index 80295af..dbc8cd7 100644 --- a/src/testorchestration/orchestrationUtils.js +++ b/src/testorchestration/orchestrationUtils.js @@ -431,6 +431,7 @@ class OrchestrationUtils { nodeIndex: parseInt(process.env.BROWSERSTACK_NODE_INDEX || '0', 10), totalNodes: parseInt(process.env.BROWSERSTACK_TOTAL_NODE_COUNT || '1', 10), hostInfo: getHostInfo(), + frameworkName: "nightwatch" }; this.logger.debug(`[collectBuildData] Sending build data payload: ${JSON.stringify(payload)}`); From 7eab4c8e9b060bb681d034193050c8dd13fb1b05 Mon Sep 17 00:00:00 2001 From: harshit-bstack Date: Thu, 23 Oct 2025 14:29:22 +0530 Subject: [PATCH 07/27] feat: Remove unused helper functions and debug logs from orchestration files --- src/testorchestration/helpers.js | 93 ------------------- src/testorchestration/orchestrationUtils.js | 2 +- .../testOrchestrationHandler.js | 1 - src/testorchestration/testOrderingServer.js | 1 - 4 files changed, 1 insertion(+), 96 deletions(-) diff --git a/src/testorchestration/helpers.js b/src/testorchestration/helpers.js index 08c967c..7c241bf 100644 --- a/src/testorchestration/helpers.js +++ b/src/testorchestration/helpers.js @@ -1,96 +1,6 @@ -const os = require('os'); -const path = require('path'); const { execSync } = require('child_process'); -const fs = require('fs'); const Logger = require('../utils/logger'); -// Constants -const MAX_GIT_META_DATA_SIZE_IN_BYTES = 512 * 1024; // 512 KB - -/** - * Get host information for the test orchestration - */ -function getHostInfo() { - return { - hostname: os.hostname(), - platform: process.platform, - architecture: process.arch, - release: os.release(), - username: os.userInfo().username - }; -} - -/** - * Format git author information - */ -function gitAuthor(name, email) { - if (!name && !email) { - return ''; - } - return `${name} (${email})`; -} - -/** - * Get the size of a JSON object in bytes - */ -function getSizeOfJsonObjectInBytes(obj) { - try { - const jsonString = JSON.stringify(obj); - return Buffer.byteLength(jsonString, 'utf8'); - } catch (e) { - Logger.error(`Error calculating object size: ${e}`); - return 0; - } -} - -/** - * Truncate a string to reduce its size by the specified number of bytes - */ -function truncateString(str, bytesToTruncate) { - if (!str || bytesToTruncate <= 0) { - return str; - } - - const originalBytes = Buffer.byteLength(str, 'utf8'); - const targetBytes = Math.max(0, originalBytes - bytesToTruncate); - - if (targetBytes >= originalBytes) { - return str; - } - - // Perform binary search to find the right truncation point - let left = 0; - let right = str.length; - - while (left < right) { - const mid = Math.floor((left + right) / 2); - const truncated = str.substring(0, mid); - const bytes = Buffer.byteLength(truncated, 'utf8'); - - if (bytes <= targetBytes) { - left = mid + 1; - } else { - right = mid; - } - } - - return str.substring(0, left - 1) + '...'; -} - -/** - * Check and truncate VCS info if needed - */ -function checkAndTruncateVcsInfo(gitMetaData) { - const gitMetaDataSizeInBytes = getSizeOfJsonObjectInBytes(gitMetaData); - - if (gitMetaDataSizeInBytes && gitMetaDataSizeInBytes > MAX_GIT_META_DATA_SIZE_IN_BYTES) { - const truncateSize = gitMetaDataSizeInBytes - MAX_GIT_META_DATA_SIZE_IN_BYTES; - const truncatedCommitMessage = truncateString(gitMetaData.commit_message, truncateSize); - gitMetaData.commit_message = truncatedCommitMessage; - Logger.info(`The commit has been truncated. Size of commit after truncation is ${getSizeOfJsonObjectInBytes(gitMetaData) / 1024} KB`); - } - return gitMetaData; -} /** * Check if a git metadata result is valid @@ -355,8 +265,5 @@ function getGitMetadataForAiSelection(folders = []) { } module.exports = { - getHostInfo, getGitMetadataForAiSelection, - gitAuthor, - checkAndTruncateVcsInfo }; \ No newline at end of file diff --git a/src/testorchestration/orchestrationUtils.js b/src/testorchestration/orchestrationUtils.js index dbc8cd7..96bda86 100644 --- a/src/testorchestration/orchestrationUtils.js +++ b/src/testorchestration/orchestrationUtils.js @@ -3,7 +3,7 @@ const fs = require('fs'); const os = require('os'); const { tmpdir } = require('os'); const Logger = require('../utils/logger'); -const { getHostInfo, getGitMetadataForAiSelection } = require('./helpers'); +const { getHostInfo } = require('../utils/helper'); const RequestUtils = require('./requestUtils'); // Constants diff --git a/src/testorchestration/testOrchestrationHandler.js b/src/testorchestration/testOrchestrationHandler.js index d5a4c56..f8103e3 100644 --- a/src/testorchestration/testOrchestrationHandler.js +++ b/src/testorchestration/testOrchestrationHandler.js @@ -104,7 +104,6 @@ class TestOrchestrationHandler { this.logger.info(`Reordering test files with orchestration strategy: ${orchestrationStrategy}`); // Use server handler approach for test file orchestration - this.logger.debug('Using SDK flow for test files orchestration.'); await this.testOrderingServerHandler.splitTests(testFiles, orchestrationStrategy, orchestrationMetadata); const orderedTestFiles = await this.testOrderingServerHandler.getOrderedTestFiles() || []; diff --git a/src/testorchestration/testOrderingServer.js b/src/testorchestration/testOrderingServer.js index e468433..cf0fe77 100644 --- a/src/testorchestration/testOrderingServer.js +++ b/src/testorchestration/testOrderingServer.js @@ -27,7 +27,6 @@ class TestOrderingServer { * Initiates the split tests request and stores the response data for polling. */ async splitTests(testFiles, orchestrationStrategy, orchestrationMetadata = {}) { - this.logger.debug(`[splitTests] Initiating split tests with strategy: ${orchestrationStrategy}`); try { let prDetails = []; const source = orchestrationMetadata['run_smart_selection']?.source; From 1b983188de326154a8b4a976237d27fb261f56eb Mon Sep 17 00:00:00 2001 From: harshit-bstack Date: Thu, 23 Oct 2025 15:12:53 +0530 Subject: [PATCH 08/27] feat: Update framework name comment in build data payload and enhance error logging in RequestUtils --- src/testorchestration/orchestrationUtils.js | 2 +- src/testorchestration/requestUtils.js | 32 ++------------------- 2 files changed, 3 insertions(+), 31 deletions(-) diff --git a/src/testorchestration/orchestrationUtils.js b/src/testorchestration/orchestrationUtils.js index 96bda86..397606f 100644 --- a/src/testorchestration/orchestrationUtils.js +++ b/src/testorchestration/orchestrationUtils.js @@ -431,7 +431,7 @@ class OrchestrationUtils { nodeIndex: parseInt(process.env.BROWSERSTACK_NODE_INDEX || '0', 10), totalNodes: parseInt(process.env.BROWSERSTACK_TOTAL_NODE_COUNT || '1', 10), hostInfo: getHostInfo(), - frameworkName: "nightwatch" + frameworkName: "nightwatch" // TODO: Need to remove this after adding support in Test Orchestration Server }; this.logger.debug(`[collectBuildData] Sending build data payload: ${JSON.stringify(payload)}`); diff --git a/src/testorchestration/requestUtils.js b/src/testorchestration/requestUtils.js index 9b87e06..22a1e4e 100644 --- a/src/testorchestration/requestUtils.js +++ b/src/testorchestration/requestUtils.js @@ -79,37 +79,9 @@ class RequestUtils { } return responseObj; - } catch (e) { - // Enhanced error logging for better diagnosis - if (e.code === 'EPIPE') { - Logger.error(`❌ Network connection error (EPIPE) when calling orchestration API`); - Logger.error(` URL: ${fullUrl}`); - Logger.error(` This usually indicates a network connectivity issue or the connection was closed unexpectedly`); - Logger.error(` Please check your internet connection and BrowserStack service status`); - } else if (e.code === 'ECONNREFUSED') { - Logger.error(`❌ Connection refused when calling orchestration API`); - Logger.error(` URL: ${fullUrl}`); - Logger.error(` The BrowserStack orchestration service may be unavailable`); - } else if (e.code === 'ENOTFOUND') { - Logger.error(`❌ DNS resolution failed for orchestration API`); - Logger.error(` URL: ${fullUrl}`); - Logger.error(` Please check your DNS settings and network connectivity`); - } else if (e.response && e.response.status === 401) { - Logger.error(`❌ Authentication failed for orchestration API`); - Logger.error(` Please check your BROWSERSTACK_TESTHUB_JWT token`); - } else if (e.response && e.response.status === 403) { - Logger.error(`❌ Access forbidden for orchestration API`); - Logger.error(` Your account may not have access to test orchestration features`); - } else { - Logger.error(`❌ Orchestration request failed: ${e.message || e} - ${reqEndpoint}`); - if (e.response) { - Logger.error(` Response status: ${e.response.status}`); - Logger.error(` Response data: ${JSON.stringify(e.response.data)}`); - } - } - + } catch (e) { // Log stack trace for debugging - Logger.debug(`Error stack trace: ${e.stack}`); + Logger.debug(`[makeOrchestrationRequest] Error during API Call: ${e.message || e} - ${reqEndpoint}`); return null; } From b8f963acd14da6129e386329ba3b65693f10daf0 Mon Sep 17 00:00:00 2001 From: harshit-bstack Date: Thu, 23 Oct 2025 15:45:04 +0530 Subject: [PATCH 09/27] feat: Remove unused orchestration files and refactor helper functions for improved Git metadata handling --- src/testorchestration/helpers.js | 269 ------------------ src/testorchestration/index.js | 113 -------- src/testorchestration/orchestrationUtils.js | 35 +-- src/testorchestration/requestUtils.js | 2 +- .../testOrchestrationHandler.js | 2 - src/testorchestration/testOrderingServer.js | 31 +- src/utils/helper.js | 263 +++++++++++++++++ 7 files changed, 287 insertions(+), 428 deletions(-) delete mode 100644 src/testorchestration/helpers.js delete mode 100644 src/testorchestration/index.js diff --git a/src/testorchestration/helpers.js b/src/testorchestration/helpers.js deleted file mode 100644 index 7c241bf..0000000 --- a/src/testorchestration/helpers.js +++ /dev/null @@ -1,269 +0,0 @@ -const { execSync } = require('child_process'); -const Logger = require('../utils/logger'); - - -/** - * Check if a git metadata result is valid - */ -function isValidGitResult(result) { - return ( - Array.isArray(result.filesChanged) && - result.filesChanged.length > 0 && - Array.isArray(result.authors) && - result.authors.length > 0 - ); -} - -/** - * Get base branch from repository - */ -function getBaseBranch() { - try { - // Try to get the default branch from origin/HEAD symbolic ref (works for most providers) - try { - const originHeadOutput = execSync('git symbolic-ref refs/remotes/origin/HEAD').toString().trim(); - if (originHeadOutput.startsWith('refs/remotes/origin/')) { - return originHeadOutput.replace('refs/remotes/', ''); - } - } catch (e) { - // Symbolic ref might not exist - } - - // Fallback: use the first branch in local heads - try { - const branchesOutput = execSync('git branch').toString().trim(); - const branches = branchesOutput.split('\n').filter(Boolean); - if (branches.length > 0) { - // Remove the '* ' from current branch if present and return first branch - const firstBranch = branches[0].replace(/^\*\s+/, '').trim(); - return firstBranch; - } - } catch (e) { - // Branches might not exist - } - - // Fallback: use the first remote branch if available - try { - const remoteBranchesOutput = execSync('git branch -r').toString().trim(); - const remoteBranches = remoteBranchesOutput.split('\n').filter(Boolean); - for (const branch of remoteBranches) { - const cleanBranch = branch.trim(); - if (cleanBranch.startsWith('origin/') && !cleanBranch.includes('HEAD')) { - return cleanBranch; - } - } - } catch (e) { - // Remote branches might not exist - } - } catch (e) { - Logger.debug(`Error finding base branch: ${e}`); - } - - return null; -} - -/** - * Get changed files from commits - */ -function getChangedFilesFromCommits(commitHashes) { - const changedFiles = new Set(); - - try { - for (const commit of commitHashes) { - try { - // Check if commit has parents - const parentsOutput = execSync(`git log -1 --pretty=%P ${commit}`).toString().trim(); - const parents = parentsOutput.split(' ').filter(Boolean); - - for (const parent of parents) { - const diffOutput = execSync(`git diff --name-only ${parent} ${commit}`).toString().trim(); - const files = diffOutput.split('\n').filter(Boolean); - - for (const file of files) { - changedFiles.add(file); - } - } - } catch (e) { - Logger.debug(`Error processing commit ${commit}: ${e}`); - } - } - } catch (e) { - Logger.debug(`Error getting changed files from commits: ${e}`); - } - - return Array.from(changedFiles); -} - -/** - * Get Git metadata for AI selection - * @param multiRepoSource Array of repository paths for multi-repo setup - */ -function getGitMetadataForAiSelection(folders = []) { - if (folders && folders.length === 0) { - folders = [process.cwd()]; - } - - const results = []; - - for (const folder of folders) { - const originalDir = process.cwd(); - try { - // Initialize the result structure - const result = { - prId: '', - filesChanged: [], - authors: [], - prDate: '', - commitMessages: [], - prTitle: '', - prDescription: '', - prRawDiff: '' - }; - - // Change directory to the folder - process.chdir(folder); - - // Get current branch and latest commit - const currentBranch = execSync('git rev-parse --abbrev-ref HEAD').toString().trim(); - const latestCommit = execSync('git rev-parse HEAD').toString().trim(); - result.prId = latestCommit; - - // Find base branch - const baseBranch = getBaseBranch(); - Logger.debug(`Base branch for comparison: ${baseBranch}`); - - let commits = []; - - if (baseBranch) { - try { - // Get changed files between base branch and current branch - const changedFilesOutput = execSync(`git diff --name-only ${baseBranch}...${currentBranch}`).toString().trim(); - Logger.debug(`Changed files between ${baseBranch} and ${currentBranch}: ${changedFilesOutput}`); - result.filesChanged = changedFilesOutput.split('\n').filter(f => f.trim()); - - // Get commits between base branch and current branch - const commitsOutput = execSync(`git log ${baseBranch}..${currentBranch} --pretty=%H`).toString().trim(); - commits = commitsOutput.split('\n').filter(Boolean); - } catch (e) { - Logger.debug('Failed to get changed files from branch comparison. Falling back to recent commits.'); - // Fallback to recent commits - const recentCommitsOutput = execSync('git log -10 --pretty=%H').toString().trim(); - commits = recentCommitsOutput.split('\n').filter(Boolean); - - if (commits.length > 0) { - result.filesChanged = getChangedFilesFromCommits(commits.slice(0, 5)); - } - } - } else { - // Fallback to recent commits - const recentCommitsOutput = execSync('git log -10 --pretty=%H').toString().trim(); - commits = recentCommitsOutput.split('\n').filter(Boolean); - - if (commits.length > 0) { - result.filesChanged = getChangedFilesFromCommits(commits.slice(0, 5)); - } - } - - // Process commit authors and messages - const authorsSet = new Set(); - const commitMessages = []; - - // Only process commits if we have them - if (commits.length > 0) { - for (const commit of commits) { - try { - const commitMessage = execSync(`git log -1 --pretty=%B ${commit}`).toString().trim(); - Logger.debug(`Processing commit: ${commitMessage}`); - - const authorName = execSync(`git log -1 --pretty=%an ${commit}`).toString().trim(); - authorsSet.add(authorName || 'Unknown'); - - commitMessages.push({ - message: commitMessage.trim(), - user: authorName || 'Unknown' - }); - } catch (e) { - Logger.debug(`Error processing commit ${commit}: ${e}`); - } - } - } - - // If we have no commits but have changed files, add a fallback author - if (commits.length === 0 && result.filesChanged.length > 0) { - try { - // Try to get current git user as fallback - const fallbackAuthor = execSync('git config user.name').toString().trim() || 'Unknown'; - authorsSet.add(fallbackAuthor); - Logger.debug(`Added fallback author: ${fallbackAuthor}`); - } catch (e) { - authorsSet.add('Unknown'); - Logger.debug('Added Unknown as fallback author'); - } - } - - result.authors = Array.from(authorsSet); - result.commitMessages = commitMessages; - - // Get commit date - if (latestCommit) { - const commitDate = execSync(`git log -1 --pretty=%cd --date=format:'%Y-%m-%d' ${latestCommit}`).toString().trim(); - result.prDate = commitDate.replace(/'/g, ''); - } - - // Set PR title and description from latest commit if not already set - if ((!result.prTitle || result.prTitle.trim() === '') && latestCommit) { - try { - const latestCommitMessage = execSync(`git log -1 --pretty=%B ${latestCommit}`).toString().trim(); - const messageLines = latestCommitMessage.trim().split('\n'); - result.prTitle = messageLines[0] || ''; - - if (messageLines.length > 2) { - result.prDescription = messageLines.slice(2).join('\n').trim(); - } - } catch (e) { - Logger.debug(`Error extracting commit message for PR title: ${e}`); - } - } - - // Reset directory - process.chdir(originalDir); - - results.push(result); - } catch (e) { - Logger.error(`Exception in populating Git metadata for AI selection (folder: ${folder}): ${e}`); - - // Reset directory if needed - try { - process.chdir(originalDir); - } catch (dirError) { - Logger.error(`Error resetting directory: ${dirError}`); - } - } - } - - // Filter out results with empty filesChanged - const filteredResults = results.filter(isValidGitResult); - - // Map to required output format - const formattedResults = filteredResults.map((result) => ({ - prId: result.prId || '', - filesChanged: Array.isArray(result.filesChanged) ? result.filesChanged : [], - authors: Array.isArray(result.authors) ? result.authors : [], - prDate: result.prDate || '', - commitMessages: Array.isArray(result.commitMessages) - ? result.commitMessages.map((cm) => ({ - message: cm.message || '', - user: cm.user || '' - })) - : [], - prTitle: result.prTitle || '', - prDescription: result.prDescription || '', - prRawDiff: result.prRawDiff || '' - })); - - return formattedResults; -} - -module.exports = { - getGitMetadataForAiSelection, -}; \ No newline at end of file diff --git a/src/testorchestration/index.js b/src/testorchestration/index.js deleted file mode 100644 index 7cf2123..0000000 --- a/src/testorchestration/index.js +++ /dev/null @@ -1,113 +0,0 @@ -/** - * Nightwatch Test Orchestration Module - * - * This module provides test orchestration functionality for Nightwatch tests - * using BrowserStack's AI-powered test selection and ordering capabilities. - * - * @module nightwatch-test-orchestration - */ - -// Core orchestration classes -const TestOrchestrationHandler = require('./testOrchestrationHandler'); -const TestOrderingServer = require('./testOrderingServer'); -const OrchestrationUtils = require('./orchestrationUtils'); -const TestOrchestrationIntegration = require('./testOrchestrationIntegration'); - -// Utility classes -const RequestUtils = require('./requestUtils'); -const { getHostInfo, getGitMetadataForAiSelection } = require('./helpers'); - -// Main API and application functions -const { applyOrchestrationIfEnabled } = require('./applyOrchestration'); - -/** - * Main Test Orchestration class that provides the primary interface - */ -class NightwatchTestOrchestration { - constructor() { - this.handler = null; - this.integration = TestOrchestrationIntegration.getInstance(); - } - - /** - * Initialize test orchestration with configuration - * @param {Object} config - Nightwatch configuration object - */ - initialize(config) { - this.handler = TestOrchestrationHandler.getInstance(config); - this.integration.configure(config); - return this; - } - - /** - * Apply orchestration to test specs - * @param {Array} specs - Array of test file paths - * @param {Object} config - Configuration object - * @returns {Promise} - Ordered test specs - */ - async applyOrchestration(specs, config) { - if (!this.handler) { - this.initialize(config); - } - return await applyOrchestrationIfEnabled(specs, config); - } - - /** - * Collect build data after test execution - * @param {Object} config - Configuration object - * @returns {Promise} - Build data response - */ - async collectBuildData(config) { - if (!this.handler) { - this.initialize(config); - } - const utils = OrchestrationUtils.getInstance(config); - return await utils.collectBuildData(config); - } - - /** - * Check if test orchestration is enabled - * @param {Object} config - Configuration object - * @returns {boolean} - True if orchestration is enabled - */ - isEnabled(config) { - if (!this.handler) { - this.initialize(config); - } - return this.handler.testOrderingEnabled(); - } -} - -// Create main instance -const testOrchestration = new NightwatchTestOrchestration(); - -// Export main interface -module.exports = { - // Main class - NightwatchTestOrchestration, - - // Primary instance - testOrchestration, - - // Core classes - TestOrchestrationHandler, - TestOrderingServer, - OrchestrationUtils, - TestOrchestrationIntegration, - - // Utilities - RequestUtils, - helpers: { - getHostInfo, - getGitMetadataForAiSelection - }, - - // Main functions - applyOrchestrationIfEnabled, - - // API methods (convenient access) - initialize: (config) => testOrchestration.initialize(config), - applyOrchestration: (specs, config) => testOrchestration.applyOrchestration(specs, config), - collectBuildData: (config) => testOrchestration.collectBuildData(config), - isEnabled: (config) => testOrchestration.isEnabled(config) -}; \ No newline at end of file diff --git a/src/testorchestration/orchestrationUtils.js b/src/testorchestration/orchestrationUtils.js index 397606f..dc8157f 100644 --- a/src/testorchestration/orchestrationUtils.js +++ b/src/testorchestration/orchestrationUtils.js @@ -1,11 +1,10 @@ const path = require('path'); const fs = require('fs'); -const os = require('os'); const { tmpdir } = require('os'); const Logger = require('../utils/logger'); const { getHostInfo } = require('../utils/helper'); const RequestUtils = require('./requestUtils'); - +const helper = require('../utils/helper'); // Constants const RUN_SMART_SELECTION = 'runSmartSelection'; const ALLOWED_ORCHESTRATION_KEYS = [RUN_SMART_SELECTION]; @@ -48,6 +47,11 @@ class OrchestrationUtils { * @param config Configuration object */ constructor(config) { + this._settings = config['@nightwatch/browserstack'] || {}; + this._bstackOptions = {}; + if (config && config.desiredCapabilities && config.desiredCapabilities['bstack:options']) { + this._bstackOptions = config.desiredCapabilities['bstack:options']; + } this.logger = Logger; this.runSmartSelection = false; this.smartSelectionMode = 'relevantFirst'; @@ -66,8 +70,8 @@ class OrchestrationUtils { runSmartSelectionOpts.source || null ); - // Extract build details from config - this._extractBuildDetails(config); + // Extract build details + this._extractBuildDetails(); } /** @@ -92,22 +96,13 @@ class OrchestrationUtils { /** * Extract build details from config */ - _extractBuildDetails(config) { + _extractBuildDetails() { try { - const bsOptions = config['@nightwatch/browserstack']; - const bstackOptions = config.desiredCapabilities?.['bstack:options']; - - this.buildName = bsOptions?.test_observability?.buildName || - bstackOptions?.buildName || - path.basename(process.cwd()); - - this.projectName = bsOptions?.test_observability?.projectName || - bstackOptions?.projectName || - ''; - - this.buildIdentifier = bsOptions?.test_observability?.buildIdentifier || - bstackOptions?.buildIdentifier || - ''; + this.buildName = helper.getBuildName(this._settings, this._bstackOptions) || ''; + + this.projectName = helper.getProjectName(this._settings, this._bstackOptions) || ''; + + this.buildIdentifier = process.env.BROWSERSTACK_BUILD_RUN_IDENTIFIER || ''; this.logger.debug(`[_extractBuildDetails] Extracted - projectName: ${this.projectName}, buildName: ${this.buildName}, buildIdentifier: ${this.buildIdentifier}`); } catch (e) { @@ -427,7 +422,7 @@ class OrchestrationUtils { const payload = { projectName: this.getProjectName(), buildName: this.getBuildName(), - buildRunIdentifier: process.env.BROWSERSTACK_BUILD_RUN_IDENTIFIER || '', + buildRunIdentifier: this.getBuildIdentifier(), nodeIndex: parseInt(process.env.BROWSERSTACK_NODE_INDEX || '0', 10), totalNodes: parseInt(process.env.BROWSERSTACK_TOTAL_NODE_COUNT || '1', 10), hostInfo: getHostInfo(), diff --git a/src/testorchestration/requestUtils.js b/src/testorchestration/requestUtils.js index 22a1e4e..83c635a 100644 --- a/src/testorchestration/requestUtils.js +++ b/src/testorchestration/requestUtils.js @@ -37,7 +37,7 @@ class RequestUtils { // Validate JWT token if (!jwtToken) { - Logger.error('BROWSERSTACK_TESTHUB_JWT environment variable is not set. This is required for test orchestration.'); + Logger.error('BS_TESTOPS_JWT environment variable is not set. This is required for test orchestration.'); return null; } diff --git a/src/testorchestration/testOrchestrationHandler.js b/src/testorchestration/testOrchestrationHandler.js index f8103e3..c89e90d 100644 --- a/src/testorchestration/testOrchestrationHandler.js +++ b/src/testorchestration/testOrchestrationHandler.js @@ -1,5 +1,3 @@ -const path = require('path'); -const {performance} = require('perf_hooks'); const Logger = require('../utils/logger'); const TestOrderingServer = require('./testOrderingServer'); const OrchestrationUtils = require('./orchestrationUtils'); diff --git a/src/testorchestration/testOrderingServer.js b/src/testorchestration/testOrderingServer.js index cf0fe77..b47d0b7 100644 --- a/src/testorchestration/testOrderingServer.js +++ b/src/testorchestration/testOrderingServer.js @@ -1,8 +1,7 @@ const path = require('path'); const Logger = require('../utils/logger'); -const { getHostInfo, getGitMetadataForAiSelection } = require('./helpers'); +const { getHostInfo, getGitMetadataForAiSelection, getProjectName, getBuildName } = require('../utils/helper'); const RequestUtils = require('./requestUtils'); - const ORCHESTRATION_API_URL = 'https://collector-observability.browserstack.com'; /** @@ -21,6 +20,11 @@ class TestOrderingServer { this.defaultTimeout = 60; this.defaultTimeoutInterval = 5; this.splitTestsApiCallCount = 0; + this._settings = config['@nightwatch/browserstack'] || {}; + this._bstackOptions = {}; + if (config && config.desiredCapabilities && config.desiredCapabilities['bstack:options']) { + this._bstackOptions = config.desiredCapabilities['bstack:options']; + } } /** @@ -42,8 +46,8 @@ class TestOrderingServer { orchestrationMetadata, nodeIndex: parseInt(process.env.BROWSERSTACK_NODE_INDEX || '0'), totalNodes: parseInt(process.env.BROWSERSTACK_TOTAL_NODE_COUNT || '1'), - projectName: this._getProjectName(), - buildName: this._getBuildName(), + projectName: getProjectName(this._settings, this._bstackOptions), + buildName: getBuildName(this._settings, this._bstackOptions), buildRunIdentifier: process.env.BROWSERSTACK_BUILD_RUN_IDENTIFIER || '', hostInfo: getHostInfo(), prDetails @@ -61,25 +65,6 @@ class TestOrderingServer { } } - /** - * Get project name from configuration - */ - _getProjectName() { - const bsOptions = this.config['@nightwatch/browserstack']; - return bsOptions?.test_observability?.projectName || - this.config.desiredCapabilities?.['bstack:options']?.projectName || - ''; - } - - /** - * Get build name from configuration - */ - _getBuildName() { - const bsOptions = this.config['@nightwatch/browserstack']; - return bsOptions?.test_observability?.buildName || - this.config.desiredCapabilities?.['bstack:options']?.buildName || - path.basename(process.cwd()); - } /** * Processes the split tests API response and extracts relevant fields. diff --git a/src/utils/helper.js b/src/utils/helper.js index ecdc0af..57f3e01 100644 --- a/src/utils/helper.js +++ b/src/utils/helper.js @@ -15,6 +15,7 @@ const Logger = require('./logger'); const LogPatcher = require('./logPatcher'); const BSTestOpsPatcher = new LogPatcher({}); const sessions = {}; +const { execSync } = require('child_process'); console = {}; Object.keys(consoleHolder).forEach(method => { @@ -1028,3 +1029,265 @@ exports.expandGlobPattern = (pattern) => { return files; }; + +/** + * Check if a git metadata result is valid + */ +function isValidGitResult(result) { + return ( + Array.isArray(result.filesChanged) && + result.filesChanged.length > 0 && + Array.isArray(result.authors) && + result.authors.length > 0 + ); +} + +/** + * Get base branch from repository + */ +function getBaseBranch() { + try { + // Try to get the default branch from origin/HEAD symbolic ref (works for most providers) + try { + const originHeadOutput = execSync('git symbolic-ref refs/remotes/origin/HEAD').toString().trim(); + if (originHeadOutput.startsWith('refs/remotes/origin/')) { + return originHeadOutput.replace('refs/remotes/', ''); + } + } catch (e) { + // Symbolic ref might not exist + } + + // Fallback: use the first branch in local heads + try { + const branchesOutput = execSync('git branch').toString().trim(); + const branches = branchesOutput.split('\n').filter(Boolean); + if (branches.length > 0) { + // Remove the '* ' from current branch if present and return first branch + const firstBranch = branches[0].replace(/^\*\s+/, '').trim(); + return firstBranch; + } + } catch (e) { + // Branches might not exist + } + + // Fallback: use the first remote branch if available + try { + const remoteBranchesOutput = execSync('git branch -r').toString().trim(); + const remoteBranches = remoteBranchesOutput.split('\n').filter(Boolean); + for (const branch of remoteBranches) { + const cleanBranch = branch.trim(); + if (cleanBranch.startsWith('origin/') && !cleanBranch.includes('HEAD')) { + return cleanBranch; + } + } + } catch (e) { + // Remote branches might not exist + } + } catch (e) { + Logger.debug(`Error finding base branch: ${e}`); + } + + return null; +} + +/** + * Get changed files from commits + */ +function getChangedFilesFromCommits(commitHashes) { + const changedFiles = new Set(); + + try { + for (const commit of commitHashes) { + try { + // Check if commit has parents + const parentsOutput = execSync(`git log -1 --pretty=%P ${commit}`).toString().trim(); + const parents = parentsOutput.split(' ').filter(Boolean); + + for (const parent of parents) { + const diffOutput = execSync(`git diff --name-only ${parent} ${commit}`).toString().trim(); + const files = diffOutput.split('\n').filter(Boolean); + + for (const file of files) { + changedFiles.add(file); + } + } + } catch (e) { + Logger.debug(`Error processing commit ${commit}: ${e}`); + } + } + } catch (e) { + Logger.debug(`Error getting changed files from commits: ${e}`); + } + + return Array.from(changedFiles); +} + +/** + * Get Git metadata for AI selection + * @param multiRepoSource Array of repository paths for multi-repo setup + */ +exports.getGitMetadataForAiSelection = (folders = []) => { + if (folders && folders.length === 0) { + folders = [process.cwd()]; + } + + const results = []; + + for (const folder of folders) { + const originalDir = process.cwd(); + try { + // Initialize the result structure + const result = { + prId: '', + filesChanged: [], + authors: [], + prDate: '', + commitMessages: [], + prTitle: '', + prDescription: '', + prRawDiff: '' + }; + + // Change directory to the folder + process.chdir(folder); + + // Get current branch and latest commit + const currentBranch = execSync('git rev-parse --abbrev-ref HEAD').toString().trim(); + const latestCommit = execSync('git rev-parse HEAD').toString().trim(); + result.prId = latestCommit; + + // Find base branch + const baseBranch = getBaseBranch(); + Logger.debug(`Base branch for comparison: ${baseBranch}`); + + let commits = []; + + if (baseBranch) { + try { + // Get changed files between base branch and current branch + const changedFilesOutput = execSync(`git diff --name-only ${baseBranch}...${currentBranch}`).toString().trim(); + Logger.debug(`Changed files between ${baseBranch} and ${currentBranch}: ${changedFilesOutput}`); + result.filesChanged = changedFilesOutput.split('\n').filter(f => f.trim()); + + // Get commits between base branch and current branch + const commitsOutput = execSync(`git log ${baseBranch}..${currentBranch} --pretty=%H`).toString().trim(); + commits = commitsOutput.split('\n').filter(Boolean); + } catch (e) { + Logger.debug('Failed to get changed files from branch comparison. Falling back to recent commits.'); + // Fallback to recent commits + const recentCommitsOutput = execSync('git log -10 --pretty=%H').toString().trim(); + commits = recentCommitsOutput.split('\n').filter(Boolean); + + if (commits.length > 0) { + result.filesChanged = getChangedFilesFromCommits(commits.slice(0, 5)); + } + } + } else { + // Fallback to recent commits + const recentCommitsOutput = execSync('git log -10 --pretty=%H').toString().trim(); + commits = recentCommitsOutput.split('\n').filter(Boolean); + + if (commits.length > 0) { + result.filesChanged = getChangedFilesFromCommits(commits.slice(0, 5)); + } + } + + // Process commit authors and messages + const authorsSet = new Set(); + const commitMessages = []; + + // Only process commits if we have them + if (commits.length > 0) { + for (const commit of commits) { + try { + const commitMessage = execSync(`git log -1 --pretty=%B ${commit}`).toString().trim(); + Logger.debug(`Processing commit: ${commitMessage}`); + + const authorName = execSync(`git log -1 --pretty=%an ${commit}`).toString().trim(); + authorsSet.add(authorName || 'Unknown'); + + commitMessages.push({ + message: commitMessage.trim(), + user: authorName || 'Unknown' + }); + } catch (e) { + Logger.debug(`Error processing commit ${commit}: ${e}`); + } + } + } + + // If we have no commits but have changed files, add a fallback author + if (commits.length === 0 && result.filesChanged.length > 0) { + try { + // Try to get current git user as fallback + const fallbackAuthor = execSync('git config user.name').toString().trim() || 'Unknown'; + authorsSet.add(fallbackAuthor); + Logger.debug(`Added fallback author: ${fallbackAuthor}`); + } catch (e) { + authorsSet.add('Unknown'); + Logger.debug('Added Unknown as fallback author'); + } + } + + result.authors = Array.from(authorsSet); + result.commitMessages = commitMessages; + + // Get commit date + if (latestCommit) { + const commitDate = execSync(`git log -1 --pretty=%cd --date=format:'%Y-%m-%d' ${latestCommit}`).toString().trim(); + result.prDate = commitDate.replace(/'/g, ''); + } + + // Set PR title and description from latest commit if not already set + if ((!result.prTitle || result.prTitle.trim() === '') && latestCommit) { + try { + const latestCommitMessage = execSync(`git log -1 --pretty=%B ${latestCommit}`).toString().trim(); + const messageLines = latestCommitMessage.trim().split('\n'); + result.prTitle = messageLines[0] || ''; + + if (messageLines.length > 2) { + result.prDescription = messageLines.slice(2).join('\n').trim(); + } + } catch (e) { + Logger.debug(`Error extracting commit message for PR title: ${e}`); + } + } + + // Reset directory + process.chdir(originalDir); + + results.push(result); + } catch (e) { + Logger.error(`Exception in populating Git metadata for AI selection (folder: ${folder}): ${e}`); + + // Reset directory if needed + try { + process.chdir(originalDir); + } catch (dirError) { + Logger.error(`Error resetting directory: ${dirError}`); + } + } + } + + // Filter out results with empty filesChanged + const filteredResults = results.filter(isValidGitResult); + + // Map to required output format + const formattedResults = filteredResults.map((result) => ({ + prId: result.prId || '', + filesChanged: Array.isArray(result.filesChanged) ? result.filesChanged : [], + authors: Array.isArray(result.authors) ? result.authors : [], + prDate: result.prDate || '', + commitMessages: Array.isArray(result.commitMessages) + ? result.commitMessages.map((cm) => ({ + message: cm.message || '', + user: cm.user || '' + })) + : [], + prTitle: result.prTitle || '', + prDescription: result.prDescription || '', + prRawDiff: result.prRawDiff || '' + })); + + return formattedResults; +} \ No newline at end of file From df2b1c82d78408d8429c084dd542f26bc631c8cf Mon Sep 17 00:00:00 2001 From: harshit-bstack Date: Tue, 28 Oct 2025 14:09:09 +0530 Subject: [PATCH 10/27] style: eslint update --- nightwatch/globals.js | 48 +++++++++---------- src/testorchestration/applyOrchestration.js | 19 ++++---- src/testorchestration/orchestrationUtils.js | 34 ++++++++----- src/testorchestration/requestUtils.js | 8 +++- .../testOrchestrationHandler.js | 4 ++ .../testOrchestrationIntegration.js | 17 +++++-- src/testorchestration/testOrderingServer.js | 9 ++-- src/utils/helper.js | 17 +++++-- src/utils/requestHelper.js | 1 + 9 files changed, 98 insertions(+), 59 deletions(-) diff --git a/nightwatch/globals.js b/nightwatch/globals.js index 9c9a053..faf0d28 100644 --- a/nightwatch/globals.js +++ b/nightwatch/globals.js @@ -9,7 +9,7 @@ const path = require('path'); const AccessibilityAutomation = require('../src/accessibilityAutomation'); const eventHelper = require('../src/utils/eventHelper'); const OrchestrationUtils = require('../src/testorchestration/orchestrationUtils'); -const { type } = require('os'); +const {type} = require('os'); const localTunnel = new LocalTunnel(); const testObservability = new TestObservability(); const accessibilityAutomation = new AccessibilityAutomation(); @@ -338,9 +338,9 @@ module.exports = { let allTestFiles = []; // Checking either for Feature Path or src_folders, feature path take priority - if(helper.isCucumberTestSuite(settings) && settings?.test_runner?.options?.feature_path){ + if (helper.isCucumberTestSuite(settings) && settings?.test_runner?.options?.feature_path){ Logger.debug('Getting test files from feature_path configuration...'); - if(Array.isArray(settings.test_runner.options.feature_path)){ + if (Array.isArray(settings.test_runner.options.feature_path)){ settings.test_runner.options.feature_path.forEach(featurePath => { const files = helper.collectTestFiles(featurePath, 'feature_path config'); allTestFiles = allTestFiles.concat(files); @@ -349,7 +349,7 @@ module.exports = { const files = helper.collectTestFiles(settings.test_runner.options.feature_path, 'feature_path config'); allTestFiles = allTestFiles.concat(files); } - }else if (settings.src_folders && Array.isArray(settings.src_folders) && settings.src_folders.length > 0) { + } else if (settings.src_folders && Array.isArray(settings.src_folders) && settings.src_folders.length > 0) { Logger.debug('Getting test files from src_folders configuration...'); settings.src_folders.forEach(folder => { const files = helper.collectTestFiles(folder, 'src_folders config'); @@ -373,29 +373,29 @@ module.exports = { if (orderedFiles && orderedFiles.length > 0) { Logger.info(`Test files reordered by orchestration: ${orderedFiles.length} files`); - Logger.info(`Test orchestration recommended order change:`); - Logger.info(`Original: ${allTestFiles.join(', ')}`); - Logger.info(`Optimized: ${orderedFiles.join(', ')}`); + Logger.info('Test orchestration recommended order change:'); + Logger.info(`Original: ${allTestFiles.join(', ')}`); + Logger.info(`Optimized: ${orderedFiles.join(', ')}`); - try { - if(helper.isCucumberTestSuite(settings) && settings?.test_runner?.options?.feature_path){ - // For cucumber, we override the feature_path option with ordered files - settings.test_runner.options['feature_path'] = orderedFiles; - }else{ - settings.src_folders = orderedFiles; - for (const envName in testEnvSettings) { - testEnvSettings[envName].src_folders = orderedFiles; - testEnvSettings[envName].test_runner.src_folders = orderedFiles; - } - if (settings.test_runner && typeof settings.test_runner === 'object' && !Array.isArray(settings.test_runner)) { - settings.test_runner.src_folders = orderedFiles; - } + try { + if (helper.isCucumberTestSuite(settings) && settings?.test_runner?.options?.feature_path){ + // For cucumber, we override the feature_path option with ordered files + settings.test_runner.options['feature_path'] = orderedFiles; + } else { + settings.src_folders = orderedFiles; + for (const envName in testEnvSettings) { + testEnvSettings[envName].src_folders = orderedFiles; + testEnvSettings[envName].test_runner.src_folders = orderedFiles; } + if (settings.test_runner && typeof settings.test_runner === 'object' && !Array.isArray(settings.test_runner)) { + settings.test_runner.src_folders = orderedFiles; + } + } - } catch (reorderError) { - Logger.error(`Runtime reordering failed: ${reorderError.message}`); - Logger.info(`Falling back to original order for current execution.`); - } + } catch (reorderError) { + Logger.error(`Runtime reordering failed: ${reorderError.message}`); + Logger.info('Falling back to original order for current execution.'); + } } else { Logger.info('Split test API called - no reordering available'); } diff --git a/src/testorchestration/applyOrchestration.js b/src/testorchestration/applyOrchestration.js index b10be05..7c837ab 100644 --- a/src/testorchestration/applyOrchestration.js +++ b/src/testorchestration/applyOrchestration.js @@ -1,5 +1,4 @@ -const path = require('path'); -const { performance } = require('perf_hooks'); +const {performance} = require('perf_hooks'); const Logger = require('../utils/logger'); const TestOrchestrationHandler = require('./testOrchestrationHandler'); @@ -14,6 +13,7 @@ async function applyOrchestrationIfEnabled(specs, config) { if (!orchestrationHandler) { Logger.warn('Orchestration handler is not initialized. Skipping orchestration.'); + return specs; } @@ -23,6 +23,7 @@ async function applyOrchestrationIfEnabled(specs, config) { if (!runSmartSelectionEnabled) { Logger.info('runSmartSelection is not enabled in config. Skipping orchestration.'); + return specs; } @@ -53,13 +54,13 @@ async function applyOrchestrationIfEnabled(specs, config) { ); return orderedFiles; - } else { - Logger.info('No test files were reordered by orchestration.'); - orchestrationHandler.addToOrderingInstrumentationData( - 'timeTakenToApply', - Math.floor(performance.now() - startTime) // Time in milliseconds - ); - } + } + Logger.info('No test files were reordered by orchestration.'); + orchestrationHandler.addToOrderingInstrumentationData( + 'timeTakenToApply', + Math.floor(performance.now() - startTime) // Time in milliseconds + ); + return specs; } diff --git a/src/testorchestration/orchestrationUtils.js b/src/testorchestration/orchestrationUtils.js index dc8157f..7e6d0a0 100644 --- a/src/testorchestration/orchestrationUtils.js +++ b/src/testorchestration/orchestrationUtils.js @@ -1,8 +1,8 @@ const path = require('path'); const fs = require('fs'); -const { tmpdir } = require('os'); +const {tmpdir} = require('os'); const Logger = require('../utils/logger'); -const { getHostInfo } = require('../utils/helper'); +const {getHostInfo} = require('../utils/helper'); const RequestUtils = require('./requestUtils'); const helper = require('../utils/helper'); // Constants @@ -59,7 +59,7 @@ class OrchestrationUtils { this.smartSelectionSource = null; // Store source paths if provided // Check both possible configuration paths: direct or nested in browserstack options - let testOrchOptions = this._getTestOrchestrationOptions(config); + const testOrchOptions = this._getTestOrchestrationOptions(config); // Try to get runSmartSelection options const runSmartSelectionOpts = testOrchOptions[RUN_SMART_SELECTION] || {}; @@ -117,6 +117,7 @@ class OrchestrationUtils { if (!OrchestrationUtils._instance && config) { OrchestrationUtils._instance = new OrchestrationUtils(config); } + return OrchestrationUtils._instance; } @@ -144,6 +145,7 @@ class OrchestrationUtils { static checkAbortBuildFileExists() { const buildUuid = process.env.BS_TESTOPS_BUILD_HASHED_ID; const filePath = path.join(tmpdir(), `abort_build_${buildUuid}`); + return fs.existsSync(filePath); } @@ -222,10 +224,10 @@ class OrchestrationUtils { // Normalize source to always be a list of paths if (source === null) { - this.smartSelectionSource = None; + this.smartSelectionSource = null; } else if (Array.isArray(source)) { this.smartSelectionSource = source; - } else if(typeof source === 'string' && source.endsWith('.json')) { + } else if (typeof source === 'string' && source.endsWith('.json')) { this.smartSelectionSource = this._loadSourceFromFile(source) || []; } @@ -244,6 +246,7 @@ class OrchestrationUtils { */ if (!fs.existsSync(filePath)) { this.logger.error(`Source file '${filePath}' does not exist.`); + return []; } @@ -253,6 +256,7 @@ class OrchestrationUtils { data = JSON.parse(fileContent); } catch (error) { this.logger.error(`Error parsing JSON from source file '${filePath}': ${error.message}`); + return []; } @@ -276,6 +280,7 @@ class OrchestrationUtils { if (key && value) { acc[key.trim()] = value.trim(); } + return acc; }, {}); } @@ -284,6 +289,7 @@ class OrchestrationUtils { } this.logger.debug(`Feature branch mappings from env: ${JSON.stringify(envMap)}`); + return envMap; }; @@ -300,6 +306,7 @@ class OrchestrationUtils { if (repoInfo.featureBranch) { return repoInfo.featureBranch; } + return null; }; @@ -329,7 +336,7 @@ class OrchestrationUtils { continue; } - const repoInfoCopy = { ...repoInfo }; + const repoInfoCopy = {...repoInfo}; repoInfoCopy.name = name; repoInfoCopy.featureBranch = getFeatureBranch(name, repoInfo); @@ -377,6 +384,7 @@ class OrchestrationUtils { if (this.testOrdering.getEnabled()) { return this.testOrdering.getName(); } + return null; } @@ -391,6 +399,7 @@ class OrchestrationUtils { 'source': this.getSmartSelectionSource() } }; + return data; } @@ -426,7 +435,7 @@ class OrchestrationUtils { nodeIndex: parseInt(process.env.BROWSERSTACK_NODE_INDEX || '0', 10), totalNodes: parseInt(process.env.BROWSERSTACK_TOTAL_NODE_COUNT || '1', 10), hostInfo: getHostInfo(), - frameworkName: "nightwatch" // TODO: Need to remove this after adding support in Test Orchestration Server + frameworkName: 'nightwatch' // TODO: Need to remove this after adding support in Test Orchestration Server }; this.logger.debug(`[collectBuildData] Sending build data payload: ${JSON.stringify(payload)}`); @@ -435,13 +444,16 @@ class OrchestrationUtils { if (response) { this.logger.debug(`[collectBuildData] Build data collection response: ${JSON.stringify(response)}`); + return response; - } else { - this.logger.error(`[collectBuildData] Failed to collect build data for build UUID: ${buildUuid}`); - return null; - } + } + this.logger.error(`[collectBuildData] Failed to collect build data for build UUID: ${buildUuid}`); + + return null; + } catch (e) { this.logger.error(`[collectBuildData] Exception in collecting build data for build UUID ${buildUuid}: ${e}`); + return null; } } diff --git a/src/testorchestration/requestUtils.js b/src/testorchestration/requestUtils.js index 83c635a..254dc9e 100644 --- a/src/testorchestration/requestUtils.js +++ b/src/testorchestration/requestUtils.js @@ -10,7 +10,8 @@ class RequestUtils { */ static async postCollectBuildData(reqEndpoint, data) { Logger.debug('Processing Request for postCollectBuildData'); - return RequestUtils.makeOrchestrationRequest('POST', reqEndpoint, { data }); + + return RequestUtils.makeOrchestrationRequest('POST', reqEndpoint, {data}); } /** @@ -18,7 +19,8 @@ class RequestUtils { */ static async testOrchestrationSplitTests(reqEndpoint, data) { Logger.debug('Processing Request for testOrchestrationSplitTests'); - return RequestUtils.makeOrchestrationRequest('POST', reqEndpoint, { data }); + + return RequestUtils.makeOrchestrationRequest('POST', reqEndpoint, {data}); } /** @@ -26,6 +28,7 @@ class RequestUtils { */ static async getTestOrchestrationOrderedTests(reqEndpoint, data) { Logger.debug('Processing Request for getTestOrchestrationOrderedTests'); + return RequestUtils.makeOrchestrationRequest('GET', reqEndpoint, {}); } @@ -38,6 +41,7 @@ class RequestUtils { // Validate JWT token if (!jwtToken) { Logger.error('BS_TESTOPS_JWT environment variable is not set. This is required for test orchestration.'); + return null; } diff --git a/src/testorchestration/testOrchestrationHandler.js b/src/testorchestration/testOrchestrationHandler.js index c89e90d..dc23fc4 100644 --- a/src/testorchestration/testOrchestrationHandler.js +++ b/src/testorchestration/testOrchestrationHandler.js @@ -27,6 +27,7 @@ class TestOrchestrationHandler { if (TestOrchestrationHandler._instance === null && config !== null) { TestOrchestrationHandler._instance = new TestOrchestrationHandler(config); } + return TestOrchestrationHandler._instance; } @@ -88,6 +89,7 @@ class TestOrchestrationHandler { try { if (!testFiles || testFiles.length === 0) { this.logger.debug('[reorderTestFiles] No test files provided for ordering.'); + return null; } @@ -96,6 +98,7 @@ class TestOrchestrationHandler { if (orchestrationStrategy === null) { this.logger.error('Orchestration strategy is None. Cannot proceed with test orchestration session.'); + return null; } @@ -115,6 +118,7 @@ class TestOrchestrationHandler { } catch (e) { this.logger.debug(`[reorderTestFiles] Error in ordering test classes: ${e}`); } + return null; } diff --git a/src/testorchestration/testOrchestrationIntegration.js b/src/testorchestration/testOrchestrationIntegration.js index e215694..c301a8e 100644 --- a/src/testorchestration/testOrchestrationIntegration.js +++ b/src/testorchestration/testOrchestrationIntegration.js @@ -1,4 +1,4 @@ -const { applyOrchestrationIfEnabled } = require('./applyOrchestration'); +const {applyOrchestrationIfEnabled} = require('./applyOrchestration'); const OrchestrationUtils = require('./orchestrationUtils'); const Logger = require('../utils/logger'); @@ -17,6 +17,7 @@ class TestOrchestrationIntegration { if (!TestOrchestrationIntegration._instance) { TestOrchestrationIntegration._instance = new TestOrchestrationIntegration(); } + return TestOrchestrationIntegration._instance; } @@ -42,6 +43,7 @@ class TestOrchestrationIntegration { async applyOrchestration(specs, settings) { if (!specs || !Array.isArray(specs) || specs.length === 0) { Logger.debug('No specs provided for test orchestration.'); + return specs; } @@ -51,13 +53,16 @@ class TestOrchestrationIntegration { if (orderedSpecs && orderedSpecs.length > 0 && orderedSpecs !== specs) { Logger.info(`Test orchestration applied. Spec order changed from [${specs.join(', ')}] to [${orderedSpecs.join(', ')}]`); + return orderedSpecs; - } else { - Logger.info('Test orchestration completed. No changes to spec order.'); - return specs; - } + } + Logger.info('Test orchestration completed. No changes to spec order.'); + + return specs; + } catch (error) { Logger.error(`Error applying test orchestration: ${error}`); + return specs; } } @@ -75,11 +80,13 @@ class TestOrchestrationIntegration { } else { Logger.debug('Build data collection returned no response.'); } + return response; } } catch (error) { Logger.error(`Error collecting build data: ${error}`); } + return null; } diff --git a/src/testorchestration/testOrderingServer.js b/src/testorchestration/testOrderingServer.js index b47d0b7..911e8a3 100644 --- a/src/testorchestration/testOrderingServer.js +++ b/src/testorchestration/testOrderingServer.js @@ -1,6 +1,6 @@ const path = require('path'); const Logger = require('../utils/logger'); -const { getHostInfo, getGitMetadataForAiSelection, getProjectName, getBuildName } = require('../utils/helper'); +const {getHostInfo, getGitMetadataForAiSelection, getProjectName, getBuildName} = require('../utils/helper'); const RequestUtils = require('./requestUtils'); const ORCHESTRATION_API_URL = 'https://collector-observability.browserstack.com'; @@ -41,7 +41,7 @@ class TestOrderingServer { } const payload = { - tests: testFiles.map(f => ({ filePath: f })), + tests: testFiles.map(f => ({filePath: f})), orchestrationStrategy, orchestrationMetadata, nodeIndex: parseInt(process.env.BROWSERSTACK_NODE_INDEX || '0'), @@ -112,11 +112,12 @@ class TestOrderingServer { async getOrderedTestFiles() { if (!this.requestData) { this.logger.error('[getOrderedTestFiles] No request data available to fetch ordered test files.'); + return null; } let testFilesJsonList = null; - let testFiles = []; + const testFiles = []; const startTimeMillis = Date.now(); const timeoutInterval = parseInt(String(this.requestData.timeoutInterval || this.defaultTimeoutInterval), 10); const timeoutMillis = parseInt(String(this.requestData.timeout || this.defaultTimeout), 10) * 1000; @@ -166,9 +167,11 @@ class TestOrderingServer { } this.logger.debug(`[getOrderedTestFiles] Ordered test files received: ${JSON.stringify(testFiles)}`); + return testFiles; } catch (e) { this.logger.error(`[getOrderedTestFiles] Exception in fetching ordered test files: ${e}`); + return null; } } diff --git a/src/utils/helper.js b/src/utils/helper.js index 57f3e01..d7fdd4d 100644 --- a/src/utils/helper.js +++ b/src/utils/helper.js @@ -15,7 +15,7 @@ const Logger = require('./logger'); const LogPatcher = require('./logPatcher'); const BSTestOpsPatcher = new LogPatcher({}); const sessions = {}; -const { execSync } = require('child_process'); +const {execSync} = require('child_process'); console = {}; Object.keys(consoleHolder).forEach(method => { @@ -864,7 +864,7 @@ exports.findFilesRecursively = (dir, pattern) => { return files; } - const entries = fs.readdirSync(dir, { withFileTypes: true }); + const entries = fs.readdirSync(dir, {withFileTypes: true}); for (const entry of entries) { const fullPath = path.join(dir, entry.name); @@ -924,9 +924,11 @@ exports.matchesGlobPattern = (filePath, pattern) => { try { const regex = new RegExp(regexPattern); + return regex.test(normalizedPath); } catch (err) { Logger.debug(`Error in glob pattern matching: ${err.message}`); + return false; } }; @@ -939,6 +941,7 @@ exports.collectTestFiles = (testPath, source = 'unknown') => { // Check if it's a glob pattern if (exports.isGlobPattern(testPath)) { Logger.debug(`Processing glob pattern: ${testPath}`); + return exports.expandGlobPattern(testPath); } @@ -952,11 +955,13 @@ exports.collectTestFiles = (testPath, source = 'unknown') => { if (stats.isFile()) { const relativePath = path.relative(process.cwd(), resolvedPath); Logger.debug(`Found test file: ${relativePath}`); + return [relativePath]; } else if (stats.isDirectory()) { // For directories, find all supported test files const files = exports.findTestFilesInDirectory(resolvedPath); Logger.debug(`Found ${files.length} test files in directory: ${testPath}`); + return files; } } else { @@ -965,6 +970,7 @@ exports.collectTestFiles = (testPath, source = 'unknown') => { } catch (err) { Logger.debug(`Could not collect test files from ${testPath} (${source}): ${err.message}`); } + return []; }; @@ -974,7 +980,7 @@ exports.findTestFilesInDirectory = (dir) => { const supportedExtensions = ['.js', '.feature']; try { - const entries = fs.readdirSync(dir, { recursive: true }); + const entries = fs.readdirSync(dir, {recursive: true}); for (const entry of entries) { if (typeof entry === 'string') { @@ -999,7 +1005,7 @@ exports.expandGlobPattern = (pattern) => { Logger.debug(`Expanding glob pattern: ${pattern}`); // Extract the base directory from the pattern - const parts = pattern.split(/[\/\\]/); + const parts = pattern.split(/[/\\]/); let baseDir = '.'; let patternStart = 0; @@ -1064,6 +1070,7 @@ function getBaseBranch() { if (branches.length > 0) { // Remove the '* ' from current branch if present and return first branch const firstBranch = branches[0].replace(/^\*\s+/, '').trim(); + return firstBranch; } } catch (e) { @@ -1290,4 +1297,4 @@ exports.getGitMetadataForAiSelection = (folders = []) => { })); return formattedResults; -} \ No newline at end of file +}; \ No newline at end of file diff --git a/src/utils/requestHelper.js b/src/utils/requestHelper.js index 9ada379..29a0e2b 100644 --- a/src/utils/requestHelper.js +++ b/src/utils/requestHelper.js @@ -35,6 +35,7 @@ exports.makeRequest = (type, url, data, config, requestUrl=API_URL, jsonResponse agent }; const acceptedStatusCodes = [200, 201, 202]; + return new Promise((resolve, reject) => { request(options, function callback(error, response, body) { if (error) { From c69da11cef4244b9f460ab3c8c4ad4f6ef86049a Mon Sep 17 00:00:00 2001 From: harshit-bstack Date: Tue, 28 Oct 2025 14:10:28 +0530 Subject: [PATCH 11/27] feat: Remove framework name from build data payload in OrchestrationUtils --- src/testorchestration/orchestrationUtils.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/testorchestration/orchestrationUtils.js b/src/testorchestration/orchestrationUtils.js index 7e6d0a0..c63e2dd 100644 --- a/src/testorchestration/orchestrationUtils.js +++ b/src/testorchestration/orchestrationUtils.js @@ -434,8 +434,7 @@ class OrchestrationUtils { buildRunIdentifier: this.getBuildIdentifier(), nodeIndex: parseInt(process.env.BROWSERSTACK_NODE_INDEX || '0', 10), totalNodes: parseInt(process.env.BROWSERSTACK_TOTAL_NODE_COUNT || '1', 10), - hostInfo: getHostInfo(), - frameworkName: 'nightwatch' // TODO: Need to remove this after adding support in Test Orchestration Server + hostInfo: getHostInfo() }; this.logger.debug(`[collectBuildData] Sending build data payload: ${JSON.stringify(payload)}`); From ee4fbc1dbfb4b6dd3d93d481ce9129a4ab63e6a7 Mon Sep 17 00:00:00 2001 From: harshit-bstack Date: Tue, 28 Oct 2025 18:25:18 +0530 Subject: [PATCH 12/27] feat: Enhance build details extraction by adding observability options --- src/testorchestration/orchestrationUtils.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/testorchestration/orchestrationUtils.js b/src/testorchestration/orchestrationUtils.js index c63e2dd..c2630f7 100644 --- a/src/testorchestration/orchestrationUtils.js +++ b/src/testorchestration/orchestrationUtils.js @@ -98,9 +98,12 @@ class OrchestrationUtils { */ _extractBuildDetails() { try { - this.buildName = helper.getBuildName(this._settings, this._bstackOptions) || ''; + const fromProduct = { + test_observability: true + }; + this.buildName = helper.getBuildName(this._settings, this._bstackOptions, fromProduct) || ''; - this.projectName = helper.getProjectName(this._settings, this._bstackOptions) || ''; + this.projectName = helper.getProjectName(this._settings, this._bstackOptions, fromProduct) || ''; this.buildIdentifier = process.env.BROWSERSTACK_BUILD_RUN_IDENTIFIER || ''; From e235cf75ed4ce9b642f514f55d8a1e9edc0653fe Mon Sep 17 00:00:00 2001 From: harshit-bstack Date: Tue, 28 Oct 2025 18:35:51 +0530 Subject: [PATCH 13/27] feat: Add test observability options to project and build name retrieval --- src/testorchestration/testOrderingServer.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/testorchestration/testOrderingServer.js b/src/testorchestration/testOrderingServer.js index 911e8a3..f0a21eb 100644 --- a/src/testorchestration/testOrderingServer.js +++ b/src/testorchestration/testOrderingServer.js @@ -25,7 +25,11 @@ class TestOrderingServer { if (config && config.desiredCapabilities && config.desiredCapabilities['bstack:options']) { this._bstackOptions = config.desiredCapabilities['bstack:options']; } + this.fromProduct = { + test_observability: true + }; } + /** * Initiates the split tests request and stores the response data for polling. @@ -46,8 +50,8 @@ class TestOrderingServer { orchestrationMetadata, nodeIndex: parseInt(process.env.BROWSERSTACK_NODE_INDEX || '0'), totalNodes: parseInt(process.env.BROWSERSTACK_TOTAL_NODE_COUNT || '1'), - projectName: getProjectName(this._settings, this._bstackOptions), - buildName: getBuildName(this._settings, this._bstackOptions), + projectName: getProjectName(this._settings, this._bstackOptions, this.fromProduct), + buildName: getBuildName(this._settings, this._bstackOptions, this.fromProduct), buildRunIdentifier: process.env.BROWSERSTACK_BUILD_RUN_IDENTIFIER || '', hostInfo: getHostInfo(), prDetails From f56a9f418becdb2b0a143c5d78cfd08447cc7f57 Mon Sep 17 00:00:00 2001 From: harshit-bstack Date: Wed, 29 Oct 2025 12:53:13 +0530 Subject: [PATCH 14/27] feat: Enhance test orchestration by adding observability session check and improve git metadata handling --- nightwatch/globals.js | 2 +- src/testorchestration/orchestrationUtils.js | 4 ++-- src/utils/helper.js | 4 ++++ 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/nightwatch/globals.js b/nightwatch/globals.js index faf0d28..c28b6c0 100644 --- a/nightwatch/globals.js +++ b/nightwatch/globals.js @@ -328,7 +328,7 @@ module.exports = { // Initialize and configure test orchestration try { const orchestrationUtils = OrchestrationUtils.getInstance(settings); - if (orchestrationUtils && orchestrationUtils.testOrderingEnabled()) { + if (orchestrationUtils && orchestrationUtils.testOrderingEnabled() && helper.isTestObservabilitySession()) { // Apply test orchestration to reorder test files before execution const TestOrchestrationIntegration = require('../src/testorchestration/testOrchestrationIntegration'); const orchestrationIntegration = TestOrchestrationIntegration.getInstance(); diff --git a/src/testorchestration/orchestrationUtils.js b/src/testorchestration/orchestrationUtils.js index c2630f7..b4191a6 100644 --- a/src/testorchestration/orchestrationUtils.js +++ b/src/testorchestration/orchestrationUtils.js @@ -67,7 +67,7 @@ class OrchestrationUtils { this._setRunSmartSelection( runSmartSelectionOpts.enabled || false, runSmartSelectionOpts.mode || 'relevantFirst', - runSmartSelectionOpts.source || null + runSmartSelectionOpts.source ); // Extract build details @@ -221,7 +221,7 @@ class OrchestrationUtils { try { this.runSmartSelection = Boolean(enabled); this.smartSelectionMode = mode; - + this.smartSelectionSource = []; // Log the configuration for debugging this.logger.debug(`Setting runSmartSelection: enabled=${this.runSmartSelection}, mode=${this.smartSelectionMode}`); diff --git a/src/utils/helper.js b/src/utils/helper.js index d7fdd4d..1054240 100644 --- a/src/utils/helper.js +++ b/src/utils/helper.js @@ -1134,7 +1134,11 @@ function getChangedFilesFromCommits(commitHashes) { * @param multiRepoSource Array of repository paths for multi-repo setup */ exports.getGitMetadataForAiSelection = (folders = []) => { + if (folders && folders.length === 0) { + return []; + } + if (folders === null){ folders = [process.cwd()]; } From 7b0784da79defc7b5884521b89df8e6eab067f78 Mon Sep 17 00:00:00 2001 From: harshit-bstack Date: Wed, 29 Oct 2025 15:02:17 +0530 Subject: [PATCH 15/27] feat: Add test observability session check to build data collection in orchestration --- nightwatch/globals.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/nightwatch/globals.js b/nightwatch/globals.js index c28b6c0..345e18c 100644 --- a/nightwatch/globals.js +++ b/nightwatch/globals.js @@ -9,7 +9,6 @@ const path = require('path'); const AccessibilityAutomation = require('../src/accessibilityAutomation'); const eventHelper = require('../src/utils/eventHelper'); const OrchestrationUtils = require('../src/testorchestration/orchestrationUtils'); -const {type} = require('os'); const localTunnel = new LocalTunnel(); const testObservability = new TestObservability(); const accessibilityAutomation = new AccessibilityAutomation(); @@ -435,7 +434,7 @@ module.exports = { // Collect build data for test orchestration if enabled try { const orchestrationUtils = OrchestrationUtils.getInstance(); - if (orchestrationUtils && orchestrationUtils.testOrderingEnabled()) { + if (orchestrationUtils && orchestrationUtils.testOrderingEnabled() && helper.isTestObservabilitySession()) { Logger.info('Collecting build data for test orchestration...'); await orchestrationUtils.collectBuildData(this.settings || {}); } From 32eac85a65d94660b2d081b094aef03066eee11b Mon Sep 17 00:00:00 2001 From: harshit-bstack Date: Wed, 29 Oct 2025 17:20:31 +0530 Subject: [PATCH 16/27] feat: Refactor test orchestration logic to improve readability and maintainability --- nightwatch/globals.js | 150 ++++++++++++++++++++++-------------------- 1 file changed, 78 insertions(+), 72 deletions(-) diff --git a/nightwatch/globals.js b/nightwatch/globals.js index 345e18c..359de29 100644 --- a/nightwatch/globals.js +++ b/nightwatch/globals.js @@ -326,83 +326,86 @@ module.exports = { // Initialize and configure test orchestration try { - const orchestrationUtils = OrchestrationUtils.getInstance(settings); - if (orchestrationUtils && orchestrationUtils.testOrderingEnabled() && helper.isTestObservabilitySession()) { - // Apply test orchestration to reorder test files before execution - const TestOrchestrationIntegration = require('../src/testorchestration/testOrchestrationIntegration'); - const orchestrationIntegration = TestOrchestrationIntegration.getInstance(); - orchestrationIntegration.configure(settings); - - // Check if we have test files to reorder from various sources - let allTestFiles = []; - - // Checking either for Feature Path or src_folders, feature path take priority - if (helper.isCucumberTestSuite(settings) && settings?.test_runner?.options?.feature_path){ - Logger.debug('Getting test files from feature_path configuration...'); - if (Array.isArray(settings.test_runner.options.feature_path)){ - settings.test_runner.options.feature_path.forEach(featurePath => { - const files = helper.collectTestFiles(featurePath, 'feature_path config'); + if (helper.isTestObservabilitySession()) { + const orchestrationUtils = OrchestrationUtils.getInstance(settings); + if (orchestrationUtils && orchestrationUtils.testOrderingEnabled()){ + // Apply test orchestration to reorder test files before execution + const TestOrchestrationIntegration = require('../src/testorchestration/testOrchestrationIntegration'); + const orchestrationIntegration = TestOrchestrationIntegration.getInstance(); + orchestrationIntegration.configure(settings); + + // Check if we have test files to reorder from various sources + let allTestFiles = []; + + // Checking either for Feature Path or src_folders, feature path take priority + if (helper.isCucumberTestSuite(settings) && settings?.test_runner?.options?.feature_path){ + Logger.debug('Getting test files from feature_path configuration...'); + if (Array.isArray(settings.test_runner.options.feature_path)){ + settings.test_runner.options.feature_path.forEach(featurePath => { + const files = helper.collectTestFiles(featurePath, 'feature_path config'); + allTestFiles = allTestFiles.concat(files); + }); + } else if (typeof settings.test_runner.options.feature_path === 'string'){ + const files = helper.collectTestFiles(settings.test_runner.options.feature_path, 'feature_path config'); + allTestFiles = allTestFiles.concat(files); + } + } else if (settings.src_folders && Array.isArray(settings.src_folders) && settings.src_folders.length > 0) { + Logger.debug('Getting test files from src_folders configuration...'); + settings.src_folders.forEach(folder => { + const files = helper.collectTestFiles(folder, 'src_folders config'); allTestFiles = allTestFiles.concat(files); }); - } else if (typeof settings.test_runner.options.feature_path === 'string'){ - const files = helper.collectTestFiles(settings.test_runner.options.feature_path, 'feature_path config'); - allTestFiles = allTestFiles.concat(files); } - } else if (settings.src_folders && Array.isArray(settings.src_folders) && settings.src_folders.length > 0) { - Logger.debug('Getting test files from src_folders configuration...'); - settings.src_folders.forEach(folder => { - const files = helper.collectTestFiles(folder, 'src_folders config'); - allTestFiles = allTestFiles.concat(files); - }); - } - // Remove duplicates and ensure all paths are relative to cwd - allTestFiles = [...new Set(allTestFiles)].map(file => { - return path.isAbsolute(file) ? path.relative(process.cwd(), file) : file; - }); + // Remove duplicates and ensure all paths are relative to cwd + allTestFiles = [...new Set(allTestFiles)].map(file => { + return path.isAbsolute(file) ? path.relative(process.cwd(), file) : file; + }); - if (allTestFiles.length > 0) { - Logger.info(`Applying test orchestration to reorder test files... Found ${allTestFiles.length} test files`); - Logger.debug(`Test files: ${JSON.stringify(allTestFiles)}`); - - // Apply orchestration to get ordered test files (synchronously) - try { - const orderedFiles = await orchestrationIntegration.applyOrchestration(allTestFiles, settings); - if (orderedFiles && orderedFiles.length > 0) { - Logger.info(`Test files reordered by orchestration: ${orderedFiles.length} files`); - - Logger.info('Test orchestration recommended order change:'); - Logger.info(`Original: ${allTestFiles.join(', ')}`); - Logger.info(`Optimized: ${orderedFiles.join(', ')}`); - - try { - if (helper.isCucumberTestSuite(settings) && settings?.test_runner?.options?.feature_path){ - // For cucumber, we override the feature_path option with ordered files - settings.test_runner.options['feature_path'] = orderedFiles; - } else { - settings.src_folders = orderedFiles; - for (const envName in testEnvSettings) { - testEnvSettings[envName].src_folders = orderedFiles; - testEnvSettings[envName].test_runner.src_folders = orderedFiles; + if (allTestFiles.length > 0) { + Logger.info(`Applying test orchestration to reorder test files... Found ${allTestFiles.length} test files`); + Logger.debug(`Test files: ${JSON.stringify(allTestFiles)}`); + + // Apply orchestration to get ordered test files (synchronously) + try { + const orderedFiles = await orchestrationIntegration.applyOrchestration(allTestFiles, settings); + if (orderedFiles && orderedFiles.length > 0) { + Logger.info(`Test files reordered by orchestration: ${orderedFiles.length} files`); + + Logger.info('Test orchestration recommended order change:'); + Logger.info(`Original: ${allTestFiles.join(', ')}`); + Logger.info(`Optimized: ${orderedFiles.join(', ')}`); + + try { + if (helper.isCucumberTestSuite(settings) && settings?.test_runner?.options?.feature_path){ + // For cucumber, we override the feature_path option with ordered files + settings.test_runner.options['feature_path'] = orderedFiles; + } else { + settings.src_folders = orderedFiles; + for (const envName in testEnvSettings) { + testEnvSettings[envName].src_folders = orderedFiles; + testEnvSettings[envName].test_runner.src_folders = orderedFiles; + } + if (settings.test_runner && typeof settings.test_runner === 'object' && !Array.isArray(settings.test_runner)) { + settings.test_runner.src_folders = orderedFiles; + } } - if (settings.test_runner && typeof settings.test_runner === 'object' && !Array.isArray(settings.test_runner)) { - settings.test_runner.src_folders = orderedFiles; - } - } - - } catch (reorderError) { - Logger.error(`Runtime reordering failed: ${reorderError.message}`); - Logger.info('Falling back to original order for current execution.'); - } - } else { - Logger.info('Split test API called - no reordering available'); + + } catch (reorderError) { + Logger.error(`Runtime reordering failed: ${reorderError.message}`); + Logger.info('Falling back to original order for current execution.'); + } + } else { + Logger.info('Split test API called - no reordering available'); + } + } catch (error) { + Logger.error(`Error applying test orchestration: ${error}`); } - } catch (error) { - Logger.error(`Error applying test orchestration: ${error}`); + + } else { + Logger.debug('No test files found for orchestration - skipping split test API call'); } - } else { - Logger.debug('No test files found for orchestration - skipping split test API call'); } } } catch (error) { @@ -433,10 +436,13 @@ module.exports = { // Collect build data for test orchestration if enabled try { - const orchestrationUtils = OrchestrationUtils.getInstance(); - if (orchestrationUtils && orchestrationUtils.testOrderingEnabled() && helper.isTestObservabilitySession()) { - Logger.info('Collecting build data for test orchestration...'); - await orchestrationUtils.collectBuildData(this.settings || {}); + if (helper.isTestObservabilitySession()) { + const orchestrationUtils = OrchestrationUtils.getInstance(); + if (orchestrationUtils && orchestrationUtils.testOrderingEnabled()){ + + Logger.info('Collecting build data for test orchestration...'); + await orchestrationUtils.collectBuildData(this.settings || {}); + } } } catch (error) { Logger.error(`Error collecting build data for test orchestration: ${error}`); From 7c16d677c078c0b47dd6adc98cc3f830fa813bf1 Mon Sep 17 00:00:00 2001 From: harshit-bstack Date: Thu, 30 Oct 2025 13:53:49 +0530 Subject: [PATCH 17/27] feat: Validate smart selection mode in run smart selection settings --- src/testorchestration/orchestrationUtils.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/testorchestration/orchestrationUtils.js b/src/testorchestration/orchestrationUtils.js index b4191a6..f93bd28 100644 --- a/src/testorchestration/orchestrationUtils.js +++ b/src/testorchestration/orchestrationUtils.js @@ -220,7 +220,12 @@ class OrchestrationUtils { _setRunSmartSelection(enabled, mode, source = null) { try { this.runSmartSelection = Boolean(enabled); - this.smartSelectionMode = mode; + if (['relevantFirst', 'relevantOnly'].includes(mode)) { + this.smartSelectionMode = mode; + } else { + this.smartSelectionMode = 'relevantFirst'; + } + this.smartSelectionSource = []; // Log the configuration for debugging this.logger.debug(`Setting runSmartSelection: enabled=${this.runSmartSelection}, mode=${this.smartSelectionMode}`); From 37627bf8921691b1f8dbb8c835216e1e66ddcafe Mon Sep 17 00:00:00 2001 From: harshit-bstack Date: Thu, 30 Oct 2025 13:54:02 +0530 Subject: [PATCH 18/27] fix: Corrected base branch retrieval by updating the string replacement for origin reference --- src/utils/helper.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/helper.js b/src/utils/helper.js index 1054240..4202d44 100644 --- a/src/utils/helper.js +++ b/src/utils/helper.js @@ -1057,7 +1057,7 @@ function getBaseBranch() { try { const originHeadOutput = execSync('git symbolic-ref refs/remotes/origin/HEAD').toString().trim(); if (originHeadOutput.startsWith('refs/remotes/origin/')) { - return originHeadOutput.replace('refs/remotes/', ''); + return originHeadOutput.replace('refs/remotes/origin/', ''); } } catch (e) { // Symbolic ref might not exist From b84700ff7929ac87e76f9b73dbd027803edff78b Mon Sep 17 00:00:00 2001 From: harshit-bstack Date: Thu, 30 Oct 2025 14:22:24 +0530 Subject: [PATCH 19/27] refactor: Change log level from info to debug for test orchestration messages --- nightwatch/globals.js | 6 +----- src/testorchestration/applyOrchestration.js | 6 +----- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/nightwatch/globals.js b/nightwatch/globals.js index 359de29..38ade3e 100644 --- a/nightwatch/globals.js +++ b/nightwatch/globals.js @@ -364,7 +364,7 @@ module.exports = { if (allTestFiles.length > 0) { - Logger.info(`Applying test orchestration to reorder test files... Found ${allTestFiles.length} test files`); + Logger.debug(`Applying test orchestration to reorder test files... Found ${allTestFiles.length} test files`); Logger.debug(`Test files: ${JSON.stringify(allTestFiles)}`); // Apply orchestration to get ordered test files (synchronously) @@ -372,10 +372,6 @@ module.exports = { const orderedFiles = await orchestrationIntegration.applyOrchestration(allTestFiles, settings); if (orderedFiles && orderedFiles.length > 0) { Logger.info(`Test files reordered by orchestration: ${orderedFiles.length} files`); - - Logger.info('Test orchestration recommended order change:'); - Logger.info(`Original: ${allTestFiles.join(', ')}`); - Logger.info(`Optimized: ${orderedFiles.join(', ')}`); try { if (helper.isCucumberTestSuite(settings) && settings?.test_runner?.options?.feature_path){ diff --git a/src/testorchestration/applyOrchestration.js b/src/testorchestration/applyOrchestration.js index 7c837ab..19e96ac 100644 --- a/src/testorchestration/applyOrchestration.js +++ b/src/testorchestration/applyOrchestration.js @@ -9,7 +9,6 @@ const TestOrchestrationHandler = require('./testOrchestrationHandler'); async function applyOrchestrationIfEnabled(specs, config) { // Initialize orchestration handler const orchestrationHandler = TestOrchestrationHandler.getInstance(config); - Logger.info('Orchestration handler is initialized'); if (!orchestrationHandler) { Logger.warn('Orchestration handler is not initialized. Skipping orchestration.'); @@ -32,13 +31,10 @@ async function applyOrchestrationIfEnabled(specs, config) { orchestrationHandler.addToOrderingInstrumentationData('enabled', orchestrationHandler.testOrderingEnabled()); const startTime = performance.now(); - - Logger.info('Test orchestration is enabled. Attempting to reorder test files.'); - + // Get the test files from the specs const testFiles = specs; testOrderingApplied = true; - Logger.info(`Test files to be reordered: ${testFiles.join(', ')}`); // Reorder the test files const orderedFiles = await orchestrationHandler.reorderTestFiles(testFiles); From badf8acfd5a4407270bf35f7082288473d26672d Mon Sep 17 00:00:00 2001 From: harshit-bstack Date: Thu, 30 Oct 2025 17:24:06 +0530 Subject: [PATCH 20/27] feat: Integrate orchestration data retrieval into test observability and refactor getBuildStartData method --- src/testObservability.js | 8 +++++++- src/testorchestration/orchestrationUtils.js | 5 ++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/testObservability.js b/src/testObservability.js index 56ca7e2..c6efdaa 100644 --- a/src/testObservability.js +++ b/src/testObservability.js @@ -8,6 +8,7 @@ const {makeRequest} = require('./utils/requestHelper'); const CrashReporter = require('./utils/crashReporter'); const Logger = require('./utils/logger'); const {API_URL, TAKE_SCREENSHOT_REGEX} = require('./utils/constants'); +const OrchestrationUtils = require('./testorchestration/orchestrationUtils') const hooksMap = {}; class TestObservability { @@ -111,7 +112,8 @@ class TestObservability { frameworkName: helper.getFrameworkName(this._testRunner), frameworkVersion: helper.getPackageVersion('nightwatch'), sdkVersion: helper.getAgentVersion() - } + }, + test_orchestration: this.getTestOrchestrationBuildStartData(this._settings) }; const config = { @@ -151,6 +153,10 @@ class TestObservability { process.env.BROWSERSTACK_TEST_REPORTING = false; } } + getTestOrchestrationBuildStartData(settings) { + const orchestrationUtils = OrchestrationUtils.getInstance(settings); + return orchestrationUtils.getBuildStartData(); + } async stopBuildUpstream () { if (!process.env.BS_TESTOPS_BUILD_COMPLETED) { diff --git a/src/testorchestration/orchestrationUtils.js b/src/testorchestration/orchestrationUtils.js index f93bd28..1642154 100644 --- a/src/testorchestration/orchestrationUtils.js +++ b/src/testorchestration/orchestrationUtils.js @@ -2,7 +2,6 @@ const path = require('path'); const fs = require('fs'); const {tmpdir} = require('os'); const Logger = require('../utils/logger'); -const {getHostInfo} = require('../utils/helper'); const RequestUtils = require('./requestUtils'); const helper = require('../utils/helper'); // Constants @@ -414,7 +413,7 @@ class OrchestrationUtils { /** * Get build start data */ - getBuildStartData(config) { + getBuildStartData() { const testOrchestrationData = {}; testOrchestrationData['run_smart_selection'] = { @@ -442,7 +441,7 @@ class OrchestrationUtils { buildRunIdentifier: this.getBuildIdentifier(), nodeIndex: parseInt(process.env.BROWSERSTACK_NODE_INDEX || '0', 10), totalNodes: parseInt(process.env.BROWSERSTACK_TOTAL_NODE_COUNT || '1', 10), - hostInfo: getHostInfo() + hostInfo: helper.getHostInfo() }; this.logger.debug(`[collectBuildData] Sending build data payload: ${JSON.stringify(payload)}`); From 6c86c9c04021b1c5a3c7bd3e860881c96ed9cc20 Mon Sep 17 00:00:00 2001 From: harshit-bstack Date: Thu, 30 Oct 2025 18:19:14 +0530 Subject: [PATCH 21/27] fix: Correct syntax by adding missing semicolon in testObservability.js --- src/testObservability.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/testObservability.js b/src/testObservability.js index c6efdaa..3bf458b 100644 --- a/src/testObservability.js +++ b/src/testObservability.js @@ -8,7 +8,7 @@ const {makeRequest} = require('./utils/requestHelper'); const CrashReporter = require('./utils/crashReporter'); const Logger = require('./utils/logger'); const {API_URL, TAKE_SCREENSHOT_REGEX} = require('./utils/constants'); -const OrchestrationUtils = require('./testorchestration/orchestrationUtils') +const OrchestrationUtils = require('./testorchestration/orchestrationUtils'); const hooksMap = {}; class TestObservability { @@ -154,7 +154,8 @@ class TestObservability { } } getTestOrchestrationBuildStartData(settings) { - const orchestrationUtils = OrchestrationUtils.getInstance(settings); + const orchestrationUtils = OrchestrationUtils.getInstance(settings); + return orchestrationUtils.getBuildStartData(); } From 79736046cf38d2b47ba39ef81fa33de3777d938c Mon Sep 17 00:00:00 2001 From: harshit-bstack Date: Fri, 31 Oct 2025 17:26:10 +0530 Subject: [PATCH 22/27] fix: Update test_orchestration data retrieval to use parent settings --- src/testObservability.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/testObservability.js b/src/testObservability.js index 3bf458b..eb04010 100644 --- a/src/testObservability.js +++ b/src/testObservability.js @@ -113,7 +113,7 @@ class TestObservability { frameworkVersion: helper.getPackageVersion('nightwatch'), sdkVersion: helper.getAgentVersion() }, - test_orchestration: this.getTestOrchestrationBuildStartData(this._settings) + test_orchestration: this.getTestOrchestrationBuildStartData(this._parentSettings) }; const config = { From 513c50c1253f250ff41c85dbe42f1d9434d2f2d9 Mon Sep 17 00:00:00 2001 From: harshit-bstack Date: Mon, 3 Nov 2025 18:57:23 +0530 Subject: [PATCH 23/27] fix: Enhance feature branch validation in OrchestrationUtils --- src/testorchestration/orchestrationUtils.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/testorchestration/orchestrationUtils.js b/src/testorchestration/orchestrationUtils.js index 1642154..c3082ee 100644 --- a/src/testorchestration/orchestrationUtils.js +++ b/src/testorchestration/orchestrationUtils.js @@ -347,7 +347,7 @@ class OrchestrationUtils { repoInfoCopy.name = name; repoInfoCopy.featureBranch = getFeatureBranch(name, repoInfo); - if (!repoInfoCopy.featureBranch) { + if (!repoInfoCopy.featureBranch || repoInfoCopy.featureBranch === '') { this.logger.warn(`Feature branch not specified for source '${name}': ${JSON.stringify(repoInfo)}`); continue; } From ecdd133803c71bcf59099da3ff74d0c819a3c238 Mon Sep 17 00:00:00 2001 From: harshit-bstack Date: Tue, 4 Nov 2025 19:39:04 +0530 Subject: [PATCH 24/27] fix: Correct base branch retrieval by adjusting symbolic ref path --- src/utils/helper.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/helper.js b/src/utils/helper.js index 4202d44..1054240 100644 --- a/src/utils/helper.js +++ b/src/utils/helper.js @@ -1057,7 +1057,7 @@ function getBaseBranch() { try { const originHeadOutput = execSync('git symbolic-ref refs/remotes/origin/HEAD').toString().trim(); if (originHeadOutput.startsWith('refs/remotes/origin/')) { - return originHeadOutput.replace('refs/remotes/origin/', ''); + return originHeadOutput.replace('refs/remotes/', ''); } } catch (e) { // Symbolic ref might not exist From 91e2851e2a4edbd215a4bdd1476ffb5521c9c8f2 Mon Sep 17 00:00:00 2001 From: harshit-bstack Date: Tue, 4 Nov 2025 19:46:24 +0530 Subject: [PATCH 25/27] fix: Validate repository URL to ensure it is not empty or whitespace --- src/testorchestration/orchestrationUtils.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/testorchestration/orchestrationUtils.js b/src/testorchestration/orchestrationUtils.js index c3082ee..46c32e0 100644 --- a/src/testorchestration/orchestrationUtils.js +++ b/src/testorchestration/orchestrationUtils.js @@ -326,7 +326,7 @@ class OrchestrationUtils { continue; } - if (!repoInfo.url) { + if (!repoInfo.url || String(repoInfo.url).trim() === '') { this.logger.warn(`Repository URL is missing for source '${name}': ${JSON.stringify(repoInfo)}`); continue; } From a6805e5b70d50df8258d30fbbad75a7746369bda Mon Sep 17 00:00:00 2001 From: harshit-bstack Date: Wed, 5 Nov 2025 15:59:19 +0530 Subject: [PATCH 26/27] fix: Enhance feature branch retrieval by adding null and type checks --- src/testorchestration/orchestrationUtils.js | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/testorchestration/orchestrationUtils.js b/src/testorchestration/orchestrationUtils.js index 46c32e0..a7b7b27 100644 --- a/src/testorchestration/orchestrationUtils.js +++ b/src/testorchestration/orchestrationUtils.js @@ -306,12 +306,14 @@ class OrchestrationUtils { const getFeatureBranch = (name, repoInfo) => { // 1. Check in environment variable map - if (featureBranchEnvMap[name]) { - return featureBranchEnvMap[name]; + if (featureBranchEnvMap && featureBranchEnvMap[name]) { + const val = featureBranchEnvMap[name]; + return typeof val === 'string' ? val.trim() : val; } // 2. Check in repo_info - if (repoInfo.featureBranch) { - return repoInfo.featureBranch; + if (repoInfo && repoInfo.featureBranch) { + const val = repoInfo.featureBranch; + return typeof val === 'string' ? val.trim() : val; } return null; From 21d5e356a1ff63b16a95a31e99d9f323884aca6a Mon Sep 17 00:00:00 2001 From: harshit-bstack Date: Wed, 5 Nov 2025 16:00:45 +0530 Subject: [PATCH 27/27] fix: eslint fix --- src/testorchestration/orchestrationUtils.js | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/testorchestration/orchestrationUtils.js b/src/testorchestration/orchestrationUtils.js index a7b7b27..c9965f6 100644 --- a/src/testorchestration/orchestrationUtils.js +++ b/src/testorchestration/orchestrationUtils.js @@ -307,13 +307,15 @@ class OrchestrationUtils { const getFeatureBranch = (name, repoInfo) => { // 1. Check in environment variable map if (featureBranchEnvMap && featureBranchEnvMap[name]) { - const val = featureBranchEnvMap[name]; - return typeof val === 'string' ? val.trim() : val; + const val = featureBranchEnvMap[name]; + + return typeof val === 'string' ? val.trim() : val; } // 2. Check in repo_info if (repoInfo && repoInfo.featureBranch) { - const val = repoInfo.featureBranch; - return typeof val === 'string' ? val.trim() : val; + const val = repoInfo.featureBranch; + + return typeof val === 'string' ? val.trim() : val; } return null;