diff --git a/command-snapshot.json b/command-snapshot.json index 0a0e589a1..275c91f50 100644 --- a/command-snapshot.json +++ b/command-snapshot.json @@ -4,6 +4,23 @@ "plugin": "@salesforce/plugin-source", "flags": ["json", "loglevel", "manifest", "metadata", "outputdir", "packagename", "rootdir", "sourcepath"] }, + { + "command": "force:source:delete", + "plugin": "@salesforce/plugin-source", + "flags": [ + "apiversion", + "checkonly", + "json", + "loglevel", + "metadata", + "noprompt", + "sourcepath", + "targetusername", + "testlevel", + "verbose", + "wait" + ] + }, { "command": "force:source:deploy", "plugin": "@salesforce/plugin-source", diff --git a/messages/delete.json b/messages/delete.json new file mode 100644 index 000000000..272727ec3 --- /dev/null +++ b/messages/delete.json @@ -0,0 +1,18 @@ +{ + "description": "delete source from your project and from a non-source-tracked org \n Use this command to delete components from orgs that don’t have source tracking.\nTo remove deleted items from scratch orgs, which have change tracking, use \"sfdx force:source:push\".", + "examples": ["$ sfdx force:source:delete -m ", "$ sfdx force:source:delete -p path/to/source"], + "flags": { + "sourcepath": "comma-separated list of source file paths to delete", + "metadata": "comma-separated list of names of metadata components to delete", + "noprompt": "do not prompt for delete confirmation", + "wait": "wait time for command to finish in minutes", + "checkonly": "validate delete command but do not delete from the org or delete files locally", + "testLevel": "deployment testing level", + "runTests": "tests to run if --testlevel RunSpecifiedTests", + "verbose": "verbose output of delete result", + + "checkonlyLong": "Validates the deleted metadata and runs all Apex tests, but prevents the deletion from being saved to the org. \nIf you change a field type from Master-Detail to Lookup or vice versa, that change isn’t supported when using the --checkonly parameter to test a deletion (validation). This kind of change isn’t supported for test deletions to avoid the risk of data loss or corruption. If a change that isn’t supported for test deletions is included in a deletion package, the test deletion fails and issues an error.\nIf your deletion package changes a field type from Master-Detail to Lookup or vice versa, you can still validate the changes prior to deploying to Production by performing a full deletion to another test Sandbox. A full deletion includes a validation of the changes as part of the deletion process.\nNote: A Metadata API deletion that includes Master-Detail relationships deletes all detail records in the Recycle Bin in the following cases.\n1. For a deletion with a new Master-Detail field, soft delete (send to the Recycle Bin) all detail records before proceeding to delete the Master-Detail field, or the deletion fails. During the deletion, detail records are permanently deleted from the Recycle Bin and cannot be recovered.\n2. For a deletion that converts a Lookup field relationship to a Master-Detail relationship, detail records must reference a master record or be soft-deleted (sent to the Recycle Bin) for the deletion to succeed. However, a successful deletion permanently deletes any detail records in the Recycle Bin.", + "sourcepathLong": "A comma-separated list of paths to the local metadata to delete. The supplied paths can be a single file (in which case the operation is applied to only one file) or a folder (in which case the operation is applied to all metadata types in the directory and its sub-directories).\nIf you specify this parameter, don’t specify --manifest or --metadata." + }, + "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)?" +} diff --git a/package.json b/package.json index 9d855709c..fc0536c29 100644 --- a/package.json +++ b/package.json @@ -133,6 +133,7 @@ "test:nuts:deploy": "PLUGIN_SOURCE_SEED_FILTER=\"deploy\" ts-node ./test/nuts/generateNuts.ts && nyc mocha \"**/*.nut.ts\" --slow 3000 --timeout 600000 --parallel --retries 0", "test:nuts:retrieve": "PLUGIN_SOURCE_SEED_FILTER=\"retrieve\" ts-node ./test/nuts/generateNuts.ts && nyc mocha \"**/*.nut.ts\" --slow 3000 --timeout 600000 --parallel --retries 0", "test:nuts:manifest:create": "nyc mocha \"test/nuts/create.nut.ts\" --slow 3000 --timeout 600000 --parallel --retries 0", + "test:nuts:delete": "nyc mocha \"test/nuts/delete.nut.ts\" --slow 3000 --timeout 600000 --parallel --retries 0", "version": "oclif-dev readme" }, "husky": { diff --git a/src/commands/force/source/delete.ts b/src/commands/force/source/delete.ts new file mode 100644 index 000000000..e48ccfa47 --- /dev/null +++ b/src/commands/force/source/delete.ts @@ -0,0 +1,204 @@ +/* + * Copyright (c) 2020, salesforce.com, inc. + * All rights reserved. + * 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 * as os from 'os'; +import { confirm } from 'cli-ux/lib/prompt'; +import { flags, FlagsConfig } from '@salesforce/command'; +import { fs, Messages } from '@salesforce/core'; +import { ComponentSet, RequestStatus, SourceComponent } from '@salesforce/source-deploy-retrieve'; +import { Duration, once, env } from '@salesforce/kit'; +import { getString } from '@salesforce/ts-types'; +import { DeployCommand } from '../../../deployCommand'; +import { ComponentSetBuilder } from '../../../componentSetBuilder'; +import { DeployCommandResult } from '../../../formatters/deployResultFormatter'; +import { DeleteResultFormatter } from '../../../formatters/deleteResultFormatter'; +import { ProgressFormatter } from '../../../formatters/progressFormatter'; +import { DeployProgressBarFormatter } from '../../../formatters/deployProgressBarFormatter'; +import { DeployProgressStatusFormatter } from '../../../formatters/deployProgressStatusFormatter'; + +Messages.importMessagesDirectory(__dirname); +const messages = Messages.loadMessages('@salesforce/plugin-source', 'delete'); + +type TestLevel = 'NoTestRun' | 'RunLocalTests' | 'RunAllTestsInOrg'; + +export class Delete extends DeployCommand { + public static readonly description = messages.getMessage('description'); + public static readonly examples = messages.getMessage('examples').split(os.EOL); + public static readonly requiresProject = true; + public static readonly requiresUsername = true; + public static readonly flagsConfig: FlagsConfig = { + checkonly: flags.boolean({ + char: 'c', + description: messages.getMessage('flags.checkonly'), + longDescription: messages.getMessage('flags.checkonlyLong'), + }), + wait: flags.minutes({ + char: 'w', + default: Duration.minutes(Delete.DEFAULT_SRC_WAIT_MINUTES), + min: Duration.minutes(1), + description: messages.getMessage('flags.wait'), + }), + testlevel: flags.enum({ + char: 'l', + description: messages.getMessage('flags.testLevel'), + options: ['NoTestRun', 'RunLocalTests', 'RunAllTestsInOrg'], + default: 'NoTestRun', + }), + noprompt: flags.boolean({ + char: 'r', + description: messages.getMessage('flags.noprompt'), + }), + metadata: flags.array({ + char: 'm', + description: messages.getMessage('flags.metadata'), + exclusive: ['manifest', 'sourcepath'], + }), + sourcepath: flags.array({ + char: 'p', + description: messages.getMessage('flags.sourcepath'), + longDescription: messages.getMessage('flags.sourcepathLong'), + exclusive: ['manifest', 'metadata'], + }), + verbose: flags.builtin({ + description: messages.getMessage('flags.verbose'), + }), + }; + protected xorFlags = ['metadata', 'sourcepath']; + protected readonly lifecycleEventNames = ['predeploy', 'postdeploy']; + private sourceComponents: SourceComponent[]; + private isRest = false; + private deleteResultFormatter: DeleteResultFormatter; + private aborted = false; + + private updateDeployId = once((id) => { + this.displayDeployId(id); + this.setStash(id); + }); + + public async run(): Promise { + await this.delete(); + this.resolveSuccess(); + const result = this.formatResult(); + // The DeleteResultFormatter will use SDR and scan the directory, if the files have been deleted, it will throw an error + // so we'll delete the files locally now + this.deleteFilesLocally(); + return result; + } + + protected async delete(): Promise { + this.deleteResultFormatter = new DeleteResultFormatter(this.logger, this.ux, {}); + // verify that the user defined one of: metadata, sourcepath + this.validateFlags(); + + this.componentSet = await ComponentSetBuilder.build({ + apiversion: this.getFlag('apiversion'), + sourceapiversion: await this.getSourceApiVersion(), + sourcepath: this.getFlag('sourcepath'), + metadata: this.flags.metadata && { + metadataEntries: this.getFlag('metadata'), + directoryPaths: this.getPackageDirs(), + }, + }); + + this.sourceComponents = this.componentSet.getSourceComponents().toArray(); + + if (!this.sourceComponents.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.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()); + this.isRest = await this.isRestDeploy(); + this.ux.log(`*** Deleting with ${this.isRest ? 'REST' : 'SOAP'} API ***`); + + const deploy = await this.componentSet.deploy({ + usernameOrConnection: this.org.getUsername(), + apiOptions: { + rest: this.isRest, + checkOnly: this.getFlag('checkonly', false), + testLevel: this.getFlag('testlevel'), + }, + }); + this.updateDeployId(deploy.id); + + if (!this.isJsonOutput()) { + const progressFormatter: ProgressFormatter = env.getBoolean('SFDX_USE_PROGRESS_BAR', true) + ? new DeployProgressBarFormatter(this.logger, this.ux) + : new DeployProgressStatusFormatter(this.logger, this.ux); + progressFormatter.progress(deploy); + } + + this.deployResult = await deploy.pollStatus(500, this.getFlag('wait').seconds); + await this.lifecycle.emit('postdeploy', this.deployResult); + } + + /** + * Checks the response status to determine whether the delete was successful. + */ + protected resolveSuccess(): void { + const status = getString(this.deployResult, 'response.status'); + if (status !== RequestStatus.Succeeded && !this.aborted) { + this.setExitCode(1); + } + } + + protected formatResult(): DeployCommandResult { + const formatterOptions = { + verbose: this.getFlag('verbose', false), + }; + + this.deleteResultFormatter = new DeleteResultFormatter(this.logger, this.ux, formatterOptions, this.deployResult); + + // Only display results to console when JSON flag is unset. + if (!this.isJsonOutput()) { + this.deleteResultFormatter.display(); + } + + return this.deleteResultFormatter.getJson(); + } + + private deleteFilesLocally(): void { + if (!this.getFlag('checkonly') && getString(this.deployResult, 'response.status') === 'Succeeded') { + this.sourceComponents.map((component) => { + // delete the content and/or the xml of the components + if (component.content) { + const stats = fs.lstatSync(component.content); + if (stats.isDirectory()) { + fs.rmdirSync(component.content, { recursive: true }); + } else { + fs.unlinkSync(component.content); + } + } + // the xml could've been deleted as part of a bundle type above + if (component.xml && fs.existsSync(component.xml)) { + fs.unlinkSync(component.xml); + } + }); + } + } + + private async handlePrompt(): Promise { + if (!this.getFlag('noprompt')) { + const paths = this.sourceComponents.flatMap((component) => [component.xml, ...component.walkContent()]); + const promptMessage = messages.getMessage('prompt', [[...new Set(paths)].join('\n')]); + + return confirm(promptMessage); + } + return true; + } +} diff --git a/src/formatters/deleteResultFormatter.ts b/src/formatters/deleteResultFormatter.ts new file mode 100644 index 000000000..6076bd2f8 --- /dev/null +++ b/src/formatters/deleteResultFormatter.ts @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2020, salesforce.com, inc. + * All rights reserved. + * 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 { UX } from '@salesforce/command'; +import { Logger } from '@salesforce/core'; +import * as chalk from 'chalk'; +import { DeployCommandResult, DeployResultFormatter } from './deployResultFormatter'; +import { ResultFormatterOptions } from './resultFormatter'; + +export class DeleteResultFormatter extends DeployResultFormatter { + public constructor(logger: Logger, ux: UX, options: ResultFormatterOptions, result?: DeployResult) { + super(logger, ux, options, result); + } + + /** + * Get the JSON output from the DeployResult. + * + * @returns a JSON formatted result matching the provided type. + */ + public getJson(): DeployCommandResult { + const json = this.getResponse() as DeployCommandResult; + json.deletedSource = this.fileResponses; // to match toolbelt json output + json.outboundFiles = []; // to match toolbelt version + json.deletes = [Object.assign({}, this.getResponse())]; // to match toolbelt version + + return json; + } + + public displayNoResultsFound(): void { + // matches toolbelt + this.ux.styledHeader(chalk.blue('Deleted Source')); + this.ux.log('No results found'); + } + + protected displaySuccesses(): void { + if (this.isSuccess() && this.fileResponses?.length) { + const successes = this.fileResponses.filter((f) => f.state !== 'Failed'); + if (!successes.length) { + return; + } + this.sortFileResponses(successes); + this.asRelativePaths(successes); + + this.ux.log(''); + this.ux.styledHeader(chalk.blue('Deleted Source')); + this.ux.table(successes, { + columns: [ + { key: 'fullName', label: 'FULL NAME' }, + { key: 'type', label: 'TYPE' }, + { key: 'filePath', label: 'PROJECT PATH' }, + ], + }); + } + } +} diff --git a/src/formatters/deployResultFormatter.ts b/src/formatters/deployResultFormatter.ts index 0053ce367..83d70be70 100644 --- a/src/formatters/deployResultFormatter.ts +++ b/src/formatters/deployResultFormatter.ts @@ -22,9 +22,11 @@ Messages.importMessagesDirectory(__dirname); const messages = Messages.loadMessages('@salesforce/plugin-source', 'deploy'); export interface DeployCommandResult extends MetadataApiDeployStatus { + deletedSource?: FileResponse[]; deployedSource: FileResponse[]; outboundFiles: string[]; deploys: MetadataApiDeployStatus[]; + deletes?: MetadataApiDeployStatus[]; } export class DeployResultFormatter extends ResultFormatter { diff --git a/test/commands/source/delete.test.ts b/test/commands/source/delete.test.ts new file mode 100644 index 000000000..584665da2 --- /dev/null +++ b/test/commands/source/delete.test.ts @@ -0,0 +1,172 @@ +/* + * Copyright (c) 2020, salesforce.com, inc. + * All rights reserved. + * 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 { join } from 'path'; +import * as sinon from 'sinon'; +import { expect } from 'chai'; +import { ComponentSet } from '@salesforce/source-deploy-retrieve'; +import { fs, Lifecycle, Org, SfdxProject } from '@salesforce/core'; +import { fromStub, stubInterface, stubMethod } from '@salesforce/ts-sinon'; +import { IConfig } from '@oclif/config'; +import { UX } from '@salesforce/command'; +import { ComponentSetBuilder, ComponentSetOptions } from '../../../src/componentSetBuilder'; +import { Delete } from '../../../src/commands/force/source/delete'; +import { exampleDeleteResponse, exampleSourceComponent } from './testConsts'; + +describe('force:source:delete', () => { + const sandbox = sinon.createSandbox(); + const username = 'delete-test@org.com'; + const defaultPackagePath = 'defaultPackagePath'; + + const oclifConfigStub = fromStub(stubInterface(sandbox)); + + // Stubs + let buildComponentSetStub: sinon.SinonStub; + let lifecycleEmitStub: sinon.SinonStub; + let resolveProjectConfigStub: sinon.SinonStub; + let fsUnlink: sinon.SinonStub; + + class TestDelete extends Delete { + public async runIt() { + await this.init(); + return this.run(); + } + public setOrg(org: Org) { + this.org = org; + } + public setProject(project: SfdxProject) { + this.project = project; + } + } + + const runDeleteCmd = async (params: string[]) => { + const cmd = new TestDelete(params, oclifConfigStub); + stubMethod(sandbox, SfdxProject, 'resolveProjectPath').resolves(join('path', 'to', 'package')); + stubMethod(sandbox, cmd, 'assignProject').callsFake(() => { + const sfdxProjectStub = fromStub( + stubInterface(sandbox, { + getDefaultPackage: () => ({ fullPath: defaultPackagePath }), + getUniquePackageDirectories: () => [{ fullPath: defaultPackagePath }], + resolveProjectConfig: resolveProjectConfigStub, + }) + ); + cmd.setProject(sfdxProjectStub); + }); + stubMethod(sandbox, cmd, 'assignOrg').callsFake(() => { + const orgStub = fromStub( + stubInterface(sandbox, { + getUsername: () => username, + }) + ); + cmd.setOrg(orgStub); + }); + stubMethod(sandbox, UX.prototype, 'log'); + stubMethod(sandbox, ComponentSet.prototype, 'deploy').resolves({ + id: '123', + pollStatus: () => { + return exampleDeleteResponse; + }, + }); + fsUnlink = stubMethod(sandbox, fs, 'unlinkSync').returns(true); + + return cmd.runIt(); + }; + + beforeEach(() => { + resolveProjectConfigStub = sandbox.stub(); + buildComponentSetStub = stubMethod(sandbox, ComponentSetBuilder, 'build').resolves({ + getSourceComponents: () => { + return { + toArray: () => { + return [exampleSourceComponent]; + }, + }; + }, + }); + lifecycleEmitStub = sandbox.stub(Lifecycle.prototype, 'emit'); + }); + + afterEach(() => { + sandbox.restore(); + }); + + // Ensure SourceCommand.createComponentSet() args + const ensureCreateComponentSetArgs = (overrides?: Partial) => { + const defaultArgs = { + sourcepath: undefined, + metadata: undefined, + apiversion: undefined, + sourceapiversion: undefined, + }; + const expectedArgs = { ...defaultArgs, ...overrides }; + + expect(buildComponentSetStub.calledOnce).to.equal(true); + expect(buildComponentSetStub.firstCall.args[0]).to.deep.equal(expectedArgs); + }; + + // Ensure Lifecycle hooks are called properly + const ensureHookArgs = () => { + const failureMsg = 'Lifecycle.emit() should be called for predeploy and postdeploy'; + expect(lifecycleEmitStub.calledTwice, failureMsg).to.equal(true); + expect(lifecycleEmitStub.firstCall.args[0]).to.equal('predeploy'); + expect(lifecycleEmitStub.secondCall.args[0]).to.equal('postdeploy'); + }; + + it('should pass along sourcepath', async () => { + const sourcepath = ['somepath']; + stubMethod(sandbox, fs, 'lstatSync').returns({ isDirectory: () => false }); + await runDeleteCmd(['--sourcepath', sourcepath[0], '--json', '-r']); + ensureCreateComponentSetArgs({ sourcepath }); + ensureHookArgs(); + expect(fsUnlink.callCount).to.equal(1); + }); + + it('should pass along metadata', async () => { + const metadata = ['ApexClass:MyClass']; + stubMethod(sandbox, fs, 'lstatSync').returns({ isDirectory: () => false }); + await runDeleteCmd(['--metadata', metadata[0], '--json', '-r']); + ensureCreateComponentSetArgs({ + metadata: { + metadataEntries: metadata, + directoryPaths: [defaultPackagePath], + }, + }); + ensureHookArgs(); + }); + + it('should pass along apiversion', async () => { + const metadata = ['ApexClass:MyClass']; + stubMethod(sandbox, fs, 'lstatSync').returns({ isDirectory: () => false }); + + await runDeleteCmd(['--metadata', metadata[0], '--json', '-r', '--apiversion', '52.0']); + ensureCreateComponentSetArgs({ + metadata: { + metadataEntries: metadata, + directoryPaths: [defaultPackagePath], + }, + apiversion: '52.0', + }); + ensureHookArgs(); + }); + + it('should pass along sourceapiversion', async () => { + const sourceApiVersion = '50.0'; + const metadata = ['ApexClass:MyClass']; + + resolveProjectConfigStub.resolves({ sourceApiVersion }); + stubMethod(sandbox, fs, 'lstatSync').returns({ isDirectory: () => false }); + + await runDeleteCmd(['--metadata', metadata[0], '--json', '-r']); + ensureCreateComponentSetArgs({ + sourceapiversion: sourceApiVersion, + metadata: { + metadataEntries: metadata, + directoryPaths: [defaultPackagePath], + }, + }); + ensureHookArgs(); + }); +}); diff --git a/test/commands/source/testConsts.ts b/test/commands/source/testConsts.ts index 621f1c3b3..a19441eb2 100644 --- a/test/commands/source/testConsts.ts +++ b/test/commands/source/testConsts.ts @@ -66,6 +66,43 @@ export const exampleDeployResponse = { id: '00N1h00000ApoBOEAZ', success: 'true', }, + ], + runTestResult: { + numFailures: '0', + numTestsRun: '0', + totalTime: '0.0', + }, + }, + done: true, + id: '0Af1h00000fCQgsCAG', + ignoreWarnings: false, + lastModifiedDate: '2021-04-09T20:23:05.000Z', + numberComponentErrors: 0, + numberComponentsDeployed: 32, + numberComponentsTotal: 86, + numberTestErrors: 0, + numberTestsCompleted: 5, + numberTestsTotal: 10, + rollbackOnError: true, + runTestsEnabled: false, + startDate: '2021-04-09T20:22:58.000Z', + status: 'Canceled', + success: false, + }, + status: 0, +}; + +export const exampleDeleteResponse = { + response: { + canceledBy: '0051h000006BHOq', + canceledByName: 'User User', + checkOnly: false, + completedDate: '2021-04-09T20:23:05.000Z', + createdBy: '0051h000006BHOq', + createdByName: 'User User', + createdDate: '2021-04-09T20:22:58.000Z', + details: { + componentSuccesses: [ { changed: 'false', componentType: 'CustomField', @@ -73,305 +110,8 @@ export const exampleDeployResponse = { createdDate: '2021-04-09T20:23:02.000Z', deleted: 'false', fileName: 'sdx_sourceDeploy_pkg_1617999776176/objects/Property__c.object', - fullName: 'Property__c.Record_Link__c', - id: '00N1h00000ApoBUEAZ', - success: 'true', - }, - { - changed: 'false', - componentType: 'CustomField', - created: 'false', - createdDate: '2021-04-09T20:23:02.000Z', - deleted: 'false', - fileName: 'sdx_sourceDeploy_pkg_1617999776176/objects/Property__c.object', - fullName: 'Property__c.Location__c', - id: '00N1h00000ApoBKEAZ', - success: 'true', - }, - { - changed: 'false', - componentType: 'CustomField', - created: 'false', - createdDate: '2021-04-09T20:23:02.000Z', - deleted: 'false', - fileName: 'sdx_sourceDeploy_pkg_1617999776176/objects/Property__c.object', - fullName: 'Property__c.Picture_IMG__c', - id: '00N1h00000ApoBLEAZ', - success: 'true', - }, - { - changed: 'false', - componentType: 'CustomField', - created: 'false', - createdDate: '2021-04-09T20:23:02.000Z', - deleted: 'false', - fileName: 'sdx_sourceDeploy_pkg_1617999776176/objects/Property__c.object', - fullName: 'Property__c.State__c', - id: '00N1h00000ApoBVEAZ', - success: 'true', - }, - { - changed: 'false', - componentType: 'CustomField', - created: 'false', - createdDate: '2021-04-09T20:23:02.000Z', - deleted: 'false', - fileName: 'sdx_sourceDeploy_pkg_1617999776176/objects/Property__c.object', - fullName: 'Property__c.Status__c', - id: '00N1h00000ApoBWEAZ', - success: 'true', - }, - { - changed: 'false', - componentType: 'CustomField', - created: 'false', - createdDate: '2021-04-09T20:23:02.000Z', - deleted: 'false', - fileName: 'sdx_sourceDeploy_pkg_1617999776176/objects/Property__c.object', - fullName: 'Property__c.Tags__c', - id: '00N1h00000ApoBXEAZ', - success: 'true', - }, - { - changed: 'false', - componentType: 'CustomField', - created: 'false', - createdDate: '2021-04-09T20:23:02.000Z', - deleted: 'false', - fileName: 'sdx_sourceDeploy_pkg_1617999776176/objects/Property__c.object', - fullName: 'Property__c.Thumbnail_IMG__c', - id: '00N1h00000ApoBYEAZ', - success: 'true', - }, - { - changed: 'false', - componentType: 'CustomField', - created: 'false', - createdDate: '2021-04-09T20:23:02.000Z', - deleted: 'false', - fileName: 'sdx_sourceDeploy_pkg_1617999776176/objects/Property__c.object', - fullName: 'Property__c.Thumbnail__c', - id: '00N1h00000ApoBjEAJ', - success: 'true', - }, - { - changed: 'false', - componentType: 'CustomField', - created: 'false', - createdDate: '2021-04-09T20:23:02.000Z', - deleted: 'false', - fileName: 'sdx_sourceDeploy_pkg_1617999776176/objects/Property__c.object', - fullName: 'Property__c.Zip__c', - id: '00N1h00000ApoBkEAJ', - success: 'true', - }, - { - changed: 'false', - componentType: 'CustomField', - created: 'false', - createdDate: '2021-04-09T20:23:02.000Z', - deleted: 'false', - fileName: 'sdx_sourceDeploy_pkg_1617999776176/objects/Property__c.object', - fullName: 'Property__c.Broker__c', - id: '00N1h00000ApoB1EAJ', - success: 'true', - }, - { - changed: 'false', - componentType: 'CustomField', - created: 'false', - createdDate: '2021-04-09T20:23:02.000Z', - deleted: 'false', - fileName: 'sdx_sourceDeploy_pkg_1617999776176/objects/Property__c.object', - fullName: 'Property__c.Beds__c', - id: '00N1h00000ApoB0EAJ', - success: 'true', - }, - { - changed: 'false', - componentType: 'CustomField', - created: 'false', - createdDate: '2021-04-09T20:23:02.000Z', - deleted: 'false', - fileName: 'sdx_sourceDeploy_pkg_1617999776176/objects/Property__c.object', - fullName: 'Property__c.City__c', - id: '00N1h00000ApoB2EAJ', - success: 'true', - }, - { - changed: 'false', - componentType: 'CustomField', - created: 'false', - createdDate: '2021-04-09T20:23:02.000Z', - deleted: 'false', - fileName: 'sdx_sourceDeploy_pkg_1617999776176/objects/Property__c.object', - fullName: 'Property__c.Date_Agreement__c', - id: '00N1h00000ApoB3EAJ', - success: 'true', - }, - { - changed: 'false', - componentType: 'CustomField', - created: 'false', - createdDate: '2021-04-09T20:23:02.000Z', - deleted: 'false', - fileName: 'sdx_sourceDeploy_pkg_1617999776176/objects/Property__c.object', - fullName: 'Property__c.Date_Closed__c', - id: '00N1h00000ApoB4EAJ', - success: 'true', - }, - { - changed: 'false', - componentType: 'CustomField', - created: 'false', - createdDate: '2021-04-09T20:23:02.000Z', - deleted: 'false', - fileName: 'sdx_sourceDeploy_pkg_1617999776176/objects/Property__c.object', - fullName: 'Property__c.Date_Contracted__c', - id: '00N1h00000ApoBAEAZ', - success: 'true', - }, - { - changed: 'false', - componentType: 'CustomField', - created: 'false', - createdDate: '2021-04-09T20:23:02.000Z', - deleted: 'false', - fileName: 'sdx_sourceDeploy_pkg_1617999776176/objects/Property__c.object', - fullName: 'Property__c.Date_Listed__c', - id: '00N1h00000ApoBBEAZ', - success: 'true', - }, - { - changed: 'false', - componentType: 'CustomField', - created: 'false', - createdDate: '2021-04-09T20:23:02.000Z', - deleted: 'false', - fileName: 'sdx_sourceDeploy_pkg_1617999776176/objects/Property__c.object', - fullName: 'Property__c.Date_Pre_Market__c', - id: '00N1h00000ApoBCEAZ', - success: 'true', - }, - { - changed: 'false', - componentType: 'CustomField', - created: 'false', - createdDate: '2021-04-09T20:23:02.000Z', - deleted: 'false', - fileName: 'sdx_sourceDeploy_pkg_1617999776176/objects/Property__c.object', - fullName: 'Property__c.Days_On_Market__c', - id: '00N1h00000ApoBDEAZ', - success: 'true', - }, - { - changed: 'false', - componentType: 'CustomField', - created: 'false', - createdDate: '2021-04-09T20:23:02.000Z', - deleted: 'false', - fileName: 'sdx_sourceDeploy_pkg_1617999776176/objects/Property__c.object', - fullName: 'Property__c.Description__c', - id: '00N1h00000ApoBEEAZ', - success: 'true', - }, - { - changed: 'false', - componentType: 'CustomField', - created: 'false', - createdDate: '2021-04-09T20:23:02.000Z', - deleted: 'false', - fileName: 'sdx_sourceDeploy_pkg_1617999776176/objects/Broker__c.object', - fullName: 'Broker__c.Mobile_Phone__c', - id: '00N1h00000ApoAdEAJ', - success: 'true', - }, - { - changed: 'false', - componentType: 'CustomField', - created: 'false', - createdDate: '2021-04-09T20:23:02.000Z', - deleted: 'false', - fileName: 'sdx_sourceDeploy_pkg_1617999776176/objects/Broker__c.object', - fullName: 'Broker__c.Phone__c', - id: '00N1h00000ApoAeEAJ', - success: 'true', - }, - { - changed: 'false', - componentType: 'CustomField', - created: 'false', - createdDate: '2021-04-09T20:23:02.000Z', - deleted: 'false', - fileName: 'sdx_sourceDeploy_pkg_1617999776176/objects/Broker__c.object', - fullName: 'Broker__c.Broker_Id__c', - id: '00N1h00000ApoAbEAJ', - success: 'true', - }, - { - changed: 'false', - componentType: 'CustomField', - created: 'false', - createdDate: '2021-04-09T20:23:02.000Z', - deleted: 'false', - fileName: 'sdx_sourceDeploy_pkg_1617999776176/objects/Broker__c.object', - fullName: 'Broker__c.Email__c', - id: '00N1h00000ApoAcEAJ', - success: 'true', - }, - { - changed: 'false', - componentType: 'CustomField', - created: 'false', - createdDate: '2021-04-09T20:23:02.000Z', - deleted: 'false', - fileName: 'sdx_sourceDeploy_pkg_1617999776176/objects/Broker__c.object', - fullName: 'Broker__c.Picture_IMG__c', - id: '00N1h00000ApoAfEAJ', - success: 'true', - }, - { - changed: 'false', - componentType: 'CustomField', - created: 'false', - createdDate: '2021-04-09T20:23:02.000Z', - deleted: 'false', - fileName: 'sdx_sourceDeploy_pkg_1617999776176/objects/Broker__c.object', - fullName: 'Broker__c.Picture__c', - id: '00N1h00000ApoAqEAJ', - success: 'true', - }, - { - changed: 'false', - componentType: 'CustomField', - created: 'false', - createdDate: '2021-04-09T20:23:02.000Z', - deleted: 'false', - fileName: 'sdx_sourceDeploy_pkg_1617999776176/objects/Broker__c.object', - fullName: 'Broker__c.Title__c', - id: '00N1h00000ApoArEAJ', - success: 'true', - }, - { - changed: 'false', - componentType: 'CustomField', - created: 'false', - createdDate: '2021-04-09T20:23:02.000Z', - deleted: 'false', - fileName: 'sdx_sourceDeploy_pkg_1617999776176/objects/Property__c.object', - fullName: 'Property__c.Address__c', - id: '00N1h00000ApoAsEAJ', - success: 'true', - }, - { - changed: 'false', - componentType: 'CustomField', - created: 'false', - createdDate: '2021-04-09T20:23:02.000Z', - deleted: 'false', - fileName: 'sdx_sourceDeploy_pkg_1617999776176/objects/Property__c.object', - fullName: 'Property__c.Assessed_Value__c', - id: '00N1h00000ApoAtEAJ', + fullName: 'Property__c.Picture__c', + id: '00N1h00000ApoBMEAZ', success: 'true', }, { @@ -381,8 +121,8 @@ export const exampleDeployResponse = { createdDate: '2021-04-09T20:23:02.000Z', deleted: 'false', fileName: 'sdx_sourceDeploy_pkg_1617999776176/objects/Property__c.object', - fullName: 'Property__c.Price_Sold__c', - id: '00N1h00000ApoBNEAZ', + fullName: 'Property__c.Baths__c', + id: '00N1h00000ApoAuEAJ', success: 'true', }, ], @@ -405,8 +145,8 @@ export const exampleDeployResponse = { rollbackOnError: true, runTestsEnabled: false, startDate: '2021-04-09T20:22:58.000Z', - status: 'Canceled', - success: false, + status: 'Succeeded', + success: true, }, status: 0, }; diff --git a/test/nuts/delete.nut.ts b/test/nuts/delete.nut.ts new file mode 100644 index 000000000..62d8583cc --- /dev/null +++ b/test/nuts/delete.nut.ts @@ -0,0 +1,121 @@ +/* + * Copyright (c) 2020, salesforce.com, inc. + * All rights reserved. + * 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 * as path from 'path'; +import * as os from 'os'; +import { expect } from 'chai'; +import { execCmd } from '@salesforce/cli-plugins-testkit'; +import { SourceTestkit } from '@salesforce/source-testkit'; +import { fs } from '@salesforce/core'; + +describe('source:delete NUTs', () => { + const executable = path.join(process.cwd(), 'bin', 'run'); + let testkit: SourceTestkit; + + const createApexClass = () => { + // create and deploy an ApexClass that can be deleted without dependency issues + const apexName = 'myApexClass'; + const output = path.join('force-app', 'main', 'default', 'classes'); + const pathToClass = path.join(testkit.projectDir, output, `${apexName}.cls`); + execCmd(`force:apex:class:create --classname ${apexName} --outputdir ${output}`); + execCmd(`force:source:deploy -m ApexClass:${apexName}`); + return { apexName, output, pathToClass }; + }; + + before(async () => { + testkit = await SourceTestkit.create({ + nut: __filename, + executable: os.platform() === 'win32' ? executable.replace(/\\/g, '\\\\') : executable, + repository: 'https://github.com/trailheadapps/dreamhouse-lwc.git', + }); + execCmd('force:source:deploy --sourcepath force-app'); + }); + + after(async () => { + await testkit?.clean(); + }); + + it('should source:delete a static resource folder using the sourcepath param', () => { + const pathToSR = path.join(testkit.projectDir, 'force-app', 'main', 'default', 'staticresources'); + const pathToJson = path.join(pathToSR, 'sample_data_properties.json'); + const pathToXml = path.join(pathToSR, 'sample_data_properties.resource-meta.xml'); + const response = execCmd<{ deletedSource: [{ filePath: string }] }>( + `force:source:delete --json --noprompt --sourcepath ${pathToJson}`, + { + ensureExitCode: 0, + } + ).jsonOutput.result; + expect(response.deletedSource).to.have.length(2); + expect(fs.fileExistsSync(pathToJson)).to.be.false; + expect(fs.fileExistsSync(pathToXml)).to.be.false; + }); + + it('should source:delete an ApexClass using the metadata param', () => { + const { apexName, pathToClass } = createApexClass(); + const response = execCmd<{ deletedSource: [{ filePath: string }] }>( + `force:source:delete --json --noprompt --metadata ApexClass:${apexName}`, + { + ensureExitCode: 0, + } + ).jsonOutput.result; + expect(response.deletedSource).to.have.length(2); + expect(fs.fileExistsSync(pathToClass)).to.be.false; + }); + + it('should source:delete all Prompts using the metadata param', () => { + const response = execCmd<{ deletedSource: [{ filePath: string }] }>( + 'force:source:delete --json --noprompt --metadata Prompt', + { + ensureExitCode: 0, + } + ).jsonOutput.result; + const pathToPrompts = path.join(testkit.projectDir, 'force-app', 'main', 'default', 'prompts'); + expect(response.deletedSource).to.have.length(3); + // should delete directory contents + expect(fs.readdirSync(pathToPrompts).length).to.equal(0); + }); + + it('should source:delete an ApexClass using the sourcepath param', () => { + const { pathToClass } = createApexClass(); + const response = execCmd<{ deletedSource: [{ filePath: string }] }>( + `force:source:delete --json --noprompt --sourcepath ${pathToClass}`, + { + ensureExitCode: 0, + } + ).jsonOutput.result; + expect(response.deletedSource).to.have.length(2); + expect(fs.fileExistsSync(pathToClass)).to.be.false; + }); + + it('should NOT delete local files with --checkonly', () => { + const { apexName, pathToClass } = createApexClass(); + const response = execCmd<{ deletedSource: [{ filePath: string }]; deletes: [{ checkOnly: boolean }] }>( + `force:source:delete --json --checkonly --noprompt --metadata ApexClass:${apexName}`, + { + ensureExitCode: 0, + } + ).jsonOutput.result; + expect(response.deletedSource).to.have.length(2); + expect(response.deletes[0].checkOnly).to.be.true; + expect(fs.fileExistsSync(pathToClass)).to.be.true; + }); + + it('should run tests with a delete', async () => { + const { pathToClass, apexName } = createApexClass(); + const response = execCmd<{ + checkOnly: boolean; + runTestsEnabled: boolean; + }>(`force:source:delete --json --testlevel RunAllTestsInOrg --noprompt --metadata ApexClass:${apexName}`, { + ensureExitCode: 1, + }).jsonOutput.result; + // the delete operation will fail due to test failures without the 'dreamhouse' permission set assigned to the user + expect(response.runTestsEnabled).to.be.true; + expect(response.checkOnly).to.be.false; + // ensure a failed delete attempt won't delete local files + expect(fs.fileExistsSync(pathToClass)).to.be.true; + }); +});