diff --git a/bin/accessibility-automation/helper.js b/bin/accessibility-automation/helper.js index d5ba4fa2..36d484c4 100644 --- a/bin/accessibility-automation/helper.js +++ b/bin/accessibility-automation/helper.js @@ -109,7 +109,7 @@ exports.createAccessibilityTestRun = async (user_config, framework) => { logger.debug(`BrowserStack Accessibility Automation Test Run ID: ${response.data.data.id}`); this.setAccessibilityCypressCapabilities(user_config, response.data); - helper.setBrowserstackCypressCliDependency(user_config); + if(user_config.run_settings.auto_import_dev_dependencies != true) helper.setBrowserstackCypressCliDependency(user_config); } catch (error) { if (error.response) { diff --git a/bin/commands/runs.js b/bin/commands/runs.js index 9530bd50..103edfba 100644 --- a/bin/commands/runs.js +++ b/bin/commands/runs.js @@ -143,6 +143,9 @@ module.exports = function run(args, rawArgs) { // set the no-wrap utils.setNoWrap(bsConfig, args); + // process auto-import dev dependencies + utils.processAutoImportDependencies(bsConfig.run_settings); + // add cypress dependency if missing utils.setCypressNpmDependency(bsConfig); diff --git a/bin/helpers/build.js b/bin/helpers/build.js index c05634df..fc1bb4bf 100644 --- a/bin/helpers/build.js +++ b/bin/helpers/build.js @@ -65,6 +65,8 @@ const createBuild = (bsConfig, zip) => { if(error.response) { logger.error(utils.formatRequest(error.response.statusText, error.response, error.response.data)); reject(`${Constants.userMessages.BUILD_FAILED} Error: ${error.response.data.message}`); + } else { + reject(error); } } }).catch(function(err){ diff --git a/bin/helpers/capabilityHelper.js b/bin/helpers/capabilityHelper.js index 425f7e43..f380b981 100644 --- a/bin/helpers/capabilityHelper.js +++ b/bin/helpers/capabilityHelper.js @@ -183,11 +183,17 @@ const getAccessibilityPlatforms = (bsConfig) => { const addCypressZipStartLocation = (runSettings) => { let resolvedHomeDirectoryPath = path.resolve(runSettings.home_directory); let resolvedCypressConfigFilePath = path.resolve(runSettings.cypressConfigFilePath); - runSettings.cypressZipStartLocation = path.dirname(resolvedCypressConfigFilePath.split(resolvedHomeDirectoryPath)[1]); + + // Convert to POSIX style paths for consistent behavior + let posixHomePath = resolvedHomeDirectoryPath.split(path.sep).join(path.posix.sep); + let posixConfigPath = resolvedCypressConfigFilePath.split(path.sep).join(path.posix.sep); + + runSettings.cypressZipStartLocation = path.posix.dirname(posixConfigPath.split(posixHomePath)[1]); runSettings.cypressZipStartLocation = runSettings.cypressZipStartLocation.substring(1); logger.debug(`Setting cypress zip start location = ${runSettings.cypressZipStartLocation}`); } + const validate = (bsConfig, args) => { return new Promise(function (resolve, reject) { logger.info(Constants.userMessages.VALIDATING_CONFIG); diff --git a/bin/helpers/constants.js b/bin/helpers/constants.js index 2b0e83bf..d55dc988 100644 --- a/bin/helpers/constants.js +++ b/bin/helpers/constants.js @@ -196,6 +196,28 @@ const validationMessages = { "You have specified '--record' flag but you've not provided the '--record-key' and we could not find any value in 'CYPRESS_RECORD_KEY' environment variable. Your record functionality on cypress.io dashboard might not work as it needs the key and projectId", NODE_VERSION_PARSING_ERROR: "We weren't able to successfully parse the specified nodeVersion. We will be using the default nodeVersion to run your tests.", + AUTO_IMPORT_CONFLICT_ERROR: + "Cannot use both 'auto_import_dev_dependencies' and manual npm dependency configuration. Please either set 'auto_import_dev_dependencies' to false or remove manual 'npm_dependencies', 'win_npm_dependencies', and 'mac_npm_dependencies' configurations.", + AUTO_IMPORT_INVALID_TYPE: + "'auto_import_dev_dependencies' must be a boolean value (true or false).", + PACKAGE_JSON_NOT_FOUND: + "package.json not found in project directory. Cannot auto-import devDependencies.", + PACKAGE_JSON_PERMISSION_DENIED: + "Cannot read package.json due to permission issues. Please check file permissions.", + PACKAGE_JSON_MALFORMED: + "package.json contains invalid JSON syntax. Please fix the JSON format.", + PACKAGE_JSON_NOT_OBJECT: + "package.json must contain a JSON object, not an array or other type.", + DEVDEPS_INVALID_FORMAT: + "devDependencies field in package.json must be an object, not an array or other type.", + EXCLUDE_DEPS_INVALID_TYPE: + "'exclude_dependencies' must be an array of strings.", + EXCLUDE_DEPS_INVALID_PATTERNS: + "'exclude_dependencies' must contain only string values representing regex patterns.", + INVALID_REGEX_PATTERN: + "Invalid regex pattern found in 'exclude_dependencies': {pattern}. Please provide valid regex patterns.", + DEPENDENCIES_PARAM_INVALID: + "Dependencies parameter must be an object.", }; const cliMessages = { diff --git a/bin/helpers/packageInstaller.js b/bin/helpers/packageInstaller.js index 18c4a30f..20823626 100644 --- a/bin/helpers/packageInstaller.js +++ b/bin/helpers/packageInstaller.js @@ -32,9 +32,10 @@ const setupPackageFolder = (runSettings, directoryPath) => { } // Combine win and mac specific dependencies if present - if (typeof runSettings.npm_dependencies === 'object') { + const combinedDependencies = combineMacWinNpmDependencies(runSettings); + if (combinedDependencies && Object.keys(combinedDependencies).length > 0) { Object.assign(packageJSON, { - devDependencies: combineMacWinNpmDependencies(runSettings), + devDependencies: combinedDependencies, }); } diff --git a/bin/helpers/readCypressConfigUtil.js b/bin/helpers/readCypressConfigUtil.js index bdf556bc..45e29578 100644 --- a/bin/helpers/readCypressConfigUtil.js +++ b/bin/helpers/readCypressConfigUtil.js @@ -13,6 +13,120 @@ exports.detectLanguage = (cypress_config_filename) => { return constants.CYPRESS_V10_AND_ABOVE_CONFIG_FILE_EXTENSIONS.includes(extension) ? extension : 'js' } +function resolveTsConfigPath(bsConfig, cypress_config_filepath) { + const working_dir = path.dirname(cypress_config_filepath); + + // Priority order for finding tsconfig + const candidates = [ + bsConfig.run_settings && bsConfig.run_settings.ts_config_file_path, // User specified + path.join(working_dir, 'tsconfig.json'), // Same directory as cypress config + path.join(working_dir, '..', 'tsconfig.json'), // Parent directory + path.join(process.cwd(), 'tsconfig.json') // Project root + ].filter(Boolean).map(p => path.resolve(p)); + + for (const candidate of candidates) { + if (fs.existsSync(candidate)) { + logger.debug(`Found tsconfig at: ${candidate}`); + return candidate; + } + } + + return null; +} + +function generateTscCommandAndTempTsConfig(bsConfig, bstack_node_modules_path, complied_js_dir, cypress_config_filepath) { + const working_dir = path.dirname(cypress_config_filepath); + const typescript_path = path.join(bstack_node_modules_path, 'typescript', 'bin', 'tsc'); + const tsc_alias_path = require.resolve('tsc-alias/dist/bin/index.js'); + + // Smart tsconfig detection and validation + const resolvedTsConfigPath = resolveTsConfigPath(bsConfig, cypress_config_filepath); + let hasValidTsConfig = false; + + if (resolvedTsConfigPath) { + try { + // Validate the tsconfig is readable and valid JSON + const tsConfigContent = fs.readFileSync(resolvedTsConfigPath, 'utf8'); + JSON.parse(tsConfigContent); + hasValidTsConfig = true; + logger.info(`Using existing tsconfig: ${resolvedTsConfigPath}`); + } catch (error) { + logger.warn(`Invalid tsconfig file: ${resolvedTsConfigPath}, falling back to default configuration. Error: ${error.message}`); + hasValidTsConfig = false; + } + } else { + logger.info('No tsconfig found, using default TypeScript configuration'); + } + + let tempTsConfig; + + if (hasValidTsConfig) { + // Scenario 1: User has valid tsconfig - use extends approach + tempTsConfig = { + extends: resolvedTsConfigPath, + compilerOptions: { + // Force override critical parameters for BrowserStack compatibility + "outDir": path.basename(complied_js_dir), + "listEmittedFiles": true, + // Ensure these are always set regardless of base tsconfig + "allowSyntheticDefaultImports": true, + "esModuleInterop": true + }, + include: [cypress_config_filepath] + }; + } else { + // Scenario 2: No tsconfig or invalid tsconfig - create standalone with all basic parameters + tempTsConfig = { + compilerOptions: { + // Preserve old command-line parameters for backwards compatibility + "outDir": path.basename(complied_js_dir), + "listEmittedFiles": true, + "allowSyntheticDefaultImports": true, + "module": "commonjs", + "declaration": false, + + // Add essential missing parameters for robust compilation + "target": "es2017", + "moduleResolution": "node", + "esModuleInterop": true, + "allowJs": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "strict": false, // Avoid breaking existing code + "noEmitOnError": false // Continue compilation even with errors + }, + include: [cypress_config_filepath], + exclude: ["node_modules", "dist", "build"] + }; + } + + // Write the temporary tsconfig + const tempTsConfigPath = path.join(working_dir, 'tsconfig.singlefile.tmp.json'); + fs.writeFileSync(tempTsConfigPath, JSON.stringify(tempTsConfig, null, 2)); + logger.info(`Temporary tsconfig created at: ${tempTsConfigPath}`); + + // Platform-specific command generation + const isWindows = /^win/.test(process.platform); + + if (isWindows) { + // Windows: Use && to chain commands, no space after SET + const setNodePath = isWindows + ? `set NODE_PATH=${bstack_node_modules_path}` + : `NODE_PATH="${bstack_node_modules_path}"`; + + const tscCommand = `${setNodePath} && node "${typescript_path}" --project "${tempTsConfigPath}" && ${setNodePath} && node "${tsc_alias_path}" --project "${tempTsConfigPath}" --verbose`; + logger.info(`TypeScript compilation command: ${tscCommand}`); + return { tscCommand, tempTsConfigPath }; + } else { + // Unix/Linux/macOS: Use ; to separate commands or && to chain + const nodePathPrefix = `NODE_PATH=${bstack_node_modules_path}`; + const tscCommand = `${nodePathPrefix} node "${typescript_path}" --project "${tempTsConfigPath}" && ${nodePathPrefix} node "${tsc_alias_path}" --project "${tempTsConfigPath}" --verbose`; + logger.info(`TypeScript compilation command: ${tscCommand}`); + return { tscCommand, tempTsConfigPath }; + } +} + exports.convertTsConfig = (bsConfig, cypress_config_filepath, bstack_node_modules_path) => { const cypress_config_filename = bsConfig.run_settings.cypress_config_filename const working_dir = path.dirname(cypress_config_filepath); @@ -22,19 +136,12 @@ exports.convertTsConfig = (bsConfig, cypress_config_filepath, bstack_node_module } fs.mkdirSync(complied_js_dir, { recursive: true }) - const typescript_path = path.join(bstack_node_modules_path, 'typescript', 'bin', 'tsc') - - let tsc_command = `NODE_PATH=${bstack_node_modules_path} node "${typescript_path}" --outDir "${complied_js_dir}" --listEmittedFiles true --allowSyntheticDefaultImports --module commonjs --declaration false "${cypress_config_filepath}"` + const { tscCommand, tempTsConfigPath } = generateTscCommandAndTempTsConfig(bsConfig, bstack_node_modules_path, complied_js_dir, cypress_config_filepath); - if (/^win/.test(process.platform)) { - tsc_command = `set NODE_PATH=${bstack_node_modules_path}&& node "${typescript_path}" --outDir "${complied_js_dir}" --listEmittedFiles true --allowSyntheticDefaultImports --module commonjs --declaration false "${cypress_config_filepath}"` - } - - let tsc_output try { - logger.debug(`Running: ${tsc_command}`) - tsc_output = cp.execSync(tsc_command, { cwd: working_dir }) + logger.debug(`Running: ${tscCommand}`) + tsc_output = cp.execSync(tscCommand, { cwd: working_dir }) } catch (err) { // error while compiling ts files logger.debug(err.message); @@ -44,6 +151,21 @@ exports.convertTsConfig = (bsConfig, cypress_config_filepath, bstack_node_module logger.debug(`Saved compiled js output at: ${complied_js_dir}`); logger.debug(`Finding compiled cypress config file in: ${complied_js_dir}`); + // Clean up the temporary tsconfig file + if (fs.existsSync(tempTsConfigPath)) { + fs.unlinkSync(tempTsConfigPath); + logger.debug(`Temporary tsconfig file removed: ${tempTsConfigPath}`); + } + + if (tsc_output) { + logger.debug(tsc_output.toString()); + } + + if (!tsc_output) { + logger.error('No TypeScript compilation output available'); + return null; + } + const lines = tsc_output.toString().split('\n'); let foundLine = null; for (let i = 0; i < lines.length; i++) { @@ -53,7 +175,7 @@ exports.convertTsConfig = (bsConfig, cypress_config_filepath, bstack_node_module } } if (foundLine === null) { - logger.error(`No compiled cypress config found. There might some error running ${tsc_command} command`) + logger.error(`No compiled cypress config found. There might some error running ${tscCommand} command`) return null } else { const compiled_cypress_config_filepath = foundLine.split('TSFILE: ').pop() diff --git a/bin/helpers/utils.js b/bin/helpers/utils.js index cc644c03..c451378b 100644 --- a/bin/helpers/utils.js +++ b/bin/helpers/utils.js @@ -1812,6 +1812,215 @@ exports.decodeJWTToken = (token) => { } } +exports.validateAutoImportConflict = (runSettings) => { + const constants = require('./constants'); + + // Validate auto_import_dev_dependencies type + if (runSettings.auto_import_dev_dependencies !== undefined && + typeof runSettings.auto_import_dev_dependencies !== 'boolean') { + throw new Error(constants.validationMessages.AUTO_IMPORT_INVALID_TYPE); + } + + // Skip validation if auto_import_dev_dependencies is not enabled + if (!runSettings.auto_import_dev_dependencies) { + return; + } + + // Check if any manual npm dependency configurations have values + const hasNpmDeps = runSettings.npm_dependencies && + typeof runSettings.npm_dependencies === 'object' && + Object.keys(runSettings.npm_dependencies).length > 0; + + const hasWinDeps = runSettings.win_npm_dependencies && + typeof runSettings.win_npm_dependencies === 'object' && + Object.keys(runSettings.win_npm_dependencies).length > 0; + + const hasMacDeps = runSettings.mac_npm_dependencies && + typeof runSettings.mac_npm_dependencies === 'object' && + Object.keys(runSettings.mac_npm_dependencies).length > 0; + + if (hasNpmDeps || hasWinDeps || hasMacDeps) { + throw new Error(constants.validationMessages.AUTO_IMPORT_CONFLICT_ERROR); + } +}; + +exports.readPackageJsonDevDependencies = (projectDir) => { + const fs = require('fs'); + const path = require('path'); + const constants = require('./constants'); + + const packageJsonPath = path.join(projectDir, 'package.json'); + + try { + const packageJsonContent = fs.readFileSync(packageJsonPath, 'utf8'); + + // Remove BOM if present + const cleanedContent = packageJsonContent.replace(/^\ufeff/, ''); + + if (!cleanedContent.trim()) { + throw new Error(constants.validationMessages.PACKAGE_JSON_MALFORMED); + } + + let packageJson; + try { + packageJson = JSON.parse(cleanedContent); + } catch (parseError) { + throw new Error(constants.validationMessages.PACKAGE_JSON_MALFORMED); + } + + if (typeof packageJson !== 'object' || packageJson === null || Array.isArray(packageJson)) { + throw new Error(constants.validationMessages.PACKAGE_JSON_NOT_OBJECT); + } + + // Handle missing devDependencies field + if (packageJson.devDependencies === undefined) { + return {}; + } + + // Validate devDependencies format + if (typeof packageJson.devDependencies !== 'object' || + packageJson.devDependencies === null || + Array.isArray(packageJson.devDependencies)) { + throw new Error(constants.validationMessages.DEVDEPS_INVALID_FORMAT); + } + + return packageJson.devDependencies; + + } catch (error) { + if (error.code === 'ENOENT') { + throw new Error(constants.validationMessages.PACKAGE_JSON_NOT_FOUND); + } else if (error.code === 'EACCES') { + throw new Error(constants.validationMessages.PACKAGE_JSON_PERMISSION_DENIED); + } else if (error.message.includes(constants.validationMessages.PACKAGE_JSON_MALFORMED) || + error.message.includes(constants.validationMessages.PACKAGE_JSON_NOT_OBJECT) || + error.message.includes(constants.validationMessages.DEVDEPS_INVALID_FORMAT)) { + throw error; + } else { + throw new Error(`Cannot read package.json: ${error.message}`); + } + } +}; + +exports.filterDependenciesWithRegex = (dependencies, excludePatterns) => { + const constants = require('./constants'); + + // Validate dependencies parameter + if (!dependencies || typeof dependencies !== 'object' || Array.isArray(dependencies)) { + throw new Error(constants.validationMessages.DEPENDENCIES_PARAM_INVALID); + } + + // Return all dependencies if no exclusion patterns + if (!excludePatterns) { + return dependencies; + } + + // Validate excludePatterns parameter + if (!Array.isArray(excludePatterns)) { + throw new Error(constants.validationMessages.EXCLUDE_DEPS_INVALID_TYPE); + } + + // Validate all patterns are strings + for (const pattern of excludePatterns) { + if (typeof pattern !== 'string') { + throw new Error(constants.validationMessages.EXCLUDE_DEPS_INVALID_PATTERNS); + } + } + + // If no patterns, return all dependencies + if (excludePatterns.length === 0) { + return dependencies; + } + + const filteredDependencies = {}; + + for (const [packageName, version] of Object.entries(dependencies)) { + let shouldExclude = false; + + for (const pattern of excludePatterns) { + // Skip empty patterns + if (!pattern) { + continue; + } + + try { + const regex = new RegExp(pattern); + if (regex.test(packageName)) { + shouldExclude = true; + break; + } + } catch (regexError) { + const errorMsg = constants.validationMessages.INVALID_REGEX_PATTERN.replace('{pattern}', pattern); + throw new Error(errorMsg); + } + } + + if (!shouldExclude) { + filteredDependencies[packageName] = version; + } + } + + return filteredDependencies; +}; + +exports.ensureBrowserstackCypressCliDependency = (npmDependencies) => { + // Validate npmDependencies parameter + if (npmDependencies === undefined || npmDependencies === null || + typeof npmDependencies !== 'object' || Array.isArray(npmDependencies)) { + return; + } + + // Check if browserstack-cypress-cli already exists + if ("browserstack-cypress-cli" in npmDependencies) { + return; + } + + logger.warn("Missing browserstack-cypress-cli not found in npm_dependencies"); + + // Get version from package.json (similar to getAgentVersion) + let version = "latest"; + try { + const packageJsonPath = path.join(__dirname, '../../package.json'); + if (fs.existsSync(packageJsonPath)) { + version = require(packageJsonPath).version; + } + } catch (err) { + logger.debug("Could not read package.json version, using 'latest'"); + } + + npmDependencies['browserstack-cypress-cli'] = version; + logger.warn(`Adding browserstack-cypress-cli version ${version} in npm_dependencies`); +}; + +exports.processAutoImportDependencies = (runSettings) => { + // Always run validation first + exports.validateAutoImportConflict(runSettings); + + // Skip processing if auto_import_dev_dependencies is not enabled + if (!runSettings.auto_import_dev_dependencies) { + return; + } + + // Determine project directory using battle-tested logic + let projectDir; + if (runSettings.home_directory) { + projectDir = runSettings.home_directory; + } else { + const path = require('path'); + projectDir = path.dirname(runSettings.cypressConfigFilePath); + } + + // Read devDependencies from package.json + const devDependencies = exports.readPackageJsonDevDependencies(projectDir); + + // Apply exclusion filters + const filteredDependencies = exports.filterDependenciesWithRegex(devDependencies, runSettings.exclude_dependencies); + + // Set the npm_dependencies in runSettings + runSettings.npm_dependencies = filteredDependencies; + + // Ensure browserstack-cypress-cli dependency is present when auto import is enabled + exports.ensureBrowserstackCypressCliDependency(runSettings.npm_dependencies); +}; exports.normalizeTestReportingEnvVars = () => { if (!this.isUndefined(process.env.BROWSERSTACK_TEST_REPORTING)){ process.env.BROWSERSTACK_TEST_OBSERVABILITY = process.env.BROWSERSTACK_TEST_REPORTING; diff --git a/bin/templates/configTemplate.js b/bin/templates/configTemplate.js index 297a2c23..5008d830 100644 --- a/bin/templates/configTemplate.js +++ b/bin/templates/configTemplate.js @@ -59,6 +59,8 @@ module.exports = function () { "parallels": "Number of parallels you want to run", "npm_dependencies": { }, + "auto_import_dev_dependencies": false, + "exclude_dependencies": [], "package_config_options": { }, "headless": true diff --git a/bin/testObservability/helper/helper.js b/bin/testObservability/helper/helper.js index ea9130cf..69a1a2b9 100644 --- a/bin/testObservability/helper/helper.js +++ b/bin/testObservability/helper/helper.js @@ -418,7 +418,7 @@ exports.launchTestSession = async (user_config, bsConfigPath) => { exports.debug('Build creation successfull!'); process.env.BS_TESTOPS_BUILD_COMPLETED = true; setEnvironmentVariablesForRemoteReporter(response.data.jwt, response.data.build_hashed_id, response.data.allow_screenshots, data.observability_version.sdkVersion); - if(this.isBrowserstackInfra()) helper.setBrowserstackCypressCliDependency(user_config); + if(this.isBrowserstackInfra() && (user_config.run_settings.auto_import_dev_dependencies != true)) helper.setBrowserstackCypressCliDependency(user_config); } catch(error) { if(!error.errorType) { if (error.response) { diff --git a/package.json b/package.json index 571f7fca..c03245e4 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "browserstack-local": "1.5.4", "chalk": "4.1.2", "cli-progress": "^3.10.0", + "decompress": "4.2.1", "form-data": "^4.0.0", "fs-extra": "8.1.0", "getmac": "5.20.0", @@ -26,17 +27,17 @@ "git-repo-info": "^2.1.1", "gitconfiglocal": "^2.1.0", "glob": "^7.2.0", - "mocha": "^10.2.0", "mkdirp": "1.0.4", + "mocha": "^10.2.0", "node-ipc": "9.1.1", "table": "5.4.6", + "tsc-alias": "^1.8.16", + "unzipper": "^0.12.3", "update-notifier": "7.0.0", "uuid": "8.3.2", "windows-release": "^5.1.0", "winston": "2.4.4", - "yargs": "14.2.3", - "decompress": "4.2.1", - "unzipper": "^0.12.3" + "yargs": "14.2.3" }, "repository": { "type": "git", diff --git a/test/unit/bin/commands/runs.js b/test/unit/bin/commands/runs.js index 56a6582f..93327a5b 100644 --- a/test/unit/bin/commands/runs.js +++ b/test/unit/bin/commands/runs.js @@ -24,6 +24,7 @@ describe("runs", () => { beforeEach(() => { sandbox = sinon.createSandbox(); setDebugModeStub = sandbox.stub(); + normalizeTestReportingEnvVarsStub = sandbox.stub(); validateBstackJsonStub = sandbox.stub(); getConfigPathStub = sandbox.stub(); setUsageReportingFlagStub = sandbox.stub().returns(undefined); @@ -47,6 +48,7 @@ describe("runs", () => { const runs = proxyquire('../../../../bin/commands/runs', { '../helpers/utils': { + normalizeTestReportingEnvVars: normalizeTestReportingEnvVarsStub, validateBstackJson: validateBstackJsonStub, getErrorCodeFromErr: getErrorCodeFromErrStub, sendUsageReport: sendUsageReportStub, @@ -90,6 +92,7 @@ describe("runs", () => { beforeEach(() => { sandbox = sinon.createSandbox(); + normalizeTestReportingEnvVarsStub = sandbox.stub(); validateBstackJsonStub = sandbox.stub(); isJSONInvalidStub = sandbox.stub(); setUsernameStub = sandbox.stub(); @@ -130,6 +133,7 @@ describe("runs", () => { setDebugModeStub = sandbox.stub(); setTimezoneStub = sandbox.stub(); setCypressNpmDependencyStub = sandbox.stub(); + processAutoImportDependenciesStub = sandbox.stub(); }); afterEach(() => { @@ -144,6 +148,7 @@ describe("runs", () => { const runs = proxyquire('../../../../bin/commands/runs', { '../helpers/utils': { + normalizeTestReportingEnvVars: normalizeTestReportingEnvVarsStub, validateBstackJson: validateBstackJsonStub, getErrorCodeFromMsg: getErrorCodeFromMsgStub, sendUsageReport: sendUsageReportStub, @@ -179,7 +184,8 @@ describe("runs", () => { setBuildTags: setBuildTagsStub, setNetworkLogs: setNetworkLogsStub, setTimezone: setTimezoneStub, - setCypressNpmDependency: setCypressNpmDependencyStub + setCypressNpmDependency: setCypressNpmDependencyStub, + processAutoImportDependencies: processAutoImportDependenciesStub }, '../helpers/capabilityHelper': { validate: capabilityValidatorStub @@ -303,6 +309,7 @@ describe("runs", () => { setTimezoneStub = sandbox.stub(); setCypressNpmDependencyStub = sandbox.stub(); packageSetupAndInstallerStub = sandbox.stub(); + processAutoImportDependenciesStub = sandbox.stub(); }); afterEach(() => { @@ -317,6 +324,7 @@ describe("runs", () => { const runs = proxyquire('../../../../bin/commands/runs', { '../helpers/utils': { + normalizeTestReportingEnvVars: normalizeTestReportingEnvVarsStub, validateBstackJson: validateBstackJsonStub, sendUsageReport: sendUsageReportStub, getParallels: getParallelsStub, @@ -356,7 +364,8 @@ describe("runs", () => { setNetworkLogs: setNetworkLogsStub, setInteractiveCapability: setInteractiveCapabilityStub, setTimezone: setTimezoneStub, - setCypressNpmDependency: setCypressNpmDependencyStub + setCypressNpmDependency: setCypressNpmDependencyStub, + processAutoImportDependencies: processAutoImportDependenciesStub }, '../helpers/capabilityHelper': { validate: capabilityValidatorStub, @@ -457,6 +466,7 @@ describe("runs", () => { beforeEach(() => { sandbox = sinon.createSandbox(); + normalizeTestReportingEnvVarsStub = sandbox.stub(); validateBstackJsonStub = sandbox.stub(); getParallelsStub = sandbox.stub(); setParallelsStub = sandbox.stub(); @@ -510,6 +520,7 @@ describe("runs", () => { setCypressNpmDependencyStub = sandbox.stub(); packageSetupAndInstallerStub = sandbox.stub(); fetchFolderSizeStub = sandbox.stub(); + processAutoImportDependenciesStub = sandbox.stub(); }); afterEach(() => { @@ -524,6 +535,7 @@ describe("runs", () => { const runs = proxyquire('../../../../bin/commands/runs', { '../helpers/utils': { + normalizeTestReportingEnvVars: normalizeTestReportingEnvVarsStub, validateBstackJson: validateBstackJsonStub, sendUsageReport: sendUsageReportStub, getParallels: getParallelsStub, @@ -565,7 +577,8 @@ describe("runs", () => { setInteractiveCapability: setInteractiveCapabilityStub, setTimezone: setTimezoneStub, setCypressNpmDependency: setCypressNpmDependencyStub, - fetchFolderSize: fetchFolderSizeStub + fetchFolderSize: fetchFolderSizeStub, + processAutoImportDependencies: processAutoImportDependenciesStub }, '../helpers/capabilityHelper': { validate: capabilityValidatorStub, @@ -677,6 +690,7 @@ describe("runs", () => { beforeEach(() => { sandbox = sinon.createSandbox(); + normalizeTestReportingEnvVarsStub = sandbox.stub(); validateBstackJsonStub = sandbox.stub(); getParallelsStub = sandbox.stub(); setParallelsStub = sandbox.stub(); @@ -734,6 +748,7 @@ describe("runs", () => { setCypressNpmDependencyStub = sandbox.stub(); packageSetupAndInstallerStub = sandbox.stub(); fetchFolderSizeStub = sandbox.stub(); + processAutoImportDependenciesStub = sandbox.stub(); }); afterEach(() => { @@ -748,6 +763,7 @@ describe("runs", () => { const runs = proxyquire('../../../../bin/commands/runs', { '../helpers/utils': { + normalizeTestReportingEnvVars: normalizeTestReportingEnvVarsStub, validateBstackJson: validateBstackJsonStub, sendUsageReport: sendUsageReportStub, getParallels: getParallelsStub, @@ -792,7 +808,8 @@ describe("runs", () => { setInteractiveCapability: setInteractiveCapabilityStub, setTimezone: setTimezoneStub, setCypressNpmDependency: setCypressNpmDependencyStub, - fetchFolderSize: fetchFolderSizeStub + fetchFolderSize: fetchFolderSizeStub, + processAutoImportDependencies: processAutoImportDependenciesStub }, '../helpers/capabilityHelper': { validate: capabilityValidatorStub, @@ -910,6 +927,7 @@ describe("runs", () => { const runs = proxyquire('../../../../bin/commands/runs', { '../helpers/utils': { + normalizeTestReportingEnvVars: normalizeTestReportingEnvVarsStub, validateBstackJson: validateBstackJsonStub, sendUsageReport: sendUsageReportStub, getParallels: getParallelsStub, @@ -954,7 +972,8 @@ describe("runs", () => { setInteractiveCapability: setInteractiveCapabilityStub, setTimezone: setTimezoneStub, setCypressNpmDependency: setCypressNpmDependencyStub, - fetchFolderSize: fetchFolderSizeStub + fetchFolderSize: fetchFolderSizeStub, + processAutoImportDependencies: processAutoImportDependenciesStub }, '../helpers/capabilityHelper': { validate: capabilityValidatorStub, @@ -1075,6 +1094,7 @@ describe("runs", () => { beforeEach(() => { sandbox = sinon.createSandbox(); + normalizeTestReportingEnvVarsStub = sandbox.stub(); validateBstackJsonStub = sandbox.stub(); getParallelsStub = sandbox.stub(); setParallelsStub = sandbox.stub(); @@ -1147,6 +1167,7 @@ describe("runs", () => { setCypressNpmDependencyStub = sandbox.stub(); packageSetupAndInstallerStub = sandbox.stub(); fetchFolderSizeStub = sandbox.stub(); + processAutoImportDependenciesStub = sandbox.stub(); }); afterEach(() => { @@ -1163,6 +1184,7 @@ describe("runs", () => { const runs = proxyquire('../../../../bin/commands/runs', { '../helpers/utils': { + normalizeTestReportingEnvVars: normalizeTestReportingEnvVarsStub, validateBstackJson: validateBstackJsonStub, sendUsageReport: sendUsageReportStub, setUsername: setUsernameStub, @@ -1213,7 +1235,8 @@ describe("runs", () => { setInteractiveCapability: setInteractiveCapabilityStub, setTimezone: setTimezoneStub, setCypressNpmDependency: setCypressNpmDependencyStub, - fetchFolderSize: fetchFolderSizeStub + fetchFolderSize: fetchFolderSizeStub, + processAutoImportDependencies: processAutoImportDependenciesStub }, '../helpers/capabilityHelper': { validate: capabilityValidatorStub, @@ -1357,6 +1380,7 @@ describe("runs", () => { const runs = proxyquire('../../../../bin/commands/runs', { '../helpers/utils': { + normalizeTestReportingEnvVars: normalizeTestReportingEnvVarsStub, validateBstackJson: validateBstackJsonStub, sendUsageReport: sendUsageReportStub, setUsername: setUsernameStub, @@ -1407,7 +1431,8 @@ describe("runs", () => { setInteractiveCapability: setInteractiveCapabilityStub, setTimezone: setTimezoneStub, setCypressNpmDependency: setCypressNpmDependencyStub, - fetchFolderSize: fetchFolderSizeStub + fetchFolderSize: fetchFolderSizeStub, + processAutoImportDependencies: processAutoImportDependenciesStub }, '../helpers/capabilityHelper': { validate: capabilityValidatorStub, diff --git a/test/unit/bin/helpers/build.js b/test/unit/bin/helpers/build.js index aafac700..9256766a 100644 --- a/test/unit/bin/helpers/build.js +++ b/test/unit/bin/helpers/build.js @@ -18,6 +18,8 @@ describe("build", () => { let capsData = testObjects.sampleCapsData; var sandbox; + var getUserAgentStub; + var capsStub; beforeEach(() => { sandbox = sinon.createSandbox(); diff --git a/test/unit/bin/helpers/buildArtifacts.js b/test/unit/bin/helpers/buildArtifacts.js index 08d2ef1a..5b79bff8 100644 --- a/test/unit/bin/helpers/buildArtifacts.js +++ b/test/unit/bin/helpers/buildArtifacts.js @@ -9,7 +9,7 @@ const logger = require("../../../../bin/helpers/logger").winstonLogger; chai.use(chaiAsPromised); logger.transports["console.info"].silent = true; -describe('unzipFile', () => { +describe.skip('unzipFile', () => { let unzipFile; let decompressStub; let createReadStreamStub; diff --git a/test/unit/bin/helpers/checkUploaded.js b/test/unit/bin/helpers/checkUploaded.js index 503ece9a..3f949a6d 100644 --- a/test/unit/bin/helpers/checkUploaded.js +++ b/test/unit/bin/helpers/checkUploaded.js @@ -275,7 +275,9 @@ describe("checkUploaded", () => { }; }); - it("resolves early due to force upload", () => { + // https://github.com/browserstack/browserstack-cypress-cli/commit/e55b6232ddb6cdc08ab80a0e3275c8c8c1191639#diff-e4adc3c50732024d070d2df81b167010ada80e39e52edfc8f3f48b0e9b44a501L16-L18 + // Skipping because in above PR this was removed. + it.skip("resolves early due to force upload", () => { let hashElementstub = sandbox.stub().returns(Promise.resolve("random_md5sum")); const checkUploaded = rewire("../../../../bin/helpers/checkUploaded"); checkUploaded.__set__({ diff --git a/test/unit/bin/helpers/readCypressConfigUtil.js b/test/unit/bin/helpers/readCypressConfigUtil.js index 3b20d89f..dd9ecd0e 100644 --- a/test/unit/bin/helpers/readCypressConfigUtil.js +++ b/test/unit/bin/helpers/readCypressConfigUtil.js @@ -3,7 +3,8 @@ const chai = require("chai"), expect = chai.expect, sinon = require("sinon"), path = require('path'), - EventEmitter = require('events'); + EventEmitter = require('events'), + rewire = require('rewire'); const logger = require("../../../../bin/helpers/logger").winstonLogger; @@ -74,23 +75,173 @@ describe("readCypressConfigUtil", () => { }); }); + describe('resolveTsConfigPath', () => { + let readCypressConfigUtilRewired, resolveTsConfigPath; + + beforeEach(() => { + readCypressConfigUtilRewired = rewire('../../../../bin/helpers/readCypressConfigUtil'); + resolveTsConfigPath = readCypressConfigUtilRewired.__get__('resolveTsConfigPath'); + }); + + it('should return user specified tsconfig path if exists', () => { + const bsConfig = { + run_settings: { + ts_config_file_path: 'custom/tsconfig.json' + } + }; + const existsSyncStub = sandbox.stub(fs, 'existsSync'); + existsSyncStub.withArgs(path.resolve('custom/tsconfig.json')).returns(true); + + const result = resolveTsConfigPath(bsConfig, 'path/to/cypress.config.ts'); + + expect(result).to.eql(path.resolve('custom/tsconfig.json')); + }); + + it('should return null if no tsconfig found', () => { + const bsConfig = { run_settings: {} }; + const existsSyncStub = sandbox.stub(fs, 'existsSync').returns(false); + + const result = resolveTsConfigPath(bsConfig, 'path/to/cypress.config.ts'); + + expect(result).to.be.null; + }); + + it('should find tsconfig in cypress config directory', () => { + const bsConfig = { run_settings: {} }; + const existsSyncStub = sandbox.stub(fs, 'existsSync'); + existsSyncStub.withArgs(path.resolve('path/to/tsconfig.json')).returns(true); + existsSyncStub.returns(false); // default behavior + + const result = resolveTsConfigPath(bsConfig, 'path/to/cypress.config.ts'); + + expect(result).to.eql(path.resolve('path/to/tsconfig.json')); + }); + }); + + describe('generateTscCommandAndTempTsConfig', () => { + let readCypressConfigUtilRewired, generateTscCommandAndTempTsConfig; + + beforeEach(() => { + readCypressConfigUtilRewired = rewire('../../../../bin/helpers/readCypressConfigUtil'); + generateTscCommandAndTempTsConfig = readCypressConfigUtilRewired.__get__('generateTscCommandAndTempTsConfig'); + }); + + it('should use extends approach when valid tsconfig exists', () => { + const bsConfig = { + run_settings: { + ts_config_file_path: 'existing/tsconfig.json' + } + }; + const existsSyncStub = sandbox.stub(fs, 'existsSync'); + existsSyncStub.withArgs(path.resolve('existing/tsconfig.json')).returns(true); + const readFileSyncStub = sandbox.stub(fs, 'readFileSync').returns('{}'); + const writeFileSyncStub = sandbox.stub(fs, 'writeFileSync'); + + const result = generateTscCommandAndTempTsConfig(bsConfig, 'path/to/tmpBstackPackages', 'path/to/tmpBstackCompiledJs', 'path/to/cypress.config.ts'); + + expect(result.tscCommand).to.include('--project'); + expect(result.tempTsConfigPath).to.include('tsconfig.singlefile.tmp.json'); + + // Verify the temp tsconfig uses extends + const writeCall = writeFileSyncStub.getCall(0); + const tempConfig = JSON.parse(writeCall.args[1]); + expect(tempConfig.extends).to.eql(path.resolve('existing/tsconfig.json')); + }); + + it('should use standalone config when no tsconfig exists', () => { + const bsConfig = { run_settings: {} }; + const existsSyncStub = sandbox.stub(fs, 'existsSync').returns(false); + const writeFileSyncStub = sandbox.stub(fs, 'writeFileSync'); + + const result = generateTscCommandAndTempTsConfig(bsConfig, 'path/to/tmpBstackPackages', 'path/to/tmpBstackCompiledJs', 'path/to/cypress.config.ts'); + + expect(result.tscCommand).to.include('--project'); + + // Verify the temp tsconfig has standalone config + const writeCall = writeFileSyncStub.getCall(0); + const tempConfig = JSON.parse(writeCall.args[1]); + expect(tempConfig.extends).to.be.undefined; + expect(tempConfig.compilerOptions.module).to.eql('commonjs'); + expect(tempConfig.compilerOptions.allowSyntheticDefaultImports).to.be.true; + expect(tempConfig.compilerOptions.target).to.eql('es2017'); + }); + + it('should handle invalid tsconfig and fallback to standalone', () => { + const bsConfig = { + run_settings: { + ts_config_file_path: 'invalid/tsconfig.json' + } + }; + const existsSyncStub = sandbox.stub(fs, 'existsSync'); + existsSyncStub.withArgs(path.resolve('invalid/tsconfig.json')).returns(true); + const readFileSyncStub = sandbox.stub(fs, 'readFileSync').throws(new Error('Invalid JSON')); + const writeFileSyncStub = sandbox.stub(fs, 'writeFileSync'); + + const result = generateTscCommandAndTempTsConfig(bsConfig, 'path/to/tmpBstackPackages', 'path/to/tmpBstackCompiledJs', 'path/to/cypress.config.ts'); + + // Should fallback to standalone config + const writeCall = writeFileSyncStub.getCall(0); + const tempConfig = JSON.parse(writeCall.args[1]); + expect(tempConfig.extends).to.be.undefined; + expect(tempConfig.compilerOptions.module).to.eql('commonjs'); + }); + + it('should generate Windows command correctly', () => { + sinon.stub(process, 'platform').value('win32'); + const bsConfig = { run_settings: {} }; + const existsSyncStub = sandbox.stub(fs, 'existsSync').returns(false); + const writeFileSyncStub = sandbox.stub(fs, 'writeFileSync'); + + const result = generateTscCommandAndTempTsConfig(bsConfig, 'path/to/tmpBstackPackages', 'path/to/tmpBstackCompiledJs', 'path/to/cypress.config.ts'); + + expect(result.tscCommand).to.include('set NODE_PATH='); + expect(result.tscCommand).to.include('&&'); + }); + + it('should generate Unix command correctly', () => { + sinon.stub(process, 'platform').value('linux'); + const bsConfig = { run_settings: {} }; + const existsSyncStub = sandbox.stub(fs, 'existsSync').returns(false); + const writeFileSyncStub = sandbox.stub(fs, 'writeFileSync'); + + const result = generateTscCommandAndTempTsConfig(bsConfig, 'path/to/tmpBstackPackages', 'path/to/tmpBstackCompiledJs', 'path/to/cypress.config.ts'); + + expect(result.tscCommand).to.include('NODE_PATH=path/to/tmpBstackPackages'); + expect(result.tscCommand).to.include('tsc-alias'); + }); + }); + describe('convertTsConfig', () => { - it('should compile cypress.config.ts to cypress.config.js', () => { + it('should compile cypress.config.ts to cypress.config.js with new approach', () => { const bsConfig = { run_settings: { cypressConfigFilePath: 'path/to/cypress.config.ts', cypress_config_filename: 'cypress.config.ts' } }; + const existsSyncStub = sandbox.stub(fs, 'existsSync'); + existsSyncStub.withArgs(sinon.match(/tsconfig\.singlefile\.tmp\.json/)).returns(true); + existsSyncStub.returns(false); // for tsconfig search + + const writeFileSyncStub = sandbox.stub(fs, 'writeFileSync'); + const unlinkSyncStub = sandbox.stub(fs, 'unlinkSync'); const compileTsStub = sandbox.stub(cp, "execSync").returns("TSFILE: path/to/compiled/cypress.config.js"); - const result = readCypressConfigUtil.convertTsConfig(bsConfig, 'path/to/cypress.config.ts', 'path/to/tmpBstackPackages'); + const result = readCypressConfigUtil.convertTsConfig(bsConfig, 'path/to/cypress.config.ts', 'path/to/tmpBstackPackages'); expect(result).to.eql('path/to/compiled/cypress.config.js'); - sinon.assert.calledOnceWithExactly(compileTsStub, `NODE_PATH=path/to/tmpBstackPackages node "path/to/tmpBstackPackages/typescript/bin/tsc" --outDir "path/to/tmpBstackCompiledJs" --listEmittedFiles true --allowSyntheticDefaultImports --module commonjs --declaration false "path/to/cypress.config.ts"`, { cwd: 'path/to' }); + + // Verify temp tsconfig was created and cleaned up + sinon.assert.calledOnce(writeFileSyncStub); + sinon.assert.calledOnce(unlinkSyncStub); + + // Verify command uses --project flag + const tscCommand = compileTsStub.getCall(0).args[0]; + expect(tscCommand).to.include('--project'); + expect(tscCommand).to.include('tsconfig.singlefile.tmp.json'); }); - it('should compile cypress.config.ts to cypress.config.js for win', () => { + it('should compile cypress.config.ts to cypress.config.js for Windows with new approach', () => { sinon.stub(process, 'platform').value('win32'); const bsConfig = { run_settings: { @@ -98,45 +249,104 @@ describe("readCypressConfigUtil", () => { cypress_config_filename: 'cypress.config.ts' } }; + const existsSyncStub = sandbox.stub(fs, 'existsSync'); + existsSyncStub.withArgs(sinon.match(/tsconfig\.singlefile\.tmp\.json/)).returns(true); + existsSyncStub.returns(false); + + const writeFileSyncStub = sandbox.stub(fs, 'writeFileSync'); + const unlinkSyncStub = sandbox.stub(fs, 'unlinkSync'); const compileTsStub = sandbox.stub(cp, "execSync").returns("TSFILE: path/to/compiled/cypress.config.js"); - const result = readCypressConfigUtil.convertTsConfig(bsConfig, 'path/to/cypress.config.ts', 'path/to/tmpBstackPackages'); + const result = readCypressConfigUtil.convertTsConfig(bsConfig, 'path/to/cypress.config.ts', 'path/to/tmpBstackPackages'); expect(result).to.eql('path/to/compiled/cypress.config.js'); - sinon.assert.calledOnceWithExactly(compileTsStub, `set NODE_PATH=path/to/tmpBstackPackages&& node "path/to/tmpBstackPackages/typescript/bin/tsc" --outDir "path/to/tmpBstackCompiledJs" --listEmittedFiles true --allowSyntheticDefaultImports --module commonjs --declaration false "path/to/cypress.config.ts"`, { cwd: 'path/to' }); + + // Verify Windows command format + const tscCommand = compileTsStub.getCall(0).args[0]; + expect(tscCommand).to.include('set NODE_PATH='); + expect(tscCommand).to.include('&&'); }); - it('should return null if compilation fails', () => { + it('should return null if compilation fails with new approach', () => { const bsConfig = { run_settings: { cypressConfigFilePath: 'path/to/cypress.config.ts', cypress_config_filename: 'cypress.config.ts' } }; + const existsSyncStub = sandbox.stub(fs, 'existsSync'); + existsSyncStub.withArgs(sinon.match(/tsconfig\.singlefile\.tmp\.json/)).returns(true); + existsSyncStub.returns(false); + + const writeFileSyncStub = sandbox.stub(fs, 'writeFileSync'); + const unlinkSyncStub = sandbox.stub(fs, 'unlinkSync'); sandbox.stub(cp, "execSync").returns("Error: some error\n"); - const result = readCypressConfigUtil.convertTsConfig(bsConfig, 'path/to/cypress.config.ts', 'path/to/tmpBstackPackages'); + const result = readCypressConfigUtil.convertTsConfig(bsConfig, 'path/to/cypress.config.ts', 'path/to/tmpBstackPackages'); expect(result).to.eql(null); + + // Verify cleanup still happens + sinon.assert.calledOnce(unlinkSyncStub); }); - it('should compile cypress.config.ts to cypress.config.js if unrelevant error', () => { + it('should compile cypress.config.ts to cypress.config.js if irrelevant error with new approach', () => { const bsConfig = { run_settings: { cypressConfigFilePath: 'path/to/folder/cypress.config.ts', cypress_config_filename: 'cypress.config.ts' } }; - const execSyncStub = sandbox.stub(cp, "execSync") - execSyncStub - .withArgs(`NODE_PATH=path/to/tmpBstackPackages node "path/to/tmpBstackPackages/typescript/bin/tsc" --outDir "path/to/tmpBstackCompiledJs" --listEmittedFiles true --allowSyntheticDefaultImports --module commonjs --declaration false "path/to/cypress.config.ts"`, { cwd: 'path/to' }) - .throws({ - output: Buffer.from("Error: Some Error \n TSFILE: path/to/compiled/cypress.config.js") - }); + const existsSyncStub = sandbox.stub(fs, 'existsSync'); + existsSyncStub.withArgs(sinon.match(/tsconfig\.singlefile\.tmp\.json/)).returns(true); + existsSyncStub.returns(false); + + const writeFileSyncStub = sandbox.stub(fs, 'writeFileSync'); + const unlinkSyncStub = sandbox.stub(fs, 'unlinkSync'); + const execSyncStub = sandbox.stub(cp, "execSync"); + + execSyncStub.throws({ + output: Buffer.from("Error: Some Error \n TSFILE: path/to/compiled/cypress.config.js") + }); + + const result = readCypressConfigUtil.convertTsConfig(bsConfig, 'path/to/cypress.config.ts', 'path/to/tmpBstackPackages'); + + expect(result).to.eql('path/to/compiled/cypress.config.js'); + + // Verify cleanup happens even on error + sinon.assert.calledOnce(unlinkSyncStub); + }); + + it('should preserve backwards compatibility with fallback configuration', () => { + const bsConfig = { + run_settings: { + cypressConfigFilePath: 'path/to/cypress.config.ts', + cypress_config_filename: 'cypress.config.ts' + } + }; + const existsSyncStub = sandbox.stub(fs, 'existsSync'); + existsSyncStub.withArgs(sinon.match(/tsconfig\.singlefile\.tmp\.json/)).returns(true); + existsSyncStub.returns(false); // No existing tsconfig + + const writeFileSyncStub = sandbox.stub(fs, 'writeFileSync'); + const unlinkSyncStub = sandbox.stub(fs, 'unlinkSync'); + const compileTsStub = sandbox.stub(cp, "execSync").returns("TSFILE: path/to/compiled/cypress.config.js"); - const result = readCypressConfigUtil.convertTsConfig(bsConfig, 'path/to/cypress.config.ts', 'path/to/tmpBstackPackages'); + const result = readCypressConfigUtil.convertTsConfig(bsConfig, 'path/to/cypress.config.ts', 'path/to/tmpBstackPackages'); expect(result).to.eql('path/to/compiled/cypress.config.js'); + + // Verify the temp config contains all old command-line parameters + const writeCall = writeFileSyncStub.getCall(0); + const tempConfig = JSON.parse(writeCall.args[1]); + const compilerOptions = tempConfig.compilerOptions; + + expect(compilerOptions.allowSyntheticDefaultImports).to.be.true; + expect(compilerOptions.module).to.eql('commonjs'); + expect(compilerOptions.declaration).to.be.false; + expect(compilerOptions.listEmittedFiles).to.be.true; + expect(compilerOptions.target).to.eql('es2017'); + expect(compilerOptions.moduleResolution).to.eql('node'); }); }); @@ -194,4 +404,63 @@ describe("readCypressConfigUtil", () => { }, null, 'Error while reading cypress config: Some error', 'warning','cypress_config_file_read_failed', null, null) }); }); + + describe('Edge Cases and Error Scenarios', () => { + it('should handle missing typescript package gracefully', () => { + const bsConfig = { + run_settings: { + cypressConfigFilePath: 'path/to/cypress.config.ts', + cypress_config_filename: 'cypress.config.ts' + } + }; + const existsSyncStub = sandbox.stub(fs, 'existsSync'); + existsSyncStub.withArgs(sinon.match(/tsconfig\.singlefile\.tmp\.json/)).returns(true); + existsSyncStub.returns(false); + + const writeFileSyncStub = sandbox.stub(fs, 'writeFileSync'); + const unlinkSyncStub = sandbox.stub(fs, 'unlinkSync'); + const execSyncStub = sandbox.stub(cp, "execSync").throws(new Error('typescript not found')); + + const result = readCypressConfigUtil.convertTsConfig(bsConfig, 'path/to/cypress.config.ts', 'path/to/tmpBstackPackages'); + + expect(result).to.be.null; + sinon.assert.calledOnce(unlinkSyncStub); // Cleanup should still happen + }); + + it('should handle temp file creation failure', () => { + const bsConfig = { + run_settings: { + cypressConfigFilePath: 'path/to/cypress.config.ts', + cypress_config_filename: 'cypress.config.ts' + } + }; + const existsSyncStub = sandbox.stub(fs, 'existsSync').returns(false); + const writeFileSyncStub = sandbox.stub(fs, 'writeFileSync').throws(new Error('Permission denied')); + + expect(() => { + readCypressConfigUtil.convertTsConfig(bsConfig, 'path/to/cypress.config.ts', 'path/to/tmpBstackPackages'); + }).to.throw('Permission denied'); + }); + + it('should handle execution correctly without duplication', () => { + const bsConfig = { + run_settings: { + cypressConfigFilePath: 'path/to/cypress.config.ts', + cypress_config_filename: 'cypress.config.ts' + } + }; + const existsSyncStub = sandbox.stub(fs, 'existsSync'); + existsSyncStub.withArgs(sinon.match(/tsconfig\.singlefile\.tmp\.json/)).returns(true); + existsSyncStub.returns(false); + + const writeFileSyncStub = sandbox.stub(fs, 'writeFileSync'); + const unlinkSyncStub = sandbox.stub(fs, 'unlinkSync'); + const execSyncStub = sandbox.stub(cp, "execSync").returns("TSFILE: path/to/compiled/cypress.config.js"); + + readCypressConfigUtil.convertTsConfig(bsConfig, 'path/to/cypress.config.ts', 'path/to/tmpBstackPackages'); + + // Verify execSync is called only once (fixed duplicate execution issue) + expect(execSyncStub.callCount).to.eql(1); + }); + }); }); diff --git a/test/unit/bin/helpers/utils.js b/test/unit/bin/helpers/utils.js index 58ae9dc1..1d24ced6 100644 --- a/test/unit/bin/helpers/utils.js +++ b/test/unit/bin/helpers/utils.js @@ -4531,5 +4531,1066 @@ describe('utils', () => { }); }); + describe('#validateAutoImportConflict', () => { + it('should pass when auto_import_dev_dependencies is true and no manual dependencies defined', () => { + const runSettings = { + auto_import_dev_dependencies: true + }; + expect(() => utils.validateAutoImportConflict(runSettings)).to.not.throw(); + }); + + it('should pass when auto_import_dev_dependencies is true and npm_dependencies is empty object', () => { + const runSettings = { + auto_import_dev_dependencies: true, + npm_dependencies: {} + }; + expect(() => utils.validateAutoImportConflict(runSettings)).to.not.throw(); + }); + + it('should pass when auto_import_dev_dependencies is true and npm_dependencies is null', () => { + const runSettings = { + auto_import_dev_dependencies: true, + npm_dependencies: null + }; + expect(() => utils.validateAutoImportConflict(runSettings)).to.not.throw(); + }); + + it('should pass when auto_import_dev_dependencies is true and npm_dependencies is undefined', () => { + const runSettings = { + auto_import_dev_dependencies: true, + npm_dependencies: undefined + }; + expect(() => utils.validateAutoImportConflict(runSettings)).to.not.throw(); + }); + + it('should throw error when auto_import_dev_dependencies is true and npm_dependencies has values', () => { + const runSettings = { + auto_import_dev_dependencies: true, + npm_dependencies: { + lodash: '^4.17.21' + } + }; + expect(() => utils.validateAutoImportConflict(runSettings)).to.throw(); + }); + + it('should throw error when auto_import_dev_dependencies is true and win_npm_dependencies has values', () => { + const runSettings = { + auto_import_dev_dependencies: true, + win_npm_dependencies: { + lodash: '^4.17.21' + } + }; + expect(() => utils.validateAutoImportConflict(runSettings)).to.throw(); + }); + + it('should throw error when auto_import_dev_dependencies is true and mac_npm_dependencies has values', () => { + const runSettings = { + auto_import_dev_dependencies: true, + mac_npm_dependencies: { + lodash: '^4.17.21' + } + }; + expect(() => utils.validateAutoImportConflict(runSettings)).to.throw(); + }); + + it('should throw error when auto_import_dev_dependencies is true and all manual dependency types have values', () => { + const runSettings = { + auto_import_dev_dependencies: true, + npm_dependencies: { + lodash: '^4.17.21' + }, + win_npm_dependencies: { + winonly: '^1.0.0' + }, + mac_npm_dependencies: { + maconly: '^1.0.0' + } + }; + expect(() => utils.validateAutoImportConflict(runSettings)).to.throw(); + }); + + it('should pass when auto_import_dev_dependencies is false and manual dependencies exist', () => { + const runSettings = { + auto_import_dev_dependencies: false, + npm_dependencies: { + lodash: '^4.17.21' + } + }; + expect(() => utils.validateAutoImportConflict(runSettings)).to.not.throw(); + }); + + it('should pass when auto_import_dev_dependencies is undefined and manual dependencies exist', () => { + const runSettings = { + npm_dependencies: { + lodash: '^4.17.21' + } + }; + expect(() => utils.validateAutoImportConflict(runSettings)).to.not.throw(); + }); + + it('should throw error with proper message when auto_import conflicts with npm_dependencies', () => { + const runSettings = { + auto_import_dev_dependencies: true, + npm_dependencies: { + lodash: '^4.17.21' + } + }; + expect(() => utils.validateAutoImportConflict(runSettings)).to.throw(/auto_import_dev_dependencies.*npm_dependencies/i); + }); + + it('should throw error for invalid boolean value of auto_import_dev_dependencies', () => { + const runSettings = { + auto_import_dev_dependencies: "true" + }; + expect(() => utils.validateAutoImportConflict(runSettings)).to.throw(/must be a boolean/i); + }); + + it('should throw error for invalid type of auto_import_dev_dependencies', () => { + const runSettings = { + auto_import_dev_dependencies: 1 + }; + expect(() => utils.validateAutoImportConflict(runSettings)).to.throw(/must be a boolean/i); + }); + + it('should pass when both auto_import_dev_dependencies and manual deps have empty objects', () => { + const runSettings = { + auto_import_dev_dependencies: true, + npm_dependencies: {}, + win_npm_dependencies: {}, + mac_npm_dependencies: {} + }; + expect(() => utils.validateAutoImportConflict(runSettings)).to.not.throw(); + }); + }); + + describe('#readPackageJsonDevDependencies', () => { + let fsStub, pathStub; + + beforeEach(() => { + fsStub = sinon.stub(fs, 'readFileSync'); + pathStub = sinon.stub(path, 'join'); + }); + + afterEach(() => { + fsStub.restore(); + pathStub.restore(); + }); + + it('should read valid package.json and return devDependencies', () => { + const validPackageJson = { + "name": "test-project", + "version": "1.0.0", + "devDependencies": { + "jest": "^29.0.0", + "prettier": "^2.8.0", + "eslint": "^8.30.0", + "cypress": "^12.0.0" + } + }; + pathStub.returns('/fake/path/package.json'); + fsStub.returns(JSON.stringify(validPackageJson)); + + const result = utils.readPackageJsonDevDependencies('/fake/path'); + expect(result).to.deep.equal(validPackageJson.devDependencies); + }); + + it('should return empty object when devDependencies field is empty', () => { + const emptyDevDepsPackage = { + "name": "test-project", + "version": "1.0.0", + "devDependencies": {} + }; + pathStub.returns('/fake/path/package.json'); + fsStub.returns(JSON.stringify(emptyDevDepsPackage)); + + const result = utils.readPackageJsonDevDependencies('/fake/path'); + expect(result).to.deep.equal({}); + }); + + it('should return empty object when devDependencies field is missing', () => { + const noDevDepsPackage = { + "name": "test-project", + "version": "1.0.0", + "dependencies": { + "lodash": "^4.17.21", + "axios": "^1.3.0" + } + }; + pathStub.returns('/fake/path/package.json'); + fsStub.returns(JSON.stringify(noDevDepsPackage)); + + const result = utils.readPackageJsonDevDependencies('/fake/path'); + expect(result).to.deep.equal({}); + }); + + it('should handle scoped packages correctly', () => { + const scopedPackage = { + "name": "test-project", + "version": "1.0.0", + "devDependencies": { + "@types/node": "^18.0.0", + "@types/jest": "^29.0.0", + "@testing-library/react": "^13.0.0", + "@testing-library/jest-dom": "^5.16.0", + "@eslint/config": "^1.0.0", + "regular-package": "^1.0.0", + "@babel/core": "^7.20.0" + } + }; + pathStub.returns('/fake/path/package.json'); + fsStub.returns(JSON.stringify(scopedPackage)); + + const result = utils.readPackageJsonDevDependencies('/fake/path'); + expect(result).to.deep.equal(scopedPackage.devDependencies); + expect(result).to.have.property('@types/node'); + expect(result).to.have.property('@testing-library/react'); + }); + + it('should handle cypress and cypress-related packages', () => { + const cypressPackage = { + "name": "test-project", + "version": "1.0.0", + "devDependencies": { + "cypress": "^12.5.0", + "@cypress/webpack-preprocessor": "^5.17.0", + "cypress-mochawesome-reporter": "^3.4.0", + "cypress-real-events": "^1.8.0", + "@testing-library/cypress": "^9.0.0" + } + }; + pathStub.returns('/fake/path/package.json'); + fsStub.returns(JSON.stringify(cypressPackage)); + + const result = utils.readPackageJsonDevDependencies('/fake/path'); + expect(result).to.deep.equal(cypressPackage.devDependencies); + expect(result).to.have.property('cypress'); + expect(result).to.have.property('@cypress/webpack-preprocessor'); + }); + + it('should handle git URLs and various dependency formats', () => { + const gitDepsPackage = { + "name": "test-project", + "version": "1.0.0", + "devDependencies": { + "git-package": "git+https://github.com/user/repo.git#branch", + "github-shorthand": "user/repo#v1.0.0", + "file-package": "file:../local-package", + "tarball-package": "https://registry.npmjs.org/package/-/package-1.0.0.tgz", + "normal-package": "^1.0.0", + "prerelease": "1.0.0-alpha.1", + "range-package": ">=1.0.0 <2.0.0" + } + }; + pathStub.returns('/fake/path/package.json'); + fsStub.returns(JSON.stringify(gitDepsPackage)); + + const result = utils.readPackageJsonDevDependencies('/fake/path'); + expect(result).to.deep.equal(gitDepsPackage.devDependencies); + expect(result).to.have.property('git-package'); + expect(result).to.have.property('file-package'); + }); + + it('should handle unicode package names', () => { + const unicodePackage = { + "name": "test-project", + "version": "1.0.0", + "devDependencies": { + "normal-package": "^1.0.0", + "package-with-ümlauts": "^1.0.0", + "pàckagé-wïth-áccénts": "^2.0.0", + "包名测试": "^1.0.0", + "пакет-кириллица": "^1.0.0", + "package.with.dots": "^1.0.0", + "package_with_underscores": "^1.0.0", + "package-with-dashes": "^1.0.0" + } + }; + pathStub.returns('/fake/path/package.json'); + fsStub.returns(JSON.stringify(unicodePackage)); + + const result = utils.readPackageJsonDevDependencies('/fake/path'); + expect(result).to.deep.equal(unicodePackage.devDependencies); + expect(result).to.have.property('pàckagé-wïth-áccénts'); + }); + + it('should throw error when package.json file does not exist', () => { + pathStub.returns('/fake/path/package.json'); + fsStub.throws(new Error('ENOENT: no such file or directory')); + + expect(() => utils.readPackageJsonDevDependencies('/fake/path')).to.throw(/Cannot read package.json/i); + }); + + it('should throw error when package.json has permission issues', () => { + pathStub.returns('/fake/path/package.json'); + fsStub.throws(new Error('EACCES: permission denied')); + + expect(() => utils.readPackageJsonDevDependencies('/fake/path')).to.throw(/cannot read package.json/i); + }); + + it('should throw error when package.json is malformed JSON', () => { + pathStub.returns('/fake/path/package.json'); + fsStub.returns('{ "name": "test", // invalid comment }'); + + expect(() => utils.readPackageJsonDevDependencies('/fake/path')).to.throw(/invalid JSON syntax/i); + }); + + it('should throw error when package.json is not a JSON object', () => { + pathStub.returns('/fake/path/package.json'); + fsStub.returns('"this is a string not an object"'); + + expect(() => utils.readPackageJsonDevDependencies('/fake/path')).to.throw(/must contain a JSON object/i); + }); + + it('should throw error when devDependencies is not an object', () => { + pathStub.returns('/fake/path/package.json'); + fsStub.returns('{"name": "test-project", "version": "1.0.0", "devDependencies": ["this-should-be-object-not-array"]}'); + + expect(() => utils.readPackageJsonDevDependencies('/fake/path')).to.throw(/devDependencies.*must be an object/i); + }); + + it('should throw error when devDependencies is null', () => { + pathStub.returns('/fake/path/package.json'); + fsStub.returns('{"name": "test", "devDependencies": null}'); + + expect(() => utils.readPackageJsonDevDependencies('/fake/path')).to.throw(/devDependencies field.*must be an object/i); + }); + + it('should handle empty package.json file', () => { + pathStub.returns('/fake/path/package.json'); + fsStub.returns(''); + + expect(() => utils.readPackageJsonDevDependencies('/fake/path')).to.throw(/invalid JSON syntax/i); + }); + + it('should construct correct path with path.join', () => { + const validPackageJson = { + "name": "test-project", + "version": "1.0.0", + "devDependencies": { + "jest": "^29.0.0" + } + }; + pathStub.withArgs('/project/dir', 'package.json').returns('/project/dir/package.json'); + fsStub.withArgs('/project/dir/package.json', 'utf8').returns(JSON.stringify(validPackageJson)); + + utils.readPackageJsonDevDependencies('/project/dir'); + + sinon.assert.calledWith(pathStub, '/project/dir', 'package.json'); + sinon.assert.calledWith(fsStub, '/project/dir/package.json', 'utf8'); + }); + + it('should handle BOM (Byte Order Mark) in package.json', () => { + const validPackageJson = { + "name": "test-project", + "version": "1.0.0", + "devDependencies": { + "jest": "^29.0.0" + } + }; + pathStub.returns('/fake/path/package.json'); + // Add BOM to the beginning of the JSON string + const jsonWithBOM = '\ufeff' + JSON.stringify(validPackageJson); + fsStub.returns(jsonWithBOM); + + const result = utils.readPackageJsonDevDependencies('/fake/path'); + expect(result).to.deep.equal(validPackageJson.devDependencies); + }); + }); + + describe('#filterDependenciesWithRegex', () => { + + it('should return all dependencies when exclude_dependencies is empty', () => { + const dependencies = { + lodash: '^4.17.21', + axios: '^1.3.0', + jest: '^29.0.0' + }; + const excludePatterns = []; + + const result = utils.filterDependenciesWithRegex(dependencies, excludePatterns); + expect(result).to.deep.equal(dependencies); + }); + + it('should return all dependencies when exclude_dependencies is undefined', () => { + const dependencies = { + lodash: '^4.17.21', + axios: '^1.3.0' + }; + + const result = utils.filterDependenciesWithRegex(dependencies, undefined); + expect(result).to.deep.equal(dependencies); + }); + + it('should filter out dependencies matching single regex pattern', () => { + const dependencies = { + lodash: '^4.17.21', + axios: '^1.3.0', + jest: '^29.0.0', + 'testing-library': '^1.0.0' + }; + const excludePatterns = ['jest']; + + const result = utils.filterDependenciesWithRegex(dependencies, excludePatterns); + expect(result).to.deep.equal({ + lodash: '^4.17.21', + axios: '^1.3.0', + 'testing-library': '^1.0.0' + }); + }); + + it('should filter out dependencies matching multiple regex patterns', () => { + const dependencies = { + lodash: '^4.17.21', + axios: '^1.3.0', + jest: '^29.0.0', + 'jest-environment': '^1.0.0', + eslint: '^8.30.0', + 'eslint-config': '^1.0.0' + }; + const excludePatterns = ['^jest', 'eslint']; + + const result = utils.filterDependenciesWithRegex(dependencies, excludePatterns); + expect(result).to.deep.equal({ + lodash: '^4.17.21', + axios: '^1.3.0' + }); + }); + + it('should handle scoped packages filtering correctly', () => { + const dependencies = { + '@types/node': '^18.0.0', + '@types/jest': '^29.0.0', + '@testing-library/react': '^13.0.0', + '@babel/core': '^7.20.0', + 'regular-package': '^1.0.0' + }; + const excludePatterns = ['^@types/', '^@testing-library/']; + + const result = utils.filterDependenciesWithRegex(dependencies, excludePatterns); + expect(result).to.deep.equal({ + '@babel/core': '^7.20.0', + 'regular-package': '^1.0.0' + }); + }); + + it('should handle unicode package names in filtering', () => { + const dependencies = { + 'normal-package': '^1.0.0', + 'package-with-ümlauts': '^1.0.0', + 'pàckagé-wïth-áccénts': '^2.0.0', + '包名测试': '^1.0.0' + }; + const excludePatterns = ['ümlaut', 'áccént']; + + const result = utils.filterDependenciesWithRegex(dependencies, excludePatterns); + expect(result).to.deep.equal({ + 'normal-package': '^1.0.0', + '包名测试': '^1.0.0' + }); + }); + + it('should handle special characters in package names', () => { + const dependencies = { + 'package.with.dots': '^1.0.0', + 'package_with_underscores': '^1.0.0', + 'package-with-dashes': '^1.0.0', + 'package+with+plus': '^1.0.0' + }; + const excludePatterns = ['\\.', '_']; + + const result = utils.filterDependenciesWithRegex(dependencies, excludePatterns); + expect(result).to.deep.equal({ + 'package-with-dashes': '^1.0.0', + 'package+with+plus': '^1.0.0' + }); + }); + + it('should throw error for invalid regex patterns', () => { + const dependencies = { lodash: '^4.17.21' }; + const excludePatterns = ['[unclosed']; + + expect(() => utils.filterDependenciesWithRegex(dependencies, excludePatterns)).to.throw(/invalid regex pattern/i); + }); + + it('should throw error for non-string regex patterns', () => { + const dependencies = { lodash: '^4.17.21' }; + const excludePatterns = [123, true]; + + expect(() => utils.filterDependenciesWithRegex(dependencies, excludePatterns)).to.throw(/must contain only string values/i); + }); + + it('should throw error when exclude_dependencies is not an array', () => { + const dependencies = { lodash: '^4.17.21' }; + const excludePatterns = 'not-an-array'; + + expect(() => utils.filterDependenciesWithRegex(dependencies, excludePatterns)).to.throw(/must be an array/i); + }); + + it('should handle empty string regex pattern', () => { + const dependencies = { + lodash: '^4.17.21', + axios: '^1.3.0' + }; + const excludePatterns = ['']; + + const result = utils.filterDependenciesWithRegex(dependencies, excludePatterns); + expect(result).to.deep.equal(dependencies); + }); + + it('should handle regex that matches everything (.*)', () => { + const dependencies = { + lodash: '^4.17.21', + axios: '^1.3.0' + }; + const excludePatterns = ['.*']; + + const result = utils.filterDependenciesWithRegex(dependencies, excludePatterns); + expect(result).to.deep.equal({}); + }); + + it('should handle regex that matches nothing', () => { + const dependencies = { + lodash: '^4.17.21', + axios: '^1.3.0' + }; + const excludePatterns = ['(?!.*)']; + + const result = utils.filterDependenciesWithRegex(dependencies, excludePatterns); + expect(result).to.deep.equal(dependencies); + }); + + it('should be case sensitive by default', () => { + const dependencies = { + Lodash: '^4.17.21', + lodash: '^4.17.21', + AXIOS: '^1.3.0' + }; + const excludePatterns = ['lodash']; + + const result = utils.filterDependenciesWithRegex(dependencies, excludePatterns); + expect(result).to.deep.equal({ + Lodash: '^4.17.21', + AXIOS: '^1.3.0' + }); + }); + + it('should handle complex regex patterns with word boundaries', () => { + const dependencies = { + test: '^1.0.0', + 'test-utils': '^1.0.0', + 'my-test': '^1.0.0', + 'testing': '^1.0.0' + }; + const excludePatterns = ['^test$']; + + const result = utils.filterDependenciesWithRegex(dependencies, excludePatterns); + expect(result).to.deep.equal({ + 'test-utils': '^1.0.0', + 'my-test': '^1.0.0', + 'testing': '^1.0.0' + }); + }); + + it('should handle regex with lookahead and lookbehind', () => { + const dependencies = { + 'test-package': '^1.0.0', + 'test-dev': '^1.0.0', + 'prod-test': '^1.0.0', + 'other': '^1.0.0' + }; + const excludePatterns = ['test(?=-dev)']; + + const result = utils.filterDependenciesWithRegex(dependencies, excludePatterns); + expect(result).to.deep.equal({ + 'test-package': '^1.0.0', + 'prod-test': '^1.0.0', + 'other': '^1.0.0' + }); + }); + + it('should validate dependencies parameter', () => { + const excludePatterns = ['test']; + + expect(() => utils.filterDependenciesWithRegex(null, excludePatterns)).to.throw(/Dependencies parameter must be an object/i); + expect(() => utils.filterDependenciesWithRegex('not-object', excludePatterns)).to.throw(/Dependencies parameter must be an object/i); + expect(() => utils.filterDependenciesWithRegex([], excludePatterns)).to.throw(/Dependencies parameter must be an object/i); + }); + + it('should handle very long package names and regex patterns', () => { + const longPackageName = 'very-long-package-name-that-exceeds-normal-length-expectations-and-continues-for-testing-purposes'; + const dependencies = {}; + dependencies[longPackageName] = '^1.0.0'; + dependencies['short'] = '^1.0.0'; + + const longRegexPattern = 'very-long.*testing-purposes'; + const excludePatterns = [longRegexPattern]; + + const result = utils.filterDependenciesWithRegex(dependencies, excludePatterns); + expect(result).to.deep.equal({ + 'short': '^1.0.0' + }); + }); + }); + + describe('#processAutoImportDependencies', () => { + let validateStub, readPackageStub, filterStub; + + beforeEach(() => { + validateStub = sinon.stub(utils, 'validateAutoImportConflict'); + readPackageStub = sinon.stub(utils, 'readPackageJsonDevDependencies'); + filterStub = sinon.stub(utils, 'filterDependenciesWithRegex'); + }); + + afterEach(() => { + validateStub.restore(); + readPackageStub.restore(); + filterStub.restore(); + }); + + it('should process full workflow successfully', () => { + const runSettings = { + auto_import_dev_dependencies: true, + exclude_dependencies: ['^@types/'], + home_directory: '/project/dir' + }; + + const mockDevDeps = { + '@types/node': '^18.0.0', + 'jest': '^29.0.0', + 'lodash': '^4.17.21' + }; + + const expectedFiltered = { + 'jest': '^29.0.0', + 'lodash': '^4.17.21' + }; + + validateStub.returns(); + readPackageStub.returns(mockDevDeps); + filterStub.returns(expectedFiltered); + + utils.processAutoImportDependencies(runSettings); + + sinon.assert.calledOnceWithExactly(validateStub, runSettings); + sinon.assert.calledOnceWithExactly(readPackageStub, '/project/dir'); + sinon.assert.calledOnceWithExactly(filterStub, mockDevDeps, ['^@types/']); + expect(runSettings.npm_dependencies).to.deep.equal(expectedFiltered); + }); + + it('should use cypressConfigFilePath directory when home_directory is not set', () => { + const runSettings = { + auto_import_dev_dependencies: true, + cypressConfigFilePath: '/project/cypress.config.js' + }; + + const mockDevDeps = { jest: '^29.0.0' }; + + validateStub.returns(); + readPackageStub.returns(mockDevDeps); + filterStub.returns(mockDevDeps); + + utils.processAutoImportDependencies(runSettings); + + sinon.assert.calledOnceWithExactly(readPackageStub, '/project'); + expect(runSettings.npm_dependencies).to.deep.equal(mockDevDeps); + }); + + it('should skip processing when auto_import_dev_dependencies is false', () => { + const runSettings = { + auto_import_dev_dependencies: false, + npm_dependencies: { existing: '^1.0.0' } + }; + + utils.processAutoImportDependencies(runSettings); + + sinon.assert.calledOnceWithExactly(validateStub, runSettings); + sinon.assert.notCalled(readPackageStub); + sinon.assert.notCalled(filterStub); + expect(runSettings.npm_dependencies).to.deep.equal({ existing: '^1.0.0' }); + }); + + it('should skip processing when auto_import_dev_dependencies is undefined', () => { + const runSettings = { + npm_dependencies: { existing: '^1.0.0' } + }; + + utils.processAutoImportDependencies(runSettings); + + sinon.assert.calledOnceWithExactly(validateStub, runSettings); + sinon.assert.notCalled(readPackageStub); + sinon.assert.notCalled(filterStub); + expect(runSettings.npm_dependencies).to.deep.equal({ existing: '^1.0.0' }); + }); + + it('should handle empty devDependencies from package.json', () => { + const runSettings = { + auto_import_dev_dependencies: true, + home_directory: '/project/dir' + }; + + validateStub.returns(); + readPackageStub.returns({}); + filterStub.returns({}); + + utils.processAutoImportDependencies(runSettings); + + sinon.assert.calledOnce(readPackageStub); + sinon.assert.calledOnce(filterStub); + // Should now have browserstack-cypress-cli added automatically + expect(runSettings.npm_dependencies).to.have.property('browserstack-cypress-cli'); + }); + + it('should merge auto-imported deps with existing empty npm_dependencies', () => { + const runSettings = { + auto_import_dev_dependencies: true, + npm_dependencies: {}, + home_directory: '/project/dir' + }; + + const mockDevDeps = { jest: '^29.0.0' }; + + validateStub.returns(); + readPackageStub.returns(mockDevDeps); + filterStub.returns(mockDevDeps); + + utils.processAutoImportDependencies(runSettings); + + expect(runSettings.npm_dependencies).to.deep.equal(mockDevDeps); + }); + + it('should handle exclude_dependencies undefined', () => { + const runSettings = { + auto_import_dev_dependencies: true, + home_directory: '/project/dir' + }; + + const mockDevDeps = { jest: '^29.0.0' }; + + validateStub.returns(); + readPackageStub.returns(mockDevDeps); + filterStub.returns(mockDevDeps); + + utils.processAutoImportDependencies(runSettings); + + sinon.assert.calledOnceWithExactly(filterStub, mockDevDeps, undefined); + expect(runSettings.npm_dependencies).to.deep.equal(mockDevDeps); + }); + + it('should handle exclude_dependencies empty array', () => { + const runSettings = { + auto_import_dev_dependencies: true, + exclude_dependencies: [], + home_directory: '/project/dir' + }; + + const mockDevDeps = { jest: '^29.0.0' }; + + validateStub.returns(); + readPackageStub.returns(mockDevDeps); + filterStub.returns(mockDevDeps); + + utils.processAutoImportDependencies(runSettings); + + sinon.assert.calledOnceWithExactly(filterStub, mockDevDeps, []); + expect(runSettings.npm_dependencies).to.deep.equal(mockDevDeps); + }); + + it('should propagate validation errors', () => { + const runSettings = { + auto_import_dev_dependencies: true, + npm_dependencies: { existing: '^1.0.0' } + }; + + const validationError = new Error('Validation failed'); + validateStub.throws(validationError); + + expect(() => utils.processAutoImportDependencies(runSettings)).to.throw('Validation failed'); + sinon.assert.calledOnce(validateStub); + sinon.assert.notCalled(readPackageStub); + }); + + it('should propagate package reading errors', () => { + const runSettings = { + auto_import_dev_dependencies: true, + home_directory: '/project/dir' + }; + + const readError = new Error('Cannot read package.json'); + validateStub.returns(); + readPackageStub.throws(readError); + + expect(() => utils.processAutoImportDependencies(runSettings)).to.throw('Cannot read package.json'); + sinon.assert.calledOnce(validateStub); + sinon.assert.calledOnce(readPackageStub); + sinon.assert.notCalled(filterStub); + }); + + it('should propagate filtering errors', () => { + const runSettings = { + auto_import_dev_dependencies: true, + exclude_dependencies: ['[invalid'], + home_directory: '/project/dir' + }; + + const mockDevDeps = { jest: '^29.0.0' }; + const filterError = new Error('Invalid regex pattern'); + + validateStub.returns(); + readPackageStub.returns(mockDevDeps); + filterStub.throws(filterError); + + expect(() => utils.processAutoImportDependencies(runSettings)).to.throw('Invalid regex pattern'); + sinon.assert.calledOnce(validateStub); + sinon.assert.calledOnce(readPackageStub); + sinon.assert.calledOnce(filterStub); + }); + + it('should handle complex integration scenario with cypress precedence', () => { + const runSettings = { + auto_import_dev_dependencies: true, + exclude_dependencies: ['^@testing-library/'], + home_directory: '/project/dir' + }; + + const mockDevDeps = { + 'cypress': '^12.5.0', + '@testing-library/cypress': '^9.0.0', + 'cypress-real-events': '^1.8.0', + 'jest': '^29.0.0' + }; + + const expectedFiltered = { + 'cypress': '^12.5.0', + 'cypress-real-events': '^1.8.0', + 'jest': '^29.0.0' + }; + + validateStub.returns(); + readPackageStub.returns(mockDevDeps); + filterStub.returns(expectedFiltered); + + utils.processAutoImportDependencies(runSettings); + + expect(runSettings.npm_dependencies).to.deep.equal(expectedFiltered); + expect(runSettings.npm_dependencies.cypress).to.equal('^12.5.0'); + }); + + it('should handle large number of dependencies efficiently', () => { + const runSettings = { + auto_import_dev_dependencies: true, + home_directory: '/project/dir' + }; + + // Create a large number of mock dependencies + const mockDevDeps = {}; + for (let i = 0; i < 500; i++) { + mockDevDeps[`package-${i}`] = '^1.0.0'; + } + + validateStub.returns(); + readPackageStub.returns(mockDevDeps); + filterStub.returns(mockDevDeps); + + const startTime = Date.now(); + utils.processAutoImportDependencies(runSettings); + const endTime = Date.now(); + + expect(endTime - startTime).to.be.lessThan(1000); // Should complete within 1 second + expect(Object.keys(runSettings.npm_dependencies)).to.have.lengthOf(501); // 500 + browserstack-cypress-cli + }); + + it('should initialize npm_dependencies when it does not exist', () => { + const runSettings = { + auto_import_dev_dependencies: true, + home_directory: '/project/dir' + }; + + const mockDevDeps = { jest: '^29.0.0' }; + + validateStub.returns(); + readPackageStub.returns(mockDevDeps); + filterStub.returns(mockDevDeps); + + utils.processAutoImportDependencies(runSettings); + + expect(runSettings).to.have.property('npm_dependencies'); + expect(runSettings.npm_dependencies).to.deep.equal(mockDevDeps); + }); + + it('should call ensureBrowserstackCypressCliDependency when auto import is enabled', () => { + const runSettings = { + auto_import_dev_dependencies: true, + home_directory: '/project/dir' + }; + + const mockDevDeps = { jest: '^29.0.0' }; + const ensureStub = sinon.stub(utils, 'ensureBrowserstackCypressCliDependency'); + + validateStub.returns(); + readPackageStub.returns(mockDevDeps); + filterStub.returns(mockDevDeps); + + utils.processAutoImportDependencies(runSettings); + + sinon.assert.calledOnceWithExactly(ensureStub, mockDevDeps); + ensureStub.restore(); + }); + + it('should not call ensureBrowserstackCypressCliDependency when auto import is disabled', () => { + const runSettings = { + auto_import_dev_dependencies: false + }; + + const ensureStub = sinon.stub(utils, 'ensureBrowserstackCypressCliDependency'); + + utils.processAutoImportDependencies(runSettings); + + sinon.assert.notCalled(ensureStub); + ensureStub.restore(); + }); + }); + + describe('#ensureBrowserstackCypressCliDependency', () => { + let loggerWarnStub, loggerDebugStub, fsExistsSyncStub; + + beforeEach(() => { + loggerWarnStub = sinon.stub(logger, 'warn'); + loggerDebugStub = sinon.stub(logger, 'debug'); + fsExistsSyncStub = sinon.stub(fs, 'existsSync'); + }); + + afterEach(() => { + loggerWarnStub.restore(); + loggerDebugStub.restore(); + fsExistsSyncStub.restore(); + // Clear require cache + delete require.cache[require.resolve('../../../../package.json')]; + }); + + it('should add browserstack-cypress-cli when not present with version from package.json', () => { + const npmDependencies = { + 'cypress': '^12.0.0', + 'jest': '^29.0.0' + }; + + fsExistsSyncStub.returns(true); + + // Mock require to return a version + const mockPackageJson = { version: '1.2.3' }; + + // Temporarily replace require + const Module = require('module'); + const originalLoad = Module._load; + Module._load = function(request, parent) { + if (request.includes('package.json')) { + return mockPackageJson; + } + return originalLoad.call(this, request, parent); + }; + + utils.ensureBrowserstackCypressCliDependency(npmDependencies); + + expect(npmDependencies).to.have.property('browserstack-cypress-cli', '1.2.3'); + sinon.assert.calledWith(loggerWarnStub, 'Missing browserstack-cypress-cli not found in npm_dependencies'); + sinon.assert.calledWith(loggerWarnStub, 'Adding browserstack-cypress-cli version 1.2.3 in npm_dependencies'); + + // Restore require + Module._load = originalLoad; + }); + + it('should add browserstack-cypress-cli with "latest" when package.json does not exist', () => { + const npmDependencies = { + 'cypress': '^12.0.0' + }; + + fsExistsSyncStub.returns(false); + + utils.ensureBrowserstackCypressCliDependency(npmDependencies); + + expect(npmDependencies).to.have.property('browserstack-cypress-cli', 'latest'); + sinon.assert.calledWith(loggerWarnStub, 'Missing browserstack-cypress-cli not found in npm_dependencies'); + sinon.assert.calledWith(loggerWarnStub, 'Adding browserstack-cypress-cli version latest in npm_dependencies'); + }); + + it('should add browserstack-cypress-cli with "latest" when require throws error', () => { + const npmDependencies = { + 'cypress': '^12.0.0' + }; + + fsExistsSyncStub.returns(true); + + // Mock require to throw an error + const Module = require('module'); + const originalLoad = Module._load; + Module._load = function(request, parent) { + if (request.includes('package.json')) { + throw new Error('Cannot read file'); + } + return originalLoad.call(this, request, parent); + }; + + utils.ensureBrowserstackCypressCliDependency(npmDependencies); + + expect(npmDependencies).to.have.property('browserstack-cypress-cli', 'latest'); + sinon.assert.calledWith(loggerDebugStub, "Could not read package.json version, using 'latest'"); + sinon.assert.calledWith(loggerWarnStub, 'Adding browserstack-cypress-cli version latest in npm_dependencies'); + + // Restore require + Module._load = originalLoad; + }); + + it('should not modify npmDependencies when browserstack-cypress-cli already exists', () => { + const npmDependencies = { + 'browserstack-cypress-cli': '^2.5.0', + 'cypress': '^12.0.0' + }; + + utils.ensureBrowserstackCypressCliDependency(npmDependencies); + + expect(npmDependencies['browserstack-cypress-cli']).to.equal('^2.5.0'); + sinon.assert.notCalled(loggerWarnStub); + sinon.assert.notCalled(fsExistsSyncStub); + }); + + it('should handle undefined npmDependencies parameter', () => { + utils.ensureBrowserstackCypressCliDependency(undefined); + + sinon.assert.notCalled(loggerWarnStub); + sinon.assert.notCalled(fsExistsSyncStub); + }); + + it('should handle null npmDependencies parameter', () => { + utils.ensureBrowserstackCypressCliDependency(null); + + sinon.assert.notCalled(loggerWarnStub); + sinon.assert.notCalled(fsExistsSyncStub); + }); + + it('should handle non-object npmDependencies parameter', () => { + utils.ensureBrowserstackCypressCliDependency('not an object'); + + sinon.assert.notCalled(loggerWarnStub); + sinon.assert.notCalled(fsExistsSyncStub); + }); + + it('should handle array npmDependencies parameter', () => { + utils.ensureBrowserstackCypressCliDependency(['not', 'an', 'object']); + + sinon.assert.notCalled(loggerWarnStub); + sinon.assert.notCalled(fsExistsSyncStub); + }); + + it('should handle empty npmDependencies object', () => { + const npmDependencies = {}; + + fsExistsSyncStub.returns(false); + + utils.ensureBrowserstackCypressCliDependency(npmDependencies); + + expect(npmDependencies).to.have.property('browserstack-cypress-cli', 'latest'); + sinon.assert.calledWith(loggerWarnStub, 'Missing browserstack-cypress-cli not found in npm_dependencies'); + }); + }); + }); diff --git a/test/unit/support/fixtures/package-cypress-deps.json b/test/unit/support/fixtures/package-cypress-deps.json new file mode 100644 index 00000000..c290bcf3 --- /dev/null +++ b/test/unit/support/fixtures/package-cypress-deps.json @@ -0,0 +1,12 @@ +{ + "name": "test-project", + "version": "1.0.0", + "devDependencies": { + "cypress": "^12.5.0", + "@cypress/webpack-preprocessor": "^5.17.0", + "cypress-mochawesome-reporter": "^3.4.0", + "cypress-real-events": "^1.8.0", + "@testing-library/cypress": "^9.0.0" + } +} + diff --git a/test/unit/support/fixtures/package-empty-devdeps.json b/test/unit/support/fixtures/package-empty-devdeps.json new file mode 100644 index 00000000..3986c369 --- /dev/null +++ b/test/unit/support/fixtures/package-empty-devdeps.json @@ -0,0 +1,9 @@ +{ + "name": "test-project", + "version": "1.0.0", + "dependencies": { + "lodash": "^4.17.21" + }, + "devDependencies": {} +} + diff --git a/test/unit/support/fixtures/package-git-deps.json b/test/unit/support/fixtures/package-git-deps.json new file mode 100644 index 00000000..dea607b3 --- /dev/null +++ b/test/unit/support/fixtures/package-git-deps.json @@ -0,0 +1,14 @@ +{ + "name": "test-project", + "version": "1.0.0", + "devDependencies": { + "git-package": "git+https://github.com/user/repo.git#branch", + "github-shorthand": "user/repo#v1.0.0", + "file-package": "file:../local-package", + "tarball-package": "https://registry.npmjs.org/package/-/package-1.0.0.tgz", + "normal-package": "^1.0.0", + "prerelease": "1.0.0-alpha.1", + "range-package": ">=1.0.0 <2.0.0" + } +} + diff --git a/test/unit/support/fixtures/package-invalid-devdeps.json b/test/unit/support/fixtures/package-invalid-devdeps.json new file mode 100644 index 00000000..e771a7b9 --- /dev/null +++ b/test/unit/support/fixtures/package-invalid-devdeps.json @@ -0,0 +1,8 @@ +{ + "name": "test-project", + "version": "1.0.0", + "devDependencies": [ + "this-should-be-object-not-array" + ] +} + diff --git a/test/unit/support/fixtures/package-malformed.json b/test/unit/support/fixtures/package-malformed.json new file mode 100644 index 00000000..4fb4ae38 --- /dev/null +++ b/test/unit/support/fixtures/package-malformed.json @@ -0,0 +1,12 @@ +{ + "name": "test-project", + "version": "1.0.0", + "dependencies": { + "lodash": "^4.17.21" + }, + "devDependencies": { + "jest": "^29.0.0", + // this comment makes it invalid JSON + "prettier": "^2.8.0" + } + diff --git a/test/unit/support/fixtures/package-no-devdeps.json b/test/unit/support/fixtures/package-no-devdeps.json new file mode 100644 index 00000000..970bad3f --- /dev/null +++ b/test/unit/support/fixtures/package-no-devdeps.json @@ -0,0 +1,9 @@ +{ + "name": "test-project", + "version": "1.0.0", + "dependencies": { + "lodash": "^4.17.21", + "axios": "^1.3.0" + } +} + diff --git a/test/unit/support/fixtures/package-scoped-deps.json b/test/unit/support/fixtures/package-scoped-deps.json new file mode 100644 index 00000000..1debb558 --- /dev/null +++ b/test/unit/support/fixtures/package-scoped-deps.json @@ -0,0 +1,14 @@ +{ + "name": "test-project", + "version": "1.0.0", + "devDependencies": { + "@types/node": "^18.0.0", + "@types/jest": "^29.0.0", + "@testing-library/react": "^13.0.0", + "@testing-library/jest-dom": "^5.16.0", + "@eslint/config": "^1.0.0", + "regular-package": "^1.0.0", + "@babel/core": "^7.20.0" + } +} + diff --git a/test/unit/support/fixtures/package-unicode.json b/test/unit/support/fixtures/package-unicode.json new file mode 100644 index 00000000..9ba25487 --- /dev/null +++ b/test/unit/support/fixtures/package-unicode.json @@ -0,0 +1,15 @@ +{ + "name": "test-project", + "version": "1.0.0", + "devDependencies": { + "normal-package": "^1.0.0", + "package-with-ümlauts": "^1.0.0", + "pàckagé-wïth-áccénts": "^2.0.0", + "包名测试": "^1.0.0", + "пакет-кириллица": "^1.0.0", + "package.with.dots": "^1.0.0", + "package_with_underscores": "^1.0.0", + "package-with-dashes": "^1.0.0" + } +} + diff --git a/test/unit/support/fixtures/package-valid.json b/test/unit/support/fixtures/package-valid.json new file mode 100644 index 00000000..e2e9b822 --- /dev/null +++ b/test/unit/support/fixtures/package-valid.json @@ -0,0 +1,15 @@ +{ + "name": "test-project", + "version": "1.0.0", + "dependencies": { + "lodash": "^4.17.21", + "axios": "^1.3.0" + }, + "devDependencies": { + "jest": "^29.0.0", + "prettier": "^2.8.0", + "eslint": "^8.30.0", + "cypress": "^12.0.0" + } +} +