diff --git a/package.json b/package.json index 6e11f68d..5559eedf 100644 --- a/package.json +++ b/package.json @@ -15,8 +15,8 @@ "@salesforce/kit": "^3.2.4", "@salesforce/plugin-info": "^3.4.93", "@salesforce/sf-plugins-core": "^12.2.5", - "@salesforce/source-deploy-retrieve": "^12.31.19", - "@salesforce/source-tracking": "^7.8.4", + "@salesforce/source-deploy-retrieve": "^12.31.21", + "@salesforce/source-tracking": "^7.8.6", "@salesforce/ts-types": "^2.0.12", "ansis": "^3.17.0", "terminal-link": "^3.0.0" diff --git a/src/commands/project/deploy/start.ts b/src/commands/project/deploy/start.ts index 9ef9e592..56881e41 100644 --- a/src/commands/project/deploy/start.ts +++ b/src/commands/project/deploy/start.ts @@ -22,7 +22,14 @@ import { DeployStages } from '../../../utils/deployStages.js'; import { AsyncDeployResultFormatter } from '../../../formatters/asyncDeployResultFormatter.js'; import { DeployResultFormatter } from '../../../formatters/deployResultFormatter.js'; import { AsyncDeployResultJson, DeployResultJson, TestLevel } from '../../../utils/types.js'; -import { executeDeploy, resolveApi, validateTests, determineExitCode, buildDeployUrl } from '../../../utils/deploy.js'; +import { + executeDeploy, + resolveApi, + validateTests, + determineExitCode, + buildDeployUrl, + buildPreDestructiveFileResponses, +} from '../../../utils/deploy.js'; import { DeployCache } from '../../../utils/deployCache.js'; import { DEPLOY_STATUS_CODES_DESCRIPTIONS } from '../../../utils/errorCodes.js'; import { ConfigVars } from '../../../configMeta.js'; @@ -250,7 +257,7 @@ export default class DeployMetadata extends SfCommand { return Promise.resolve(); }); - const { deploy } = await executeDeploy( + const { deploy, componentSet } = await executeDeploy( { ...flags, 'target-org': username, @@ -268,6 +275,9 @@ export default class DeployMetadata extends SfCommand { throw new SfError('The deploy id is not available.'); } + // Capture pre-destructive file responses BEFORE deploy executes + const preDestructiveFileResponses = await buildPreDestructiveFileResponses(componentSet, project); + this.stages = new DeployStages({ title, jsonEnabled: this.jsonEnabled(), @@ -301,7 +311,8 @@ export default class DeployMetadata extends SfCommand { const result = await deploy.pollStatus({ timeout: flags.wait }); process.exitCode = determineExitCode(result); this.stages.stop(); - const formatter = new DeployResultFormatter(result, flags, undefined, true); + + const formatter = new DeployResultFormatter(result, flags, preDestructiveFileResponses, true); if (!this.jsonEnabled()) { formatter.display(); diff --git a/src/utils/deploy.ts b/src/utils/deploy.ts index 909f07f4..8fdd80e4 100644 --- a/src/utils/deploy.ts +++ b/src/utils/deploy.ts @@ -14,13 +14,17 @@ * limitations under the License. */ +import { relative } from 'node:path'; import { ConfigAggregator, Messages, Org, SfError, SfProject } from '@salesforce/core'; import { Duration } from '@salesforce/kit'; import { Nullable } from '@salesforce/ts-types'; import { ComponentSet, ComponentSetBuilder, + ComponentStatus, DeployResult, + DestructiveChangesType, + FileResponseSuccess, MetadataApiDeploy, MetadataApiDeployOptions, RegistryAccess, @@ -254,3 +258,84 @@ export function buildDeployUrl(org: Org, deployId: string): string { const orgInstanceUrl = String(org.getField(Org.Fields.INSTANCE_URL)); return `${orgInstanceUrl}/lightning/setup/DeployStatus/page?address=%2Fchangemgmt%2FmonitorDeploymentsDetails.apexp%3FasyncId%3D${deployId}%26retURL%3D%252Fchangemgmt%252FmonitorDeployment.apexp`; } + +/** + * Creates synthetic FileResponse objects for components in pre-destructive changes. + * This ensures all file paths (e.g., .cls and .xml for ApexClass, or all LWC bundle files) + * are shown in the deployment results table. This is needed because pre-destructive files + * are deleted BEFORE the deploy, so getFileResponses() cannot access them. + * + * @param componentSet - The ComponentSet from the deployment (before deploy executes) + * @param project - The SfProject to resolve file paths from + * @returns Array of synthetic FileResponseSuccess objects representing pre-deleted files + */ +export async function buildPreDestructiveFileResponses( + componentSet?: ComponentSet, + project?: SfProject +): Promise { + if (!componentSet || !project) { + return []; + } + + const fileResponses: FileResponseSuccess[] = []; + + // Get all source components and filter for pre-destructive ones + const allComponents = componentSet.getSourceComponents().toArray(); + + const preDestructiveComponents = allComponents.filter( + (component) => component.getDestructiveChangesType() === DestructiveChangesType.PRE + ); + + if (preDestructiveComponents.length === 0) { + return [] as FileResponseSuccess[]; + } + + // Build metadata entries for ComponentSetBuilder + const metadataEntries = preDestructiveComponents.map((comp) => `${comp.type.name}:${comp.fullName}`); + + // Resolve the components from the project to get their file paths + try { + const resolvedComponentSet = await ComponentSetBuilder.build({ + metadata: { + metadataEntries, + directoryPaths: await getPackageDirs(), + }, + projectDir: project.getPath(), + }); + const resolvedComponents = resolvedComponentSet.getSourceComponents().toArray(); + + preDestructiveComponents.length = 0; + preDestructiveComponents.push(...resolvedComponents); + } catch (error) { + // If this's not resolve, try to resolve with registry only + } + + for (const component of preDestructiveComponents) { + // Get all file paths for this component (metadata XML + content files) + const filePaths: string[] = []; + const projectPath = project.getPath(); + + if (component.xml) { + const relativePath = relative(projectPath, component.xml); + filePaths.push(relativePath); + } + + // Add all content files (for bundles, this includes all files in the directory) + const contentPaths = component.walkContent(); + for (const contentPath of contentPaths) { + const relativePath = relative(projectPath, contentPath); + filePaths.push(relativePath); + } + + for (const filePath of filePaths) { + fileResponses.push({ + fullName: component.fullName, + type: component.type.name, + state: ComponentStatus.Deleted, + filePath, + }); + } + } + + return fileResponses; +} diff --git a/test/utils/output.test.ts b/test/utils/output.test.ts index fdc79bec..a230bc3e 100644 --- a/test/utils/output.test.ts +++ b/test/utils/output.test.ts @@ -17,7 +17,13 @@ import path from 'node:path'; import { stripVTControlCharacters } from 'node:util'; import { assert, expect, config } from 'chai'; import sinon from 'sinon'; -import { DeployMessage, DeployResult, Failures, FileResponse } from '@salesforce/source-deploy-retrieve'; +import { + DeployMessage, + DeployResult, + Failures, + FileResponse, + ComponentStatus, +} from '@salesforce/source-deploy-retrieve'; import { Ux } from '@salesforce/sf-plugins-core'; import { getCoverageFormattersOptions } from '../../src/utils/coverage.js'; import { getZipFileSize } from '../../src/utils/output.js'; @@ -172,6 +178,82 @@ describe('deployResultFormatter', () => { }); }); + describe('displayDeletes', () => { + let tableStub: sinon.SinonStub; + + beforeEach(() => { + tableStub = sandbox.stub(Ux.prototype, 'table'); + }); + + it('should display pre-destructive delete files in Deleted Source table', () => { + const deployResult = getDeployResult('successSync'); + + // Create pre-destructive file responses with proper typing + const preDestructiveFiles: FileResponse[] = [ + { + fullName: 'CustomObject__c', + type: 'CustomObject', + state: ComponentStatus.Deleted, + filePath: 'force-app/main/default/objects/CustomObject__c/CustomObject__c.object-meta.xml', + }, + { + fullName: 'CustomField__c.TestField__c', + type: 'CustomField', + state: ComponentStatus.Deleted, + filePath: 'force-app/main/default/objects/CustomObject__c/fields/TestField__c.field-meta.xml', + }, + ]; + + // Pass pre-destructive files as extraDeletes to formatter + // eslint-disable-next-line @typescript-eslint/no-explicit-any,@typescript-eslint/no-unsafe-argument + const formatter = new DeployResultFormatter(deployResult, { verbose: true }, preDestructiveFiles as any); + formatter.display(); + + // The formatter should call table() to display the Deleted Source + const deletesTableCall = tableStub.getCalls().find((call) => { + const callArg = call.args[0] as { title?: string }; + return callArg?.title && callArg.title.includes('Deleted Source'); + }); + + expect(deletesTableCall).to.exist; + if (deletesTableCall) { + const tableArg = deletesTableCall.args[0] as { + data: Array<{ fullName: string; type: string; filePath: string; state: string }>; + }; + expect(tableArg.data).to.deep.equal([ + { + fullName: 'CustomObject__c', + type: 'CustomObject', + state: 'Deleted', + filePath: 'force-app/main/default/objects/CustomObject__c/CustomObject__c.object-meta.xml', + }, + { + fullName: 'CustomField__c.TestField__c', + type: 'CustomField', + state: 'Deleted', + filePath: 'force-app/main/default/objects/CustomObject__c/fields/TestField__c.field-meta.xml', + }, + ]); + } + }); + + it('should not display Deleted Source table when there are no deletes', () => { + const deployResult = getDeployResult('successSync'); + + // Pass empty pre-destructive files + const formatter = new DeployResultFormatter(deployResult, { verbose: true }, []); + formatter.display(); + + // Verify no Deleted Source table is displayed + const deletesTableCall = tableStub.getCalls().find((call) => { + const callArg = call.args[0] as { title?: string }; + return callArg?.title && callArg.title.includes('Deleted Source'); + }); + + expect(deletesTableCall).to.not.exist; + }); + }); + describe('coverage functions', () => { describe('getCoverageFormattersOptions', () => { it('clover, json', () => { diff --git a/yarn.lock b/yarn.lock index 1e3e6cd1..9252775a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1697,7 +1697,7 @@ cli-progress "^3.12.0" terminal-link "^3.0.0" -"@salesforce/source-deploy-retrieve@^12.31.0", "@salesforce/source-deploy-retrieve@^12.31.19", "@salesforce/source-deploy-retrieve@^12.31.8": +"@salesforce/source-deploy-retrieve@^12.31.0": version "12.31.19" resolved "https://registry.yarnpkg.com/@salesforce/source-deploy-retrieve/-/source-deploy-retrieve-12.31.19.tgz#5feac0c809ee36c2ea3e06321e4e56c28e83da40" integrity sha512-ITf0KQOWS4OfNGE8SYerwaB9PnVZS2aXgctM9EjINmRiMJBVgQ5Pjm71IIJiR6yiEsAGfnn0S7Efir2RgtmL7Q== @@ -1717,6 +1717,26 @@ proxy-agent "^6.4.0" yaml "^2.8.1" +"@salesforce/source-deploy-retrieve@^12.31.20", "@salesforce/source-deploy-retrieve@^12.31.21": + version "12.31.21" + resolved "https://registry.yarnpkg.com/@salesforce/source-deploy-retrieve/-/source-deploy-retrieve-12.31.21.tgz#63432190be223192cdbf4310e5f51ac5c8a74f82" + integrity sha512-NZsJlvIrfIIZF03P7iLLmewjYkMsGSIV83+O5k+HGBvDDU8c2IJge3MIV/N78GSAGYahtSE+yR4x75UY0Of7qw== + dependencies: + "@salesforce/core" "^8.27.0" + "@salesforce/kit" "^3.2.4" + "@salesforce/ts-types" "^2.0.12" + "@salesforce/types" "^1.6.0" + fast-levenshtein "^3.0.0" + fast-xml-parser "^5.3.6" + got "^11.8.6" + graceful-fs "^4.2.11" + ignore "^5.3.2" + jszip "^3.10.1" + mime "2.6.0" + minimatch "^9.0.5" + proxy-agent "^6.4.0" + yaml "^2.8.1" + "@salesforce/source-testkit@^2.2.163": version "2.2.172" resolved "https://registry.yarnpkg.com/@salesforce/source-testkit/-/source-testkit-2.2.172.tgz#74ea2f84d62f00b89a871b30ec2fdc933d90edb7" @@ -1733,16 +1753,16 @@ shelljs "^0.10.0" sinon "^10.0.0" -"@salesforce/source-tracking@^7.8.4": - version "7.8.4" - resolved "https://registry.yarnpkg.com/@salesforce/source-tracking/-/source-tracking-7.8.4.tgz#e0f9a70cb50d15d3cbf262183d1b44c963317930" - integrity sha512-mF7tbRYOjMUYNFfyMHK85qDJACXkR4wP6Osmzc1kAuvArn7yK9gFEKPHS3EEh7oR/yO/uQIbnoNMAAUceOfYMg== +"@salesforce/source-tracking@^7.8.6": + version "7.8.6" + resolved "https://registry.yarnpkg.com/@salesforce/source-tracking/-/source-tracking-7.8.6.tgz#9f22ab741d8a37bed32bb741825311881f093524" + integrity sha512-aI2HAcMAOz8KQev3DNBptMLpgpMVsVpY8G9V3Mhdql28fg5OmBE5cMxhXcKHhMvSgpZbcEn4+d0ykdzQUl1nxQ== dependencies: - "@salesforce/core" "^8.24.0" + "@salesforce/core" "^8.27.0" "@salesforce/kit" "^3.2.4" - "@salesforce/source-deploy-retrieve" "^12.31.8" + "@salesforce/source-deploy-retrieve" "^12.31.20" "@salesforce/ts-types" "^2.0.12" - fast-xml-parser "^5.3.4" + fast-xml-parser "^5.3.8" graceful-fs "^4.2.11" isomorphic-git "^1.34.2" ts-retry-promise "^0.8.1" @@ -4555,6 +4575,13 @@ fast-xml-builder@^1.0.0: resolved "https://registry.yarnpkg.com/fast-xml-builder/-/fast-xml-builder-1.0.0.tgz#a485d7e8381f1db983cf006f849d1066e2935241" integrity sha512-fpZuDogrAgnyt9oDDz+5DBz0zgPdPZz6D4IR7iESxRXElrlGTRkHJ9eEt+SACRJwT0FNFrt71DFQIUFBJfX/uQ== +fast-xml-builder@^1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/fast-xml-builder/-/fast-xml-builder-1.1.3.tgz#283579acba94aecf998a7e1339bc7e037195abc1" + integrity sha512-1o60KoFw2+LWKQu3IdcfcFlGTW4dpqEWmjhYec6H82AYZU2TVBXep6tMl8Z1Y+wM+ZrzCwe3BZ9Vyd9N2rIvmg== + dependencies: + path-expression-matcher "^1.1.3" + fast-xml-parser@5.3.4: version "5.3.4" resolved "https://registry.yarnpkg.com/fast-xml-parser/-/fast-xml-parser-5.3.4.tgz#06f39aafffdbc97bef0321e626c7ddd06a043ecf" @@ -4562,7 +4589,7 @@ fast-xml-parser@5.3.4: dependencies: strnum "^2.1.0" -fast-xml-parser@^5.3.4, fast-xml-parser@^5.3.6: +fast-xml-parser@^5.3.6: version "5.4.2" resolved "https://registry.yarnpkg.com/fast-xml-parser/-/fast-xml-parser-5.4.2.tgz#7fc66463b59260b0c5fd57edf46148a418bde68b" integrity sha512-pw/6pIl4k0CSpElPEJhDppLzaixDEuWui2CUQQBH/ECDf7+y6YwA4Gf7Tyb0Rfe4DIMuZipYj4AEL0nACKglvQ== @@ -4570,6 +4597,15 @@ fast-xml-parser@^5.3.4, fast-xml-parser@^5.3.6: fast-xml-builder "^1.0.0" strnum "^2.1.2" +fast-xml-parser@^5.3.8: + version "5.5.5" + resolved "https://registry.yarnpkg.com/fast-xml-parser/-/fast-xml-parser-5.5.5.tgz#cadbcb992d6ac3f7e643d459506a8e1dd8adf5f2" + integrity sha512-NLY+V5NNbdmiEszx9n14mZBseJTC50bRq1VHsaxOmR72JDuZt+5J1Co+dC/4JPnyq+WrIHNM69r0sqf7BMb3Mg== + dependencies: + fast-xml-builder "^1.1.3" + path-expression-matcher "^1.1.3" + strnum "^2.1.2" + fastest-levenshtein@^1.0.7: version "1.0.16" resolved "https://registry.yarnpkg.com/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz#210e61b6ff181de91ea9b3d1b84fdedd47e034e5" @@ -7102,6 +7138,11 @@ path-exists@^4.0.0: resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3" integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w== +path-expression-matcher@^1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/path-expression-matcher/-/path-expression-matcher-1.1.3.tgz#8bf7c629dc1b114e42b633c071f06d14625b4e0d" + integrity sha512-qdVgY8KXmVdJZRSS1JdEPOKPdTiEK/pi0RkcT2sw1RhXxohdujUlJFPuS1TSkevZ9vzd3ZlL7ULl1MHGTApKzQ== + path-is-absolute@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f"