Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions src/commands/project/deploy/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -250,7 +257,7 @@ export default class DeployMetadata extends SfCommand<DeployResultJson> {
return Promise.resolve();
});

const { deploy } = await executeDeploy(
const { deploy, componentSet } = await executeDeploy(
{
...flags,
'target-org': username,
Expand All @@ -268,6 +275,9 @@ export default class DeployMetadata extends SfCommand<DeployResultJson> {
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(),
Expand Down Expand Up @@ -301,7 +311,8 @@ export default class DeployMetadata extends SfCommand<DeployResultJson> {
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();
Expand Down
85 changes: 85 additions & 0 deletions src/utils/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<FileResponseSuccess[]> {
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;
}
84 changes: 83 additions & 1 deletion test/utils/output.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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', () => {
Expand Down
Loading