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', () => {