Skip to content

Commit

Permalink
fix: remote-only deletes now supported (#220)
Browse files Browse the repository at this point in the history
* fix: remote-only deletes now supported

* chore: code review I - perf

* chore: update messages

* chore: fix tests after conflicts changed stubs
  • Loading branch information
WillieRuemmele authored Oct 6, 2021
1 parent d87671b commit fed3ff4
Show file tree
Hide file tree
Showing 5 changed files with 112 additions and 33 deletions.
4 changes: 3 additions & 1 deletion messages/delete.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,5 +37,7 @@
"If you don’t specify a test level, the default behavior depends on the contents of your deployment package. For more information, see “Running Tests in a Deployment” in the Metadata API Developer Guide."
]
},
"prompt": "This operation will delete the following files on your computer and in your org: \n%s\n\nAre you sure you want to proceed (y/n)?"
"localPrompt": "This operation will delete the following files on your computer and in your org: \n%s",
"remotePrompt": "This operation will delete the following metadata in your org: \n%s",
"areYouSure": "\n\nAre you sure you want to proceed (y/n)?"
}
55 changes: 41 additions & 14 deletions src/commands/force/source/delete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ import * as fs from 'fs';
import { confirm } from 'cli-ux/lib/prompt';
import { flags, FlagsConfig } from '@salesforce/command';
import { Messages } from '@salesforce/core';
import { ComponentSet, RequestStatus, SourceComponent } from '@salesforce/source-deploy-retrieve';
import { Duration, once, env } from '@salesforce/kit';
import { ComponentSet, MetadataComponent, RequestStatus, SourceComponent } from '@salesforce/source-deploy-retrieve';
import { Duration, env, once } from '@salesforce/kit';
import { getString } from '@salesforce/ts-types';
import { DeployCommand } from '../../../deployCommand';
import { ComponentSetBuilder } from '../../../componentSetBuilder';
Expand Down Expand Up @@ -72,10 +72,10 @@ export class Delete extends DeployCommand {
};
protected xorFlags = ['metadata', 'sourcepath'];
protected readonly lifecycleEventNames = ['predeploy', 'postdeploy'];
private sourceComponents: SourceComponent[];
private isRest = false;
private deleteResultFormatter: DeleteResultFormatter;
private aborted = false;
private components: MetadataComponent[];

private updateDeployId = once((id) => {
this.displayDeployId(id);
Expand Down Expand Up @@ -107,26 +107,31 @@ export class Delete extends DeployCommand {
},
});

this.sourceComponents = this.componentSet.getSourceComponents().toArray();
this.components = this.componentSet.toArray();

