UNPKG

sfdx-hardis

Version:

Swiss-army-knife Toolbox for Salesforce. Allows you to define a complete CD/CD Pipeline. Orchestrate base commands and assist users with interactive wizards

877 lines 69 kB
import { SfError } from '@salesforce/core'; import c from 'chalk'; import fs from 'fs-extra'; import { glob } from 'glob'; import * as path from 'path'; import sortArray from 'sort-array'; import { createTempDir, elapseEnd, elapseStart, execCommand, execSfdxJson, findJsonInString, getCurrentGitBranch, git, gitHasLocalUpdates, isCI, killBoringExitHandlers, replaceJsonInString, sortCrossPlatform, uxLog, uxLogTable, } from './index.js'; import { getApiVersion, getConfig, getEnvVar, getReportDirectory, setConfig } from '../../config/index.js'; import { GitProvider } from '../gitProvider/index.js'; import { deployCodeCoverageToMarkdown } from '../gitProvider/utilsMarkdown.js'; import { MetadataUtils } from '../metadata-utils/index.js'; import { importData } from './dataUtils.js'; import { analyzeDeployErrorLogs } from './deployTips.js'; import { callSfdxGitDelta, getPullRequestData, setPullRequestData } from './gitUtils.js'; import { createBlankSfdxProject, GLOB_IGNORE_PATTERNS, isSfdxProject } from './projectUtils.js'; import { prompts } from './prompts.js'; import { arrangeFilesBefore, restoreArrangedFiles } from './workaroundUtils.js'; import { countPackageXmlItems, isPackageXmlEmpty, parseXmlFile, removePackageXmlFilesContent, writeXmlFile } from './xmlUtils.js'; import { ResetMode } from 'simple-git'; import { isProductionOrg } from './orgUtils.js'; import { WebSocketClient } from '../websocketClient.js'; import { executePrePostCommands } from './prePostCommandUtils.js'; // Push sources to org // For some cases, push must be performed in 2 times: the first with all passing sources, and the second with updated sources requiring the first push export async function forceSourcePush(scratchOrgAlias, commandThis, debug = false, options = {}) { elapseStart('project:deploy:start'); const config = await getConfig('user'); const currentBranch = await getCurrentGitBranch(); let arrangedFiles = []; if (!(config[`tmp_${currentBranch}_pushed`] === true)) { arrangedFiles = await arrangeFilesBefore(commandThis, options); } try { const pushCommand = `sf project deploy start --ignore-warnings --ignore-conflicts -o ${scratchOrgAlias} --wait 60 --json`; await execCommand(pushCommand, commandThis, { fail: true, output: !isCI, debug: debug, }); if (arrangedFiles.length > 0) { await restoreArrangedFiles(arrangedFiles, commandThis); await execCommand(pushCommand, commandThis, { fail: true, output: !isCI, debug: debug, }); const configToSet = {}; configToSet[`tmp_${currentBranch}_pushed`] = true; await setConfig('user', configToSet); } elapseEnd('project:deploy:start'); } catch (e) { await restoreArrangedFiles(arrangedFiles, commandThis); // Manage beta/legacy boza const stdOut = e.stdout + e.stderr; if (stdOut.includes(`getaddrinfo EAI_AGAIN`)) { uxLog("error", this, c.red(c.bold('The error appears to be caused by an unstable internet connection. Please try again.'))); } // Analyze errors const { errLog } = await analyzeDeployErrorLogs(stdOut, true, {}); uxLog("error", commandThis, c.red('Unfortunately, push errors occurred.')); uxLog("error", this, c.red('\n' + errLog)); elapseEnd('project:deploy:start'); killBoringExitHandlers(); throw new SfError('Deployment failure. Check messages above'); } } export async function forceSourcePull(scratchOrgAlias, debug = false) { let pullCommandResult; try { const pullCommand = `sf project retrieve start --ignore-conflicts -o ${scratchOrgAlias} --wait 60 --json`; pullCommandResult = await execCommand(pullCommand, this, { fail: true, output: true, debug: debug, }); // Parse json in stdout and if json.result.status and json.result.files, create a list of files with "type" + "file name", then order it, then display it in logs if ((pullCommandResult?.result?.status === 'Succeeded' || pullCommandResult?.status === 0) && pullCommandResult?.result?.files) { // Build an array of objects for table display const files = pullCommandResult.result.files .filter((file) => file?.state !== "Failed") .map((file) => ({ Type: file.type, Name: file.fullName, State: file.state, Path: file.filePath || '' })); // Sort files by Type then Name sortArray(files, { by: ['Type', 'Name'], order: ['asc', 'asc'] }); uxLog("action", this, c.green('Successfully pulled sources from scratch org / source-tracked sandbox')); // Display as a table if (files.length > 0) { // Use the uxLogTable utility for consistent table output uxLogTable(this, files, ['Type', 'Name', 'State']); } else { uxLog("log", this, c.grey('No files pulled.')); } } else { uxLog("error", this, c.red(`Pull command did not return expected results\n${JSON.stringify(pullCommandResult, null, 2)}`)); } } catch (e) { // Manage beta/legacy boza const stdOut = e.stdout + e.stderr; // Analyze errors const { errLog } = await analyzeDeployErrorLogs(stdOut, true, {}); uxLog("error", this, c.red('Sadly there has been pull error(s)')); uxLog("error", this, c.red('\n' + errLog)); // List unknown elements from output const forceIgnoreElements = [...stdOut.matchAll(/Entity of type '(.*)' named '(.*)' cannot be found/gm)]; if (forceIgnoreElements.length > 0 && !isCI) { // Propose user to ignore elements const forceIgnoreRes = await prompts({ type: 'multiselect', message: 'If you want to try again with updated .forceignore file, please select elements you want to add, else escape', description: 'Select metadata elements to add to .forceignore to resolve deployment conflicts', name: 'value', choices: forceIgnoreElements.map((forceIgnoreElt) => { return { title: `${forceIgnoreElt[1]}: ${forceIgnoreElt[2]}`, value: forceIgnoreElt[2], }; }), }); if (forceIgnoreRes.value.length > 0 && forceIgnoreRes.value[0] !== 'exitNow') { const forceIgnoreFile = './.forceignore'; const forceIgnore = await fs.readFile(forceIgnoreFile, 'utf-8'); const forceIgnoreLines = forceIgnore.replace('\r\n', '\n').split('\n'); forceIgnoreLines.push(...forceIgnoreRes.value); await fs.writeFile(forceIgnoreFile, forceIgnoreLines.join('\n') + '\n'); uxLog("log", this, 'Updated .forceignore file'); return await forceSourcePull(scratchOrgAlias, debug); } } killBoringExitHandlers(); throw new SfError('Pull failure. Check messages above'); } // Check if some items has to be forced-retrieved because SF CLI does not detect updates const config = await getConfig('project'); if (config.autoRetrieveWhenPull) { uxLog("action", this, c.cyan('Retrieving additional sources that are usually forgotten by sf project:retrieve:start ...')); const metadataConstraint = config.autoRetrieveWhenPull.join(', '); const retrieveCommand = `sf project retrieve start -m "${metadataConstraint}" -o ${scratchOrgAlias} --wait 60`; await execCommand(retrieveCommand, this, { fail: true, output: true, debug: debug, }); } // If there are SharingRules, retrieve all of them to avoid the previous one are deleted (SF Cli strange/buggy behavior) if (pullCommandResult?.stdout?.includes("SharingRules")) { uxLog("action", this, c.cyan('Detected Sharing Rules in the pull: retrieving the whole of them to avoid silly overrides !')); const sharingRulesNamesMatches = [...pullCommandResult.stdout.matchAll(/([^ \\/]+)\.sharingRules-meta\.xml/gm)]; for (const match of sharingRulesNamesMatches) { uxLog("log", this, c.grey(`Retrieve the whole ${match[1]} SharingRules...`)); const retrieveCommand = `sf project retrieve start -m "SharingRules:${match[1]}" -o ${scratchOrgAlias} --wait 60`; await execCommand(retrieveCommand, this, { fail: true, output: true, debug: debug, }); } } } export async function smartDeploy(packageXmlFile, check = false, testlevel = 'RunLocalTests', debugMode = false, commandThis = this, options) { elapseStart('all deployments'); let quickDeploy = false; // Check package.xml emptiness const packageXmlIsEmpty = !fs.existsSync(packageXmlFile) || await isPackageXmlEmpty(packageXmlFile); // Check if destructive changes files exist and have content const hasDestructiveChanges = ((!!options.preDestructiveChanges && fs.existsSync(options.preDestructiveChanges) && !(await isPackageXmlEmpty(options.preDestructiveChanges))) || (!!options.postDestructiveChanges && fs.existsSync(options.postDestructiveChanges) && !(await isPackageXmlEmpty(options.postDestructiveChanges)))); // Check if files exist but are empty const hasEmptyDestructiveChanges = ((!!options.preDestructiveChanges && fs.existsSync(options.preDestructiveChanges) && await isPackageXmlEmpty(options.preDestructiveChanges)) || (!!options.postDestructiveChanges && fs.existsSync(options.postDestructiveChanges) && await isPackageXmlEmpty(options.postDestructiveChanges))); // Special case: both package.xml and destructive changes files exist but are empty if (packageXmlIsEmpty && hasEmptyDestructiveChanges && !hasDestructiveChanges) { await executePrePostCommands('commandsPreDeploy', { success: true, checkOnly: check, conn: options.conn, extraCommands: options.extraCommands }); uxLog("action", this, c.cyan('Both package.xml and destructive changes files exist but are empty. Nothing to deploy.')); await executePrePostCommands('commandsPostDeploy', { success: true, checkOnly: check, conn: options.conn, extraCommands: options.extraCommands }); await GitProvider.managePostPullRequestComment(check); return { messages: [], quickDeploy, deployXmlCount: 0 }; } // If we have empty package.xml and no destructive changes, there's nothing to do if (packageXmlIsEmpty && !hasDestructiveChanges) { await executePrePostCommands('commandsPreDeploy', { success: true, checkOnly: check, conn: options.conn, extraCommands: options.extraCommands }); uxLog("action", this, 'No deployment or destructive changes to perform'); await executePrePostCommands('commandsPostDeploy', { success: true, checkOnly: check, conn: options.conn, extraCommands: options.extraCommands }); await GitProvider.managePostPullRequestComment(check); return { messages: [], quickDeploy, deployXmlCount: 0 }; } // If we have empty package.xml but destructive changes, log it if (packageXmlIsEmpty && hasDestructiveChanges) { uxLog("action", this, c.cyan('Package.xml is empty, but destructive changes are present. Will proceed with deployment of destructive changes.')); } const splitDeployments = await buildDeploymentPackageXmls(packageXmlFile, check, debugMode, options); const messages = []; let deployXmlCount = splitDeployments.length; // If no deployments are planned but we have destructive changes, add a deployment with the existing package.xml if (deployXmlCount === 0 && hasDestructiveChanges) { uxLog("action", this, c.cyan('Creating deployment for destructive changes...')); splitDeployments.push({ label: 'package-for-destructive-changes', packageXmlFile: packageXmlFile, order: options.destructiveChangesAfterDeployment ? 999 : 0, }); deployXmlCount = 1; } else if (deployXmlCount === 0) { await executePrePostCommands('commandsPreDeploy', { success: true, checkOnly: check, conn: options.conn, extraCommands: options.extraCommands }); uxLog("other", this, 'No deployment to perform'); await executePrePostCommands('commandsPostDeploy', { success: true, checkOnly: check, conn: options.conn, extraCommands: options.extraCommands }); await GitProvider.managePostPullRequestComment(check); return { messages, quickDeploy, deployXmlCount }; } // Replace quick actions with dummy content in case we have dependencies between Flows & QuickActions await replaceQuickActionsWithDummy(); // Run deployment pre-commands await executePrePostCommands('commandsPreDeploy', { success: true, checkOnly: check, conn: options.conn, extraCommands: options.extraCommands }); // Process items of deployment plan uxLog("action", this, c.cyan('Processing split deployments build from deployment plan...')); uxLog("other", this, c.whiteBright(JSON.stringify(splitDeployments, null, 2))); for (const deployment of splitDeployments) { elapseStart(`deploy ${deployment.label}`); // Skip this deployment if package.xml is empty AND it's not a special destructive changes deployment // AND there are no destructive changes const isDestructiveChangesDeployment = deployment.label === 'package-for-destructive-changes'; const packageXmlEmpty = await isPackageXmlEmpty(deployment.packageXmlFile, { ignoreStandaloneParentItems: true }); if (packageXmlEmpty && !isDestructiveChangesDeployment && !hasDestructiveChanges) { uxLog("log", commandThis, c.grey(`Skipped ${c.bold(deployment.label)} deployment because package.xml is empty or contains only standalone parent items.\n${c.grey(c.italic('This may be related to filtering using package-no-overwrite.xml or packageDeployOnChange.xml'))}`)); deployXmlCount--; elapseEnd(`deploy ${deployment.label}`); continue; } let message = ''; // Wait before deployment item process if necessary if (deployment.waitBefore) { uxLog("log", commandThis, `Waiting ${deployment.waitBefore} seconds before deployment according to deployment plan`); await new Promise((resolve) => setTimeout(resolve, deployment.waitBefore * 1000)); } // Deployment of type package.xml file if (deployment.packageXmlFile) { const nbDeployedItems = await countPackageXmlItems(deployment.packageXmlFile); if (nbDeployedItems === 0 && !hasDestructiveChanges) { uxLog("warning", commandThis, c.yellow(`Skipping deployment of ${c.bold(deployment.label)} because package.xml is empty and there are no destructive changes.`)); elapseEnd(`deploy ${deployment.label}`); continue; } uxLog("action", commandThis, c.cyan(`${check ? 'Simulating deployment of' : 'Deploying'} ${c.bold(deployment.label)} package: ${deployment.packageXmlFile} (${nbDeployedItems} items)${hasDestructiveChanges ? ' with destructive changes' : ''}...`)); const branchConfig = await getConfig('branch'); // Try QuickDeploy if (check === false && (process.env?.SFDX_HARDIS_QUICK_DEPLOY || '') !== 'false') { const deploymentCheckId = await GitProvider.getDeploymentCheckId(); if (deploymentCheckId) { const quickDeployCommand = `sf project deploy quick` + ` --job-id ${deploymentCheckId} ` + (options.targetUsername ? ` -o ${options.targetUsername}` : '') + ` --wait ${getEnvVar("SFDX_DEPLOY_WAIT_MINUTES") || '120'}` + (debugMode ? ' --verbose' : '') + (process.env.SFDX_DEPLOY_DEV_DEBUG ? ' --dev-debug' : ''); const quickDeployRes = await execSfdxJson(quickDeployCommand, commandThis, { output: true, debug: debugMode, fail: false, }); if (quickDeployRes.status === 0) { uxLog("success", commandThis, c.green(`Successfully processed QuickDeploy for deploymentId ${deploymentCheckId}`)); uxLog("warning", commandThis, c.yellow('If you do not want to use QuickDeploy feature, define env variable SFDX_HARDIS_QUICK_DEPLOY=false')); quickDeploy = true; continue; } else { uxLog("warning", commandThis, c.yellow(`Unable to perform QuickDeploy for deploymentId ${deploymentCheckId}.\n${quickDeployRes.errorMessage}.`)); uxLog("success", commandThis, c.green("Switching back to effective deployment not using QuickDeploy: that's ok 😊")); const isProdOrg = await isProductionOrg(options.targetUsername || "", options); if (!isProdOrg) { testlevel = 'NoTestRun'; uxLog("success", commandThis, c.green('Note: run with NoTestRun to improve perfs as we had previously succeeded to simulate the deployment')); } } } // Adjust testlevel for deployment if needed for special case testCoverageNotBlocking else if (check === false && branchConfig?.testCoverageNotBlocking === true) { const isProdOrg = await isProductionOrg(options.targetUsername || "", options); if (!isProdOrg) { testlevel = 'NoTestRun'; uxLog("success", commandThis, c.green('Note: run with NoTestRun as we had previously succeeded to simulate the deployment with testCoverageNotBlocking=true')); } } } // No QuickDeploy Available, or QuickDeploy failing : try full deploy const reportDir = await getReportDirectory(); const hasCoverageFormatterJson = process.env?.COVERAGE_FORMATTER_JSON === "true" && testlevel !== 'NoTestRun' ? true : (testlevel === 'NoTestRun' || branchConfig?.skipCodeCoverage === true) ? false : true; const hasCoverageFormatterJsonSummary = (testlevel === 'NoTestRun' || branchConfig?.skipCodeCoverage === true) ? false : true; const deployCommand = `sf project deploy` + // (check && testlevel !== 'NoTestRun' ? ' validate' : ' start') + // Not until validate command is correct and accepts ignore-warnings ' start' + // (check && testlevel === 'NoTestRun' ? ' --dry-run' : '') + // validate with NoTestRun does not work, so use --dry-run (check ? ' --dry-run' : '') + ` --manifest "${deployment.packageXmlFile}"` + ' --ignore-warnings' + // So it does not fail in for objectTranslations stuff for example ' --ignore-conflicts' + // With CICD we are supposed to ignore them ((hasCoverageFormatterJson || hasCoverageFormatterJsonSummary) ? ` --results-dir ${reportDir}` : '') + ` --test-level ${testlevel}` + (options.testClasses && testlevel !== 'NoTestRun' ? ` --tests ${options.testClasses}` : '') + (options.preDestructiveChanges ? ` --pre-destructive-changes ${options.preDestructiveChanges}` : '') + (options.postDestructiveChanges && !(options.destructiveChangesAfterDeployment === true) ? ` --post-destructive-changes ${options.postDestructiveChanges}` : '') + (options.targetUsername ? ` -o ${options.targetUsername}` : '') + (hasCoverageFormatterJsonSummary ? ' --coverage-formatters json-summary' : '') + (hasCoverageFormatterJson ? ' --coverage-formatters json' : '') + (debugMode ? ' --verbose' : '') + ` --wait ${getEnvVar("SFDX_DEPLOY_WAIT_MINUTES") || '120'}` + (process.env.SFDX_DEPLOY_DEV_DEBUG ? ' --dev-debug' : '') + ` --json`; let deployRes; try { deployRes = await execCommand(deployCommand, commandThis, { output: false, debug: debugMode, fail: true, retry: deployment.retry || null, }); if (deployRes.status === 0) { uxLog("log", commandThis, c.grey(shortenLogLines(JSON.stringify(deployRes)))); } } catch (e) { await generateApexCoverageOutputFile(); // Special handling for "nothing to deploy" error with destructive changes if ((e.stdout + e.stderr).includes("No local changes to deploy") && hasDestructiveChanges) { uxLog("warning", commandThis, c.yellow(c.bold('Received "Nothing to Deploy" error, but destructive changes are present. ' + 'This can happen when only destructive changes are being deployed.'))); // Create a minimal response to avoid terminal freeze deployRes = { status: 0, // Treat as success stdout: JSON.stringify({ status: 0, result: { success: true, id: "destructiveChangesOnly", details: { componentSuccesses: [], runTestResult: null } } }), stderr: "" }; } else { deployRes = await handleDeployError(e, check, branchConfig, commandThis, options, deployment); } } if (typeof deployRes === 'object') { deployRes.stdout = JSON.stringify(deployRes); } await generateApexCoverageOutputFile(); // Set deployment id await getDeploymentId(deployRes.stdout + deployRes.stderr || ''); // Check org coverage if found in logs const orgCoveragePercent = await extractOrgCoverageFromLog(deployRes.stdout + deployRes.stderr || ''); if (orgCoveragePercent) { try { await checkDeploymentOrgCoverage(Number(orgCoveragePercent), { check: check, testlevel: testlevel, testClasses: options.testClasses }); } catch (errCoverage) { await GitProvider.managePostPullRequestComment(check); killBoringExitHandlers(); throw errCoverage; } } else { // Handle notif message when there is no apex const existingPrData = getPullRequestData(); const prDataCodeCoverage = { messageKey: existingPrData.messageKey ?? 'deployment', title: existingPrData.title ?? check ? '✅ Deployment check success' : '✅ Deployment success', codeCoverageMarkdownBody: testlevel === 'NoTestRun' ? '⚠️ Apex Tests has not been run thanks to useSmartDeploymentTests' : branchConfig?.skipCodeCoverage === true ? '✅⚠️ Code coverage has been skipped for this level' : '✅ No code coverage: It seems there is not Apex in this project', deployStatus: 'valid', }; setPullRequestData(prDataCodeCoverage); } let extraInfo = options?.delta === true ? 'DELTA Deployment' : 'FULL Deployment'; if (quickDeploy === true) { extraInfo += ' (using Quick Deploy)'; } // Display deployment status if (deployRes.status === 0) { message = `[sfdx-hardis] Successfully ${check ? 'checked deployment of' : 'deployed'} ${c.bold(deployment.label)} to target Salesforce org - ` + extraInfo; uxLog("success", commandThis, c.green(message)); if (deployRes?.testCoverageNotBlockingActivated === true) { uxLog("warning", commandThis, c.yellow('There is a code coverage issue, but the check is passing by design because you configured testCoverageNotBlocking: true in your branch .sfdx-hardis.yml')); } } else { message = `[sfdx-hardis] Unable to deploy ${c.bold(deployment.label)} to target Salesforce org - ` + extraInfo; uxLog("error", commandThis, c.red(c.bold(deployRes.errorMessage))); await displayDeploymentLink(deployRes.errorMessage, options); } // Restore quickActions after deployment of main package if (deployment.packageXmlFile.includes('mainPackage.xml')) { await restoreQuickActions(); } elapseEnd(`deploy ${deployment.label}`); } // Deployment of type data import else if (deployment.dataPath) { const dataPath = path.resolve(deployment.dataPath); await importData(dataPath, commandThis, options); } // Wait after deployment item process if necessary if (deployment.waitAfter) { uxLog("log", commandThis, `Waiting ${deployment.waitAfter} seconds after deployment according to deployment plan`); await new Promise((resolve) => setTimeout(resolve, deployment.waitAfter * 1000)); } messages.push(message); } // Run deployment post commands await executePrePostCommands('commandsPostDeploy', { success: true, checkOnly: check, conn: options.conn, extraCommands: options.extraCommands }); // Post pull request comment if available await GitProvider.managePostPullRequestComment(check); elapseEnd('all deployments'); return { messages, quickDeploy, deployXmlCount }; } async function handleDeployError(e, check, branchConfig, commandThis, options, deployment) { const output = e.stdout + e.stderr; // Handle coverage error if ignored if (check === true && branchConfig?.testCoverageNotBlocking === true) { const jsonResult = findJsonInString(output); if (isDeployCheckCoverageOnlyFailure(jsonResult, commandThis)) { uxLog("warning", commandThis, c.yellow(c.bold('Deployment status: Deploy check success & Ignored test coverage error'))); return { status: 0, stdout: e.stdout, stderr: e.stderr, testCoverageNotBlockingActivated: true }; } } // Handle Effective error const { errLog } = await analyzeDeployErrorLogs(output, true, { check: check }); uxLog("error", commandThis, c.red(c.bold('Sadly there has been Deployment error(s)'))); if (process.env?.SFDX_HARDIS_DEPLOY_ERR_COLORS === 'false') { uxLog("other", this, '\n' + errLog); } else { uxLog("error", this, c.red('\n' + errLog)); } await displayDeploymentLink(output, options); elapseEnd(`deploy ${deployment.label}`); await executePrePostCommands('commandsPostDeploy', { success: false, checkOnly: check, conn: options.conn }); await GitProvider.managePostPullRequestComment(check); killBoringExitHandlers(); throw new SfError('Deployment failure. Check messages above'); } /** * Returns true when a deploy-check (validate) failed only because of Apex code coverage. * This is used for branchConfig.testCoverageNotBlocking=true, to avoid masking real failures. */ export function isDeployCheckCoverageOnlyFailure(jsonResult, commandThis) { if (!jsonResult?.result) { uxLog("log", commandThis, c.grey('[testCoverageNotBlocking] No JSON result found in deploy output: cannot classify as coverage-only failure')); return false; } // When present, require the command to be a validation (deploy check) if (jsonResult.result.checkOnly === false) { uxLog("log", commandThis, c.grey('[testCoverageNotBlocking] JSON result.checkOnly is false: not a deploy-check validation')); return false; } const details = jsonResult.result.details; if (!details) { uxLog("log", commandThis, c.grey('[testCoverageNotBlocking] JSON result.details is missing: cannot validate failure reason')); return false; } // Do not ignore if we have real metadata/component failures if (details.componentFailures.length > 0) { uxLog("log", commandThis, c.grey(`[testCoverageNotBlocking] Found component failures (${details.componentFailures.length}): not a coverage-only failure`)); return false; } if (jsonResult.result.numberComponentErrors > 0) { uxLog("log", commandThis, c.grey(`[testCoverageNotBlocking] numberComponentErrors=${jsonResult.result.numberComponentErrors}: not a coverage-only failure`)); return false; } if (jsonResult.result.numberTestErrors > 0) { uxLog("log", commandThis, c.grey(`[testCoverageNotBlocking] numberTestErrors=${jsonResult.result.numberTestErrors}: test errors detected, not a coverage-only failure`)); return false; } if (jsonResult.result.numberTestsTotal !== jsonResult.result.numberTestsCompleted) { uxLog("log", commandThis, c.grey(`[testCoverageNotBlocking] numberTestsTotal (${jsonResult.result.numberTestsTotal}) != numberTestsCompleted (${jsonResult.result.numberTestsCompleted}): not a coverage-only failure`)); return false; } return true; } export function shortenLogLines(rawLog) { let rawLogCleaned = rawLog .replace(/(SOURCE PROGRESS \|.*\n)/gm, '') .replace(/(MDAPI PROGRESS \|.*\n)/gm, '') .replace(/(DEPLOY PROGRESS \|.*\n)/gm, '') .replace(/(Status: In Progress \|.*\n)/gm, ''); // Truncate JSON if huge log if (rawLogCleaned.split("\n").length > 1000 && !(process.env?.NO_TRUNCATE_LOGS === "true")) { const msg = "Result truncated by sfdx-hardis. Define NO_TRUNCATE_LOGS=true tu have full JSON logs"; const jsonLog = findJsonInString(rawLogCleaned); if (jsonLog) { if (jsonLog?.result?.details?.componentSuccesses) { jsonLog.result.details.componentSuccesses = jsonLog.result.details.componentSuccesses.filter(item => item.changed === true); jsonLog.truncatedBySfdxHardis = msg; } if (jsonLog?.result?.details?.runTestResult) { delete jsonLog.result.details.runTestResult; jsonLog.truncatedBySfdxHardis = msg; } if (jsonLog?.result?.files) { jsonLog.result.files = jsonLog.result.files.filter(item => item.state === 'Changed'); jsonLog.truncatedBySfdxHardis = msg; } rawLogCleaned = replaceJsonInString(rawLogCleaned, jsonLog); } } return rawLogCleaned; } async function getDeploymentId(rawLog) { // JSON Mode const jsonLog = findJsonInString(rawLog); if (jsonLog) { const deploymentId = jsonLog?.result?.id || null; if (deploymentId) { globalThis.pullRequestDeploymentId = deploymentId; return deploymentId; } } // Text mode const regex = /Deploy ID: (.*)/gm; if (rawLog && rawLog.match(regex)) { const deploymentId = (regex.exec(rawLog) || [])[1]; globalThis.pullRequestDeploymentId = deploymentId; return deploymentId; } uxLog("warning", this, c.yellow(`Unable to find deploymentId in logs \n${c.grey(rawLog)}`)); return null; } // Display deployment link in target org async function displayDeploymentLink(rawLog, options) { if (process?.env?.SFDX_HARDIS_DISPLAY_DEPLOYMENT_LINK === 'true') { let deploymentUrl = 'lightning/setup/DeployStatus/home'; const deploymentId = await getDeploymentId(rawLog); if (deploymentId) { const detailedDeploymentUrl = '/changemgmt/monitorDeploymentsDetails.apexp?' + encodeURIComponent(`retURL=/changemgmt/monitorDeployment.apexp&asyncId=${deploymentId}`); deploymentUrl = 'lightning/setup/DeployStatus/page?address=' + encodeURIComponent(detailedDeploymentUrl); } const openRes = await execSfdxJson(`sf org open -p ${deploymentUrl} --url-only` + (options.targetUsername ? ` --target-org ${options.targetUsername}` : ''), this, { fail: true, output: false, }); uxLog("warning", this, c.yellowBright(`Open deployment status page in org with url: ${c.bold(c.greenBright(openRes?.result?.url))}`)); } } // In some case we can not deploy the whole package.xml, so let's split it before 😊 async function buildDeploymentPackageXmls(packageXmlFile, check, debugMode, options = {}) { // Check for empty package.xml if (await isPackageXmlEmpty(packageXmlFile)) { uxLog("other", this, 'Empty package.xml: nothing to deploy'); return []; } const deployOncePackageXml = await buildDeployOncePackageXml(debugMode, options); const deployOnChangePackageXml = await buildDeployOnChangePackageXml(debugMode, options); // Copy main package.xml so it can be dynamically updated before deployment const tmpDir = await createTempDir(); const mainPackageXmlCopyFileName = path.join(tmpDir, 'calculated-package.xml'); await fs.copy(packageXmlFile, mainPackageXmlCopyFileName); const mainPackageXmlItem = { label: 'calculated-package-xml', packageXmlFile: mainPackageXmlCopyFileName, order: 0, }; const config = await getConfig('user'); // Build list of package.xml according to plan if (config.deploymentPlan && !check) { const deploymentItems = [mainPackageXmlItem]; // Work on deploymentPlan packages before deploying them const skipSplitPackages = (process.env.SFDX_HARDIS_DEPLOY_IGNORE_SPLIT_PACKAGES || 'true') !== 'false'; if (skipSplitPackages === true) { uxLog("warning", this, c.yellow('Do not split package.xml, as SFDX_HARDIS_DEPLOY_IGNORE_SPLIT_PACKAGES=false has not been found in ENV vars')); } else { for (const deploymentItem of config.deploymentPlan.packages) { if (deploymentItem.packageXmlFile) { // Copy deployment in temp packageXml file so it can be updated using package-no-overwrite and packageDeployOnChange deploymentItem.packageXmlFile = path.resolve(deploymentItem.packageXmlFile); const splitPackageXmlCopyFileName = path.join(tmpDir, path.basename(deploymentItem.packageXmlFile)); await fs.copy(deploymentItem.packageXmlFile, splitPackageXmlCopyFileName); deploymentItem.packageXmlFile = splitPackageXmlCopyFileName; // Remove split of packageXml content from main package.xml await removePackageXmlContent(mainPackageXmlCopyFileName, deploymentItem.packageXmlFile, false, { debugMode: debugMode, keepEmptyTypes: true, }); await applyPackageXmlFiltering(deploymentItem.packageXmlFile, deployOncePackageXml, deployOnChangePackageXml, debugMode); } deploymentItems.push(deploymentItem); } } await applyPackageXmlFiltering(mainPackageXmlCopyFileName, deployOncePackageXml, deployOnChangePackageXml, debugMode); // Sort in requested order const deploymentItemsSorted = sortArray(deploymentItems, { by: ['order', 'label'], order: ['asc', 'asc'], }); return deploymentItemsSorted; } // Return initial package.xml file minus deployOnce and deployOnChange items else { await applyPackageXmlFiltering(mainPackageXmlCopyFileName, deployOncePackageXml, deployOnChangePackageXml, debugMode); return [ { label: 'calculated-package-xml', packageXmlFile: mainPackageXmlCopyFileName, }, ]; } } // Apply packageXml filtering using deployOncePackageXml and deployOnChangePackageXml async function applyPackageXmlFiltering(packageXml, deployOncePackageXml, deployOnChangePackageXml, debugMode) { // Main packageXml: Remove package-no-overwrite.xml items that are already present in target org if (deployOncePackageXml) { await removePackageXmlContent(packageXml, deployOncePackageXml, false, { debugMode: debugMode, keepEmptyTypes: true, }); } //Main packageXml: Remove packageDeployOnChange.xml items that are not different in target org if (deployOnChangePackageXml) { await removePackageXmlContent(packageXml, deployOnChangePackageXml, false, { debugMode: debugMode, keepEmptyTypes: true, }); } } // package-no-overwrite.xml items are deployed only if they are not in the target org async function buildDeployOncePackageXml(debugMode = false, options = {}) { if (process.env.SKIP_PACKAGE_DEPLOY_ONCE === 'true') { uxLog("warning", this, c.yellow("Skipped package-no-overwrite.xml management because of env variable SKIP_PACKAGE_DEPLOY_ONCE='true'")); return null; } // Get default package-no-overwrite let packageNoOverwrite = path.resolve('./manifest/package-no-overwrite.xml'); if (!fs.existsSync(packageNoOverwrite)) { packageNoOverwrite = path.resolve('./manifest/packageDeployOnce.xml'); } const config = await getConfig("branch"); if (process.env?.PACKAGE_NO_OVERWRITE_PATH || config?.packageNoOverwritePath) { packageNoOverwrite = process.env.PACKAGE_NO_OVERWRITE_PATH || config?.packageNoOverwritePath; if (!fs.existsSync(packageNoOverwrite)) { throw new SfError(`packageNoOverwritePath property or PACKAGE_NO_OVERWRITE_PATH leads not existing file ${packageNoOverwrite}`); } uxLog("log", this, c.grey(`Using custom package-no-overwrite file defined at ${packageNoOverwrite}`)); } if (fs.existsSync(packageNoOverwrite)) { uxLog("action", this, c.cyan('Handling package-no-overwrite.xml (Metadata that are not overwritten if existing in target org)...')); // If package-no-overwrite.xml is not empty, build target org package.xml and remove its content from packageOnce.xml if (!(await isPackageXmlEmpty(packageNoOverwrite))) { const tmpDir = await createTempDir(); // Build target org package.xml uxLog("action", this, c.cyan(`Generating full package.xml from target org to identify its items matching with package-no-overwrite.xml ...`)); const targetOrgPackageXml = path.join(tmpDir, 'packageTargetOrg.xml'); await buildOrgManifest(options.targetUsername, targetOrgPackageXml, options.conn); let calculatedPackageNoOverwrite = path.join(tmpDir, 'package-no-overwrite.xml'); await fs.copy(packageNoOverwrite, calculatedPackageNoOverwrite); // Keep in deployOnce.xml only what is necessary to deploy await removePackageXmlContent(calculatedPackageNoOverwrite, targetOrgPackageXml, true, { debugMode: debugMode, keepEmptyTypes: false, }); await fs.copy(calculatedPackageNoOverwrite, path.join(tmpDir, 'calculated-package-no-overwrite.xml')); calculatedPackageNoOverwrite = path.join(tmpDir, 'calculated-package-no-overwrite.xml'); uxLog("log", this, c.grey(`calculated-package-no-overwrite.xml with only items that already exist in target org: ${calculatedPackageNoOverwrite}`)); // Check if there is still something in calculated-package-no-overwrite.xml if (!(await isPackageXmlEmpty(calculatedPackageNoOverwrite))) { return calculatedPackageNoOverwrite; } } } return null; } // packageDeployOnChange.xml items are deployed only if they have changed in target org export async function buildDeployOnChangePackageXml(debugMode, options = {}) { if (process.env.SKIP_PACKAGE_DEPLOY_ON_CHANGE === 'true') { uxLog("warning", this, c.yellow("Skipped packageDeployOnChange.xml management because of env variable SKIP_PACKAGE_DEPLOY_ON_CHANGE='true'")); return null; } // Check if packageDeployOnChange.xml is defined const packageDeployOnChangePath = './manifest/packageDeployOnChange.xml'; if (!fs.existsSync(packageDeployOnChangePath)) { return null; } // Retrieve sfdx sources in local git repo await execCommand(`sf project retrieve start --manifest ${packageDeployOnChangePath}` + (options.targetUsername ? ` --target-org ${options.targetUsername}` : ''), this, { fail: true, output: true, debug: debugMode, }); // Do not call delta if no updated file has been retrieved const hasGitLocalUpdates = await gitHasLocalUpdates(); if (hasGitLocalUpdates === false) { uxLog("log", this, c.grey('No diff retrieved from packageDeployOnChange.xml')); return null; } // "Temporarily" commit updates so sfdx-git-delta can build diff package.xml await git().addConfig('user.email', 'bot@hardis.com', false, 'global'); await git().addConfig('user.name', 'Hardis', false, 'global'); await git().add('--all'); await git().commit('"temp"', ['--no-verify']); // Generate package.xml git delta const tmpDir = await createTempDir(); const gitDeltaCommandRes = await callSfdxGitDelta('HEAD~1', 'HEAD', tmpDir, { debug: debugMode }); // Now that the diff is computed, we can dump the temporary commit await git().reset(ResetMode.HARD, ['HEAD~1']); // Check git delta is ok const diffPackageXml = path.join(tmpDir, 'package', 'package.xml'); if (gitDeltaCommandRes?.status !== 0 || !fs.existsSync(diffPackageXml)) { throw new SfError('Error while running sfdx-git-delta:\n' + JSON.stringify(gitDeltaCommandRes)); } // Remove from original packageDeployOnChange the items that has not been updated const packageXmlDeployOnChangeToUse = path.join(tmpDir, 'packageDeployOnChange.xml'); await fs.copy(packageDeployOnChangePath, packageXmlDeployOnChangeToUse); await removePackageXmlContent(packageXmlDeployOnChangeToUse, diffPackageXml, false, { debugMode: debugMode, keepEmptyTypes: false, }); uxLog("log", this, c.grey(`packageDeployOnChange.xml filtered to keep only metadatas that have changed: ${packageXmlDeployOnChangeToUse}`)); // Return result return packageXmlDeployOnChangeToUse; } // Remove content of a package.xml file from another package.xml file export async function removePackageXmlContent(packageXmlFile, packageXmlFileToRemove, removedOnly = false, options = { debugMode: false, keepEmptyTypes: false }) { if (removedOnly === false) { uxLog("action", this, c.cyan(`Removing ${c.green(path.basename(packageXmlFileToRemove))} items from ${c.green(path.basename(packageXmlFile))}...`)); } else { uxLog("action", this, c.cyan(`Keeping ${c.green(path.basename(packageXmlFileToRemove))} items matching with ${c.green(path.basename(packageXmlFile))} (and remove the rest)...`)); } await removePackageXmlFilesContent(packageXmlFile, packageXmlFileToRemove, { outputXmlFile: packageXmlFile, logFlag: options.debugMode, removedOnly: removedOnly, keepEmptyTypes: options.keepEmptyTypes || false, }); } // Deploy destructive changes export async function deployDestructiveChanges(packageDeletedXmlFile, options = { debug: false, check: false }, commandThis) { // Create empty deployment file because of SF CLI limitation // cf https://gist.github.com/benahm/b590ecf575ff3c42265425233a2d727e uxLog("action", commandThis, c.cyan(`Deploying destructive changes from file ${path.resolve(packageDeletedXmlFile)}`)); const tmpDir = await createTempDir(); const emptyPackageXmlFile = path.join(tmpDir, 'package.xml'); await fs.writeFile(emptyPackageXmlFile, `<?xml version="1.0" encoding="UTF-8"?> <Package xmlns="http://soap.sforce.com/2006/04/metadata"> <version>${getApiVersion()}</version> </Package>`, 'utf8'); await fs.copy(packageDeletedXmlFile, path.join(tmpDir, 'destructiveChanges.xml')); const deployDelete = `sf project deploy ${options.check ? 'validate' : 'start'} --metadata-dir ${tmpDir}` + ` --wait ${getEnvVar("SFDX_DEPLOY_WAIT_MINUTES") || '120'}` + ` --test-level ${options.testLevel || 'NoTestRun'}` + ' --ignore-warnings' + // So it does not fail in case metadata is already deleted (options.targetUsername ? ` --target-org ${options.targetUsername}` : '') + (options.debug ? ' --verbose' : '') + ' --json'; // Deploy destructive changes let deployDeleteRes = {}; try { deployDeleteRes = await execCommand(deployDelete, commandThis, { output: true, debug: options.debug, fail: true, }); } catch (e) { const { errLog } = await analyzeDeployErrorLogs(e.stdout + e.stderr, true, {}); uxLog("error", this, c.red('Sadly there has been destruction error(s)')); uxLog("error", this, c.red('\n' + errLog)); uxLog("warning", this, c.yellow(c.bold('That could be a false positive, as in real deployment, the package.xml deployment will be committed before the use of destructiveChanges.xml'))); killBoringExitHandlers(); throw new SfError('Error while deploying destructive changes'); } await fs.remove(tmpDir); let deleteMsg = ''; if (deployDeleteRes.status === 0) { deleteMsg = `[sfdx-hardis] Successfully ${options.check ? 'checked deployment of' : 'deployed'} destructive changes to Salesforce org`; uxLog("success", commandThis, c.green(deleteMsg)); } else { deleteMsg = '[sfdx-hardis] Unable to deploy destructive changes to Salesforce org'; uxLog("error", commandThis, c.red(deployDeleteRes.errorMessage)); } } export async function deployMetadatas(options = { deployDir: '.', testlevel: 'RunLocalTests', check: false, debug: false, targetUsername: null, tryOnce: false, runTests: null, }) { // Perform deployment let deployCommand = `sf project deploy ${options.check ? 'validate' : 'start'}` + ` --metadata-dir ${options.deployDir || '.'}` + ` --wait ${getEnvVar("SFDX_DEPLOY_WAIT_MINUTES") || '120'}` + ` --test-level ${options.testlevel || 'RunLocalTests'}` + ` --api-version ${options.apiVersion || getApiVersion()}` + (options.targetUsername ? ` --target-org ${options.targetUsername}` : '') + (options.debug ? ' --verbose' : '') + ' --json'; if (options.runTests && options.testlevel == 'RunSpecifiedTests') { deployCommand += ` --tests ${options.runTests.join(',')}`; } let deployRes; try { deployRes = await execCommand(deployCommand, this, { output: true, debug: options.debug, fail: true, }); } catch (e) { // workaround if --soapdeploy is not available if (JSON.stringify(e).includes('--soapdeploy') && !options.tryOnce === true) { uxLog("warning", this, c.yellow("This may be a error with a workaround... let's try it 😊")); try { deployRes = await execCommand(deployCommand.replace(' --soapdeploy', ''), this, { output: true, debug: options.debug, fail: true, }); } catch (e2) { if (JSON.stringify(e2).includes('NoTestRun')) { // Another workaround: try running tests uxLog("warning", this, c.yellow("This may be again an error with a workaround... let's make a last attempt 😊")); deployRes = await execCommand(deployCommand.replace(' --soapdeploy', '').replace('NoTestRun', 'RunLocalTests'), this, { output: true, debug: options.debug, fail: true, }); } else { killBoringExitHandlers(); throw e2; } } } else { await checkDeploymentErrors(e, options); } } return deployRes; } let quickActionsBackUpFolder; // Replace QuickAction content with Dummy content that will always pass async function replaceQuickActionsWithDummy() { if (process.env.CI_DEPLOY_QUICK_ACTIONS_DUMMY === 'true') { uxLog("action", this, c.cyan('Replacing QuickActions content with Dummy content t