if (!this.sourceComponents.length) {
if (!this.components.length) {
// if we didn't find any components to delete, let the user know and exit
this.deleteResultFormatter.displayNoResultsFound();
return;
}

// create a new ComponentSet and mark everything for deletion
const cs = new ComponentSet([]);
this.sourceComponents.map((component) => {
cs.add(component, true);
this.components.map((component) => {
if (component instanceof SourceComponent) {
cs.add(component, true);
} else {
// a remote-only delete
cs.add(new SourceComponent({ name: component.fullName, type: component.type }), true);
}
});
this.componentSet = cs;

this.aborted = !(await this.handlePrompt());
if (this.aborted) return;

// fire predeploy event for the delete
await this.lifecycle.emit('predeploy', this.componentSet.toArray());
await this.lifecycle.emit('predeploy', this.components);
this.isRest = await this.isRestDeploy();
this.ux.log(`*** Deleting with ${this.isRest ? 'REST' : 'SOAP'} API ***`);

Expand Down Expand Up @@ -178,7 +183,7 @@ export class Delete extends DeployCommand {

private deleteFilesLocally(): void {
if (!this.getFlag('checkonly') && getString(this.deployResult, 'response.status') === 'Succeeded') {
this.sourceComponents.map((component) => {
this.components.map((component: SourceComponent) => {
// delete the content and/or the xml of the components
if (component.content) {
const stats = fs.lstatSync(component.content);
Expand All @@ -188,8 +193,7 @@ export class Delete extends DeployCommand {
fs.unlinkSync(component.content);
}
}
// the xml could've been deleted as part of a bundle type above
if (component.xml && fs.existsSync(component.xml)) {
if (component.xml) {
fs.unlinkSync(component.xml);
}
});
Expand All @@ -198,10 +202,33 @@ export class Delete extends DeployCommand {

private async handlePrompt(): Promise<boolean> {
if (!this.getFlag('noprompt')) {
const paths = this.sourceComponents.flatMap((component) => [component.xml, ...component.walkContent()]);
const promptMessage = messages.getMessage('prompt', [[...new Set(paths)].join('\n')]);
const remote: string[] = [];
const local: string[] = [];
const message: string[] = [];

this.components.flatMap((component) => {
if (component instanceof SourceComponent) {
local.push(component.xml, ...component.walkContent());
} else {
// remote only metadata
remote.push(`${component.type.name}:${component.fullName}`);
}
});

if (remote.length) {
message.push(messages.getMessage('remotePrompt', [[...new Set(remote)].join('\n')]));
}

if (local.length) {
if (message.length) {
// add a whitespace between remote and local
message.push('\n');
}
message.push('\n', messages.getMessage('localPrompt', [[...new Set(local)].join('\n')]));
}

return confirm(promptMessage);
message.push(messages.getMessage('areYouSure'));
return confirm(message.join(''));
}
return true;
}
Expand Down
39 changes: 31 additions & 8 deletions src/formatters/deleteResultFormatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@
* Licensed under the BSD 3-Clause license.
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
*/
import { DeployResult } from '@salesforce/source-deploy-retrieve';
import { DeployMessage, DeployResult, FileResponse } from '@salesforce/source-deploy-retrieve';
import { UX } from '@salesforce/command';
import { Logger } from '@salesforce/core';
import * as chalk from 'chalk';
import { DeployCommandResult, DeployResultFormatter } from './deployResultFormatter';
import { ResultFormatterOptions } from './resultFormatter';
import { ResultFormatterOptions, toArray } from './resultFormatter';

export class DeleteResultFormatter extends DeployResultFormatter {
public constructor(logger: Logger, ux: UX, options: ResultFormatterOptions, result?: DeployResult) {
Expand Down Expand Up @@ -37,13 +37,36 @@ export class DeleteResultFormatter extends DeployResultFormatter {
}

protected displaySuccesses(): void {
if (this.isSuccess() && this.fileResponses?.length) {
const successes = this.fileResponses.filter((f) => f.state !== 'Failed');
if (!successes.length) {
return;
if (this.isSuccess()) {
const successes: Array<FileResponse | DeployMessage> = [];
const fileResponseSuccesses: Map<string, FileResponse> = new Map<string, FileResponse>();

if (this.fileResponses?.length) {
const fileResponses: FileResponse[] = [];
this.fileResponses.map((f: FileResponse) => {
fileResponses.push(f);
fileResponseSuccesses.set(`${f.type}#${f.fullName}`, f);
});
this.sortFileResponses(fileResponses);
this.asRelativePaths(fileResponses);
successes.push(...fileResponses);
}

const deployMessages = toArray(this.result?.response?.details?.componentSuccesses).filter(
(item) => !item.fileName.includes('package.xml')
);
if (deployMessages.length >= successes.length) {
// if there's additional successes in the API response, find the success and add it to the output
deployMessages.map((deployMessage) => {
if (!fileResponseSuccesses.has(`${deployMessage.componentType}#${deployMessage.fullName}`)) {
successes.push(
Object.assign(deployMessage, {
type: deployMessage.componentType,
})
);
}
});
}
this.sortFileResponses(successes);
this.asRelativePaths(successes);

this.ux.log('');
this.ux.styledHeader(chalk.blue('Deleted Source'));
Expand Down
13 changes: 5 additions & 8 deletions test/commands/source/delete.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import * as fs from 'fs';
import { join } from 'path';
import * as sinon from 'sinon';
import { expect } from 'chai';
import { ComponentSet } from '@salesforce/source-deploy-retrieve';
import { ComponentSet, SourceComponent } from '@salesforce/source-deploy-retrieve';
import { Lifecycle, Org, SfdxProject } from '@salesforce/core';
import { fromStub, stubInterface, stubMethod } from '@salesforce/ts-sinon';
import { IConfig } from '@oclif/config';
Expand Down Expand Up @@ -80,12 +80,8 @@ describe('force:source:delete', () => {
beforeEach(() => {
resolveProjectConfigStub = sandbox.stub();
buildComponentSetStub = stubMethod(sandbox, ComponentSetBuilder, 'build').resolves({
getSourceComponents: () => {
return {
toArray: () => {
return [exampleSourceComponent];
},
};
toArray: () => {
return [new SourceComponent(exampleSourceComponent)];
},
});
lifecycleEmitStub = sandbox.stub(Lifecycle.prototype, 'emit');
Expand Down Expand Up @@ -123,7 +119,8 @@ describe('force:source:delete', () => {
await runDeleteCmd(['--sourcepath', sourcepath[0], '--json', '-r']);
ensureCreateComponentSetArgs({ sourcepath });
ensureHookArgs();
expect(fsUnlink.callCount).to.equal(1);
// deleting the component and its xml
expect(fsUnlink.callCount).to.equal(2);
});

it('should pass along metadata', async () => {
Expand Down
34 changes: 32 additions & 2 deletions test/nuts/delete.nut.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import * as os from 'os';
import { expect } from 'chai';
import { execCmd } from '@salesforce/cli-plugins-testkit';
import { SourceTestkit } from '@salesforce/source-testkit';
import { exec } from 'shelljs';

describe('source:delete NUTs', () => {
const executable = path.join(process.cwd(), 'bin', 'run');
Expand Down Expand Up @@ -66,9 +67,9 @@ describe('source:delete NUTs', () => {
expect(fs.existsSync(pathToClass)).to.be.false;
});

it('should source:delete all Prompts using the metadata param', () => {
it('should source:delete all Prompts using the sourcepath param', () => {
const response = execCmd<{ deletedSource: [{ filePath: string }] }>(
'force:source:delete --json --noprompt --metadata Prompt',
`force:source:delete --json --noprompt --sourcepath ${path.join('force-app', 'main', 'default', 'prompts')}`,
{
ensureExitCode: 0,
}
Expand All @@ -91,6 +92,35 @@ describe('source:delete NUTs', () => {
expect(fs.existsSync(pathToClass)).to.be.false;
});

it('should source:delete a remote-only ApexClass from the org', async () => {
const { apexName, pathToClass } = createApexClass();
const query = () => {
return JSON.parse(
exec(
`sfdx force:data:soql:query -q "SELECT IsNameObsolete FROM SourceMember WHERE MemberType='ApexClass' AND MemberName='${apexName}' LIMIT 1" -t --json`,
{ silent: true }
)
) as { result: { records: Array<{ IsNameObsolete: boolean }> } };
};

let soql = query();
// the ApexClass is present in the org
expect(soql.result.records[0].IsNameObsolete).to.be.false;
await testkit.deleteGlobs(['force-app/main/default/classes/myApexClass.*']);
const response = execCmd<{ deletedSource: [{ filePath: string }] }>(
`force:source:delete --json --noprompt --metadata ApexClass:${apexName}`,
{
ensureExitCode: 0,
}
).jsonOutput.result;
// remote only delete won't have an associated filepath
expect(response.deletedSource).to.have.length(0);
expect(fs.existsSync(pathToClass)).to.be.false;
soql = query();
// the apex class has been deleted in the org
expect(soql.result.records[0].IsNameObsolete).to.be.true;
});

it('should NOT delete local files with --checkonly', () => {
const { apexName, pathToClass } = createApexClass();
const response = execCmd<{ deletedSource: [{ filePath: string }]; deletes: [{ checkOnly: boolean }] }>(
Expand Down

0 comments on commit fed3ff4

Please sign in to comment.