From b00a59a3c06cd81b121c306b6b9af3ee581a61ad Mon Sep 17 00:00:00 2001 From: Steve Hetzel Date: Tue, 9 Nov 2021 15:55:17 -0700 Subject: [PATCH] feat: listmetadata and describemetadata * feat: add the mdapi:listmetadata command * fix: lint fixes and command snapshot * chore: update oclif topics * chore: add unit tests * chore: add NUTs * chore: add NUTs * chore: add a comment to the NUTs * feat: add the mdapi:describemetadata command and tests * fix: use short description --- command-snapshot.json | 10 + dreamhouse-lwc | 1 - messages/md.describe.json | 20 ++ messages/md.list.json | 28 +++ package.json | 4 + src/commands/force/mdapi/describemetadata.ts | 119 ++++++++++++ src/commands/force/mdapi/listmetadata.ts | 116 ++++++++++++ test/commands/mdapi/describemetadata.test.ts | 159 ++++++++++++++++ test/commands/mdapi/listmetadata.test.ts | 185 +++++++++++++++++++ test/commands/source/retrieve.test.ts | 2 + test/nuts/mdapi.nut.ts | 122 ++++++++++++ 11 files changed, 765 insertions(+), 1 deletion(-) delete mode 160000 dreamhouse-lwc create mode 100644 messages/md.describe.json create mode 100644 messages/md.list.json create mode 100644 src/commands/force/mdapi/describemetadata.ts create mode 100644 src/commands/force/mdapi/listmetadata.ts create mode 100644 test/commands/mdapi/describemetadata.test.ts create mode 100644 test/commands/mdapi/listmetadata.test.ts create mode 100644 test/nuts/mdapi.nut.ts diff --git a/command-snapshot.json b/command-snapshot.json index a875344a6..907667612 100644 --- a/command-snapshot.json +++ b/command-snapshot.json @@ -105,5 +105,15 @@ "verbose", "wait" ] + }, + { + "command": "force:mdapi:listmetadata", + "plugin": "@salesforce/plugin-source", + "flags": ["apiversion", "json", "loglevel", "resultfile", "targetusername", "metadatatype", "folder"] + }, + { + "command": "force:mdapi:describemetadata", + "plugin": "@salesforce/plugin-source", + "flags": ["apiversion", "json", "loglevel", "resultfile", "targetusername", "filterknown"] } ] diff --git a/dreamhouse-lwc b/dreamhouse-lwc deleted file mode 160000 index ae7f21bc1..000000000 --- a/dreamhouse-lwc +++ /dev/null @@ -1 +0,0 @@ -Subproject commit ae7f21bc153053f57909a403ee9e66774f92310a diff --git a/messages/md.describe.json b/messages/md.describe.json new file mode 100644 index 000000000..d508d475b --- /dev/null +++ b/messages/md.describe.json @@ -0,0 +1,20 @@ +{ + "description": "display the metadata types enabled for your org", + "examples": [ + "$ sfdx force:mdapi:describemetadata -a 43.0", + "$ sfdx force:mdapi:describemetadata -u me@example.com", + "$ sfdx force:mdapi:describemetadata -f /path/to/outputfilename.txt", + "$ sfdx force:mdapi:describemetadata -u me@example.com -f /path/to/outputfilename.txt" + ], + "flags": { + "apiversion": "API version to use", + "resultfile": "path to the file where results are stored", + "filterknown": "filter metadata known by the CLI" + }, + "flagsLong": { + "apiversion": "The API version to use. The default is the latest API version", + "resultfile": "The path to the file where the results of the command are stored. Directing the output to a file makes it easier to extract relevant information for your package.xml manifest file. The default output destination is the console.", + "filterknown": "Filters all the known metadata from the result such that all that is left are the types not yet fully supported by the CLI." + }, + "invalidResultFile": "Invalid resultfile parameter specified: %s\nMust be a valid file path." +} diff --git a/messages/md.list.json b/messages/md.list.json new file mode 100644 index 000000000..4b637a7b8 --- /dev/null +++ b/messages/md.list.json @@ -0,0 +1,28 @@ +{ + "description": "display properties of metadata components of a specified type", + "examples": [ + "$ sfdx force:mdapi:listmetadata -m CustomObject", + "$ sfdx force:mdapi:listmetadata -m CustomObject -a 43.0", + "$ sfdx force:mdapi:listmetadata -m CustomObject -u me@example.com", + "$ sfdx force:mdapi:listmetadata -m CustomObject -f /path/to/outputfilename.txt", + "$ sfdx force:mdapi:listmetadata -m Dashboard --folder foldername", + "$ sfdx force:mdapi:listmetadata -m Dashboard --folder foldername -a 43.0", + "$ sfdx force:mdapi:listmetadata -m Dashboard --folder foldername -u me@example.com", + "$ sfdx force:mdapi:listmetadata -m Dashboard --folder foldername -f /path/to/outputfilename.txt", + "$ sfdx force:mdapi:listmetadata -m CustomObject -u me@example.com -f /path/to/outputfilename.txt" + ], + "flags": { + "apiversion": "API version to use", + "resultfile": "path to the file where results are stored", + "metadatatype": "metadata type to be retrieved, such as CustomObject; metadata type value is case-sensitive", + "folder": "folder associated with the component; required for components that use folders; folder names are case-sensitive" + }, + "flagsLong": { + "apiversion": "The API version to use. The default is the latest API version", + "resultfile": "The path to the file where the results of the command are stored. The default output destination is the console.", + "metadatatype": "The metadata type to be retrieved, such as CustomObject or Report. The metadata type value is case-sensitive.", + "folder": "The folder associated with the component. This parameter is required for components that use folders, such as Dashboard, Document, EmailTemplate, or Report. The folder name value is case-sensitive." + }, + "invalidResultFile": "Invalid resultfile parameter specified: %s\nMust be a valid file path.", + "noMatchingMetadata": "No metadata found for type: %s in org: %s" +} diff --git a/package.json b/package.json index 5591684d5..3d80a6fe5 100644 --- a/package.json +++ b/package.json @@ -115,6 +115,9 @@ } } } + }, + "mdapi": { + "description": "retrieve and deploy metadata using Metadata API" } } } @@ -160,6 +163,7 @@ "test:nuts:tracking:forceignore": "mocha \"test/nuts/trackingCommands/forceIgnore.nut.ts\" --slow 3000 --timeout 600000 --retries 0", "test:nuts:tracking:remote": "mocha \"test/nuts/trackingCommands/remoteChanges.nut.ts\" --slow 3000 --timeout 600000 --retries 0", "test:nuts:tracking:resetClear": "mocha \"test/nuts/trackingCommands/resetClear.nut.ts\" --slow 3000 --timeout 600000 --retries 0", + "test:nuts:mdapi": "mocha \"test/nuts/mdapi.nut.ts\" --slow 3000 --timeout 600000 --retries 0", "version": "oclif-dev readme" }, "husky": { diff --git a/src/commands/force/mdapi/describemetadata.ts b/src/commands/force/mdapi/describemetadata.ts new file mode 100644 index 000000000..f1c23f11d --- /dev/null +++ b/src/commands/force/mdapi/describemetadata.ts @@ -0,0 +1,119 @@ +/* + * 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 * as path from 'path'; +import * as fs from 'fs'; +import { flags, FlagsConfig } from '@salesforce/command'; +import { Messages, SfdxError } from '@salesforce/core'; +import { DescribeMetadataResult } from 'jsforce'; +import { RegistryAccess } from '@salesforce/source-deploy-retrieve'; +import { SourceCommand } from '../../../sourceCommand'; + +Messages.importMessagesDirectory(__dirname); +const messages = Messages.loadMessages('@salesforce/plugin-source', 'md.describe'); + +interface FsError extends Error { + code: string; +} + +export class DescribeMetadata extends SourceCommand { + public static readonly description = messages.getMessage('description'); + public static readonly examples = messages.getMessage('examples').split(os.EOL); + public static readonly requiresUsername = true; + public static readonly flagsConfig: FlagsConfig = { + apiversion: flags.builtin({ + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore force char override for backward compat + char: 'a', + description: messages.getMessage('flags.apiversion'), + longDescription: messages.getMessage('flagsLong.apiversion'), + }), + resultfile: flags.filepath({ + char: 'f', + description: messages.getMessage('flags.resultfile'), + longDescription: messages.getMessage('flagsLong.resultfile'), + }), + filterknown: flags.boolean({ + char: 'k', + description: messages.getMessage('flags.filterknown'), + longDescription: messages.getMessage('flagsLong.filterknown'), + hidden: true, + }), + }; + + private describeResult: DescribeMetadataResult; + private targetFilePath: string; + + public async run(): Promise { + await this.describe(); + this.resolveSuccess(); + return this.formatResult(); + } + + protected async describe(): Promise { + const apiversion = this.getFlag('apiversion'); + + this.validateResultFile(); + + const connection = this.org.getConnection(); + this.describeResult = await connection.metadata.describe(apiversion); + + if (this.flags.filterknown) { + this.logger.debug('Filtering for only metadata types unregistered in the CLI'); + const registry = new RegistryAccess(); + this.describeResult.metadataObjects = this.describeResult.metadataObjects.filter((md) => { + try { + // An error is thrown when a type can't be found by name, and we want + // the ones that can't be found. + registry.getTypeByName(md.xmlName); + return false; + } catch (e) { + return true; + } + }); + } + } + + // No-op implementation since any describe metadata status would be a success. + // The only time this command would report an error is if it failed + // flag parsing or some error during the request, and those are captured + // by the command framework. + /* eslint-disable-next-line @typescript-eslint/no-empty-function */ + protected resolveSuccess(): void {} + + protected formatResult(): DescribeMetadataResult { + if (this.targetFilePath) { + fs.writeFileSync(this.targetFilePath, JSON.stringify(this.describeResult, null, 2)); + this.ux.log(`Wrote result file to ${this.targetFilePath}.`); + } else if (!this.isJsonOutput()) { + this.ux.styledJSON(this.describeResult); + } + return this.describeResult; + } + + private validateResultFile(): void { + if (this.flags.resultfile) { + this.targetFilePath = path.resolve(this.flags.resultfile); + // Ensure path exists + fs.mkdirSync(path.dirname(this.targetFilePath), { recursive: true }); + try { + const stat = fs.statSync(this.targetFilePath); + if (!stat.isFile()) { + throw SfdxError.create('@salesforce/plugin-source', 'md.describe', 'invalidResultFile', [ + this.targetFilePath, + ]); + } + } catch (err: unknown) { + const e = err as FsError; + if (e.code !== 'ENOENT') { + throw err; + } + } + } + } +} diff --git a/src/commands/force/mdapi/listmetadata.ts b/src/commands/force/mdapi/listmetadata.ts new file mode 100644 index 000000000..441df478c --- /dev/null +++ b/src/commands/force/mdapi/listmetadata.ts @@ -0,0 +1,116 @@ +/* + * 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 * as path from 'path'; +import * as fs from 'fs'; +import { flags, FlagsConfig } from '@salesforce/command'; +import { Messages, SfdxError } from '@salesforce/core'; +import { Optional } from '@salesforce/ts-types'; +import { FileProperties, ListMetadataQuery } from 'jsforce'; +import { SourceCommand } from '../../../sourceCommand'; + +Messages.importMessagesDirectory(__dirname); +const messages = Messages.loadMessages('@salesforce/plugin-source', 'md.list'); + +export type ListMetadataCommandResult = FileProperties[]; + +interface FsError extends Error { + code: string; +} + +export class ListMetadata extends SourceCommand { + public static readonly description = messages.getMessage('description'); + public static readonly examples = messages.getMessage('examples').split(os.EOL); + public static readonly requiresUsername = true; + public static readonly flagsConfig: FlagsConfig = { + apiversion: flags.builtin({ + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore force char override for backward compat + char: 'a', + description: messages.getMessage('flags.apiversion'), + longDescription: messages.getMessage('flagsLong.apiversion'), + }), + resultfile: flags.filepath({ + char: 'f', + description: messages.getMessage('flags.resultfile'), + longDescription: messages.getMessage('flagsLong.resultfile'), + }), + metadatatype: flags.string({ + char: 'm', + description: messages.getMessage('flags.metadatatype'), + longDescription: messages.getMessage('flagsLong.metadatatype'), + required: true, + }), + folder: flags.string({ + description: messages.getMessage('flags.folder'), + longDescription: messages.getMessage('flagsLong.folder'), + }), + }; + + private listResult: Optional; + private targetFilePath: string; + + public async run(): Promise { + await this.list(); + this.resolveSuccess(); + return this.formatResult(); + } + + protected async list(): Promise { + const apiversion = this.getFlag('apiversion'); + const type = this.getFlag('metadatatype'); + const folder = this.getFlag('folder'); + + this.validateResultFile(); + + const query: ListMetadataQuery = { type, folder }; + const connection = this.org.getConnection(); + const result = (await connection.metadata.list(query, apiversion)) || []; + this.listResult = Array.isArray(result) ? result : [result]; + } + + // No-op implementation since any list metadata status would be a success. + // The only time this command would report an error is if it failed + // flag parsing or some error during the request, and those are captured + // by the command framework. + /* eslint-disable-next-line @typescript-eslint/no-empty-function */ + protected resolveSuccess(): void {} + + protected formatResult(): ListMetadataCommandResult { + if (this.targetFilePath) { + fs.writeFileSync(this.targetFilePath, JSON.stringify(this.listResult, null, 2)); + this.ux.log(`Wrote result file to ${this.targetFilePath}.`); + } else if (!this.isJsonOutput()) { + if (this.listResult.length) { + this.ux.styledJSON(this.listResult); + } else { + this.ux.log(messages.getMessage('noMatchingMetadata', [this.flags.metadatatype, this.org.getUsername()])); + } + } + return this.listResult; + } + + private validateResultFile(): void { + if (this.flags.resultfile) { + this.targetFilePath = path.resolve(this.flags.resultfile); + // Ensure path exists + fs.mkdirSync(path.dirname(this.targetFilePath), { recursive: true }); + try { + const stat = fs.statSync(this.targetFilePath); + if (!stat.isFile()) { + throw SfdxError.create('@salesforce/plugin-source', 'md.list', 'invalidResultFile', [this.targetFilePath]); + } + } catch (err: unknown) { + const e = err as FsError; + if (e.code !== 'ENOENT') { + throw err; + } + } + } + } +} diff --git a/test/commands/mdapi/describemetadata.test.ts b/test/commands/mdapi/describemetadata.test.ts new file mode 100644 index 000000000..fc9db7605 --- /dev/null +++ b/test/commands/mdapi/describemetadata.test.ts @@ -0,0 +1,159 @@ +/* + * 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 fs from 'fs'; +import * as sinon from 'sinon'; +import { expect } from 'chai'; +import { Org } from '@salesforce/core'; +import { DescribeMetadataResult } from 'jsforce'; +import { fromStub, stubInterface, stubMethod } from '@salesforce/ts-sinon'; +import { cloneJson } from '@salesforce/kit'; +import { IConfig } from '@oclif/config'; +import { UX } from '@salesforce/command'; +import { RegistryAccess } from '@salesforce/source-deploy-retrieve'; +import { DescribeMetadata } from '../../../src/commands/force/mdapi/describemetadata'; + +describe('force:mdapi:describemetadata', () => { + const sandbox = sinon.createSandbox(); + const username = 'describemetadata-test@org.com'; + + const describeResponse: DescribeMetadataResult = { + metadataObjects: [ + { + directoryName: 'mlPredictions', + inFolder: false, + metaFile: false, + suffix: 'mlPrediction', + xmlName: 'MLPredictionDefinition', + }, + { + directoryName: 'fieldRestrictionRules', + inFolder: false, + metaFile: false, + suffix: 'rule', + xmlName: 'FieldRestrictionRule', + }, + ], + organizationNamespace: '', + partialSaveAllowed: true, + testRequired: false, + }; + + const oclifConfigStub = fromStub(stubInterface(sandbox)); + let describeMetadataStub: sinon.SinonStub; + let uxLogStub: sinon.SinonStub; + let uxStyledJsonStub: sinon.SinonStub; + let fsWriteFileStub: sinon.SinonStub; + let fsStatStub: sinon.SinonStub; + + class TestDescribeMetadata extends DescribeMetadata { + public async runIt() { + await this.init(); + return this.run(); + } + public setOrg(org: Org) { + this.org = org; + } + } + + const runListMetadataCmd = async (params: string[]) => { + const cmd = new TestDescribeMetadata(params, oclifConfigStub); + stubMethod(sandbox, cmd, 'assignOrg').callsFake(() => { + const orgStub = fromStub( + stubInterface(sandbox, { + getUsername: () => username, + getConnection: () => ({ + metadata: { + describe: describeMetadataStub, + }, + }), + }) + ); + cmd.setOrg(orgStub); + }); + uxLogStub = stubMethod(sandbox, UX.prototype, 'log'); + uxStyledJsonStub = stubMethod(sandbox, UX.prototype, 'styledJSON'); + + return cmd.runIt(); + }; + + beforeEach(() => { + describeMetadataStub = sandbox.stub(); + fsWriteFileStub = sandbox.stub(fs, 'writeFileSync'); + sandbox.stub(fs, 'mkdirSync'); + fsStatStub = sandbox.stub(fs, 'statSync'); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it('should return correct json', async () => { + describeMetadataStub.resolves(describeResponse); + const result = await runListMetadataCmd(['--json']); + expect(result).to.deep.equal(describeResponse); + expect(uxLogStub.called).to.be.false; + expect(uxStyledJsonStub.called).to.be.false; + }); + + it('should display correct json output', async () => { + describeMetadataStub.resolves(describeResponse); + const result = await runListMetadataCmd([]); + expect(result).to.deep.equal(describeResponse); + expect(uxLogStub.called).to.be.false; + expect(uxStyledJsonStub.firstCall.args[0]).to.deep.equal(describeResponse); + }); + + it('should report to a file (json)', async () => { + const resultfile = 'describeResults.json'; + fsStatStub.returns({ isFile: () => true }); + describeMetadataStub.resolves(describeResponse); + const result = await runListMetadataCmd(['--resultfile', resultfile, '--json']); + expect(result).to.deep.equal(describeResponse); + expect(uxLogStub.called).to.be.true; // called but not actually written to console + expect(uxStyledJsonStub.called).to.be.false; + expect(fsWriteFileStub.firstCall.args[0]).to.include(resultfile); + expect(JSON.parse(fsWriteFileStub.firstCall.args[1])).to.deep.equal(describeResponse); + }); + + it('should report to a file (display)', async () => { + const resultfile = 'describeResults.json'; + fsStatStub.returns({ isFile: () => true }); + describeMetadataStub.resolves(describeResponse); + const result = await runListMetadataCmd(['--resultfile', resultfile]); + expect(result).to.deep.equal(describeResponse); + expect(uxLogStub.firstCall.args[0]).to.include('Wrote result file to'); + expect(uxLogStub.firstCall.args[0]).to.include(resultfile); + expect(uxStyledJsonStub.called).to.be.false; + expect(fsWriteFileStub.firstCall.args[0]).to.include(resultfile); + expect(JSON.parse(fsWriteFileStub.firstCall.args[1])).to.deep.equal(describeResponse); + }); + + it('should report with a specific API version', async () => { + const apiversion = '46.0'; + describeMetadataStub.resolves(describeResponse); + const result = await runListMetadataCmd(['--apiversion', apiversion, '--json']); + expect(result).to.deep.equal(describeResponse); + expect(describeMetadataStub.firstCall.args[0]).to.equal(apiversion); + expect(uxLogStub.called).to.be.false; + expect(uxStyledJsonStub.called).to.be.false; + }); + + it('should filter on unregistered metadata types', async () => { + const originalResponse = cloneJson(describeResponse); + const filteredResponse = cloneJson(describeResponse); + filteredResponse.metadataObjects = [describeResponse.metadataObjects[0]]; + const registryStub = stubMethod(sandbox, RegistryAccess.prototype, 'getTypeByName'); + registryStub.withArgs('MLPredictionDefinition').throws(); + registryStub.withArgs('FieldRestrictionRule').returns(true); + describeMetadataStub.resolves(originalResponse); + const result = await runListMetadataCmd(['--filterknown', '--json']); + expect(result).to.deep.equal(filteredResponse); + expect(uxLogStub.called).to.be.false; + expect(uxStyledJsonStub.called).to.be.false; + }); +}); diff --git a/test/commands/mdapi/listmetadata.test.ts b/test/commands/mdapi/listmetadata.test.ts new file mode 100644 index 000000000..9399779eb --- /dev/null +++ b/test/commands/mdapi/listmetadata.test.ts @@ -0,0 +1,185 @@ +/* + * 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 fs from 'fs'; +import * as sinon from 'sinon'; +import { assert, expect } from 'chai'; +import { Org } from '@salesforce/core'; +import { FileProperties } from 'jsforce'; +import { fromStub, stubInterface, stubMethod } from '@salesforce/ts-sinon'; +import { IConfig } from '@oclif/config'; +import { UX } from '@salesforce/command'; +import { ListMetadata } from '../../../src/commands/force/mdapi/listmetadata'; + +describe('force:mdapi:listmetadata', () => { + const sandbox = sinon.createSandbox(); + const username = 'listmetadata-test@org.com'; + + const listResponse: FileProperties = { + createdById: '0053F00000BHrxsQAD', + createdByName: 'User User', + createdDate: '2021-11-02T19:49:41.000Z', + fileName: 'classes/MyApexClass.cls', + fullName: 'MyApexClass', + id: '01p3F00000NkdcuQAB', + lastModifiedById: '0053F00000BHrxsQAD', + lastModifiedByName: 'User User', + lastModifiedDate: '2021-11-02T19:49:41.000Z', + manageableState: 'unmanaged', + type: 'ApexClass', + }; + + const oclifConfigStub = fromStub(stubInterface(sandbox)); + let listMetadataStub: sinon.SinonStub; + let uxLogStub: sinon.SinonStub; + let uxStyledJsonStub: sinon.SinonStub; + let fsWriteFileStub: sinon.SinonStub; + let fsStatStub: sinon.SinonStub; + + class TestListMetadata extends ListMetadata { + public async runIt() { + await this.init(); + return this.run(); + } + public setOrg(org: Org) { + this.org = org; + } + } + + const runListMetadataCmd = async (params: string[]) => { + const cmd = new TestListMetadata(params, oclifConfigStub); + stubMethod(sandbox, cmd, 'assignOrg').callsFake(() => { + const orgStub = fromStub( + stubInterface(sandbox, { + getUsername: () => username, + getConnection: () => ({ + metadata: { + list: listMetadataStub, + }, + }), + }) + ); + cmd.setOrg(orgStub); + }); + uxLogStub = stubMethod(sandbox, UX.prototype, 'log'); + uxStyledJsonStub = stubMethod(sandbox, UX.prototype, 'styledJSON'); + + return cmd.runIt(); + }; + + beforeEach(() => { + listMetadataStub = sandbox.stub(); + fsWriteFileStub = sandbox.stub(fs, 'writeFileSync'); + sandbox.stub(fs, 'mkdirSync'); + fsStatStub = sandbox.stub(fs, 'statSync'); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it('should fail without required metadatatype flag', async () => { + try { + await runListMetadataCmd([]); + assert(false, 'expected mdapi:listmetadata to error'); + } catch (e: unknown) { + expect((e as Error).message).to.include('Missing required flag:'); + } + }); + + it('should report when no matching metadata (json)', async () => { + listMetadataStub.resolves(); + const result = await runListMetadataCmd(['--metadatatype', 'ApexClass', '--json']); + expect(result).to.deep.equal([]); + expect(uxLogStub.called).to.be.false; + expect(uxStyledJsonStub.called).to.be.false; + }); + + it('should report when no matching metadata (display)', async () => { + listMetadataStub.resolves(); + const result = await runListMetadataCmd(['--metadatatype', 'ApexClass']); + expect(result).to.deep.equal([]); + expect(uxLogStub.firstCall.args[0]).to.equal(`No metadata found for type: ApexClass in org: ${username}`); + expect(uxStyledJsonStub.called).to.be.false; + }); + + it('should report with single matching metadata (json)', async () => { + listMetadataStub.resolves(listResponse); + const result = await runListMetadataCmd(['--metadatatype', 'ApexClass', '--json']); + expect(result).to.deep.equal([listResponse]); + expect(uxLogStub.called).to.be.false; + expect(uxStyledJsonStub.called).to.be.false; + }); + + it('should report with single matching metadata (display)', async () => { + listMetadataStub.resolves(listResponse); + const result = await runListMetadataCmd(['--metadatatype', 'ApexClass']); + expect(result).to.deep.equal([listResponse]); + expect(uxLogStub.called).to.be.false; + expect(uxStyledJsonStub.firstCall.args[0]).to.deep.equal([listResponse]); + }); + + it('should report with multiple matching metadata', async () => { + const listResponse2 = JSON.parse(JSON.stringify(listResponse)) as FileProperties; + listResponse2.fileName = 'classes/MyApexClass2.cls'; + listResponse2.fullName = 'MyApexClass2'; + const response = [listResponse, listResponse2]; + listMetadataStub.resolves(response); + const result = await runListMetadataCmd(['--metadatatype', 'ApexClass', '--json']); + expect(result).to.deep.equal(response); + expect(uxLogStub.called).to.be.false; + expect(uxStyledJsonStub.called).to.be.false; + }); + + it('should report to a file (json)', async () => { + const resultfile = 'listResults.json'; + fsStatStub.returns({ isFile: () => true }); + listMetadataStub.resolves(listResponse); + const result = await runListMetadataCmd(['--metadatatype', 'ApexClass', '--resultfile', resultfile, '--json']); + expect(result).to.deep.equal([listResponse]); + expect(uxLogStub.called).to.be.true; // called but not actually written to console + expect(uxStyledJsonStub.called).to.be.false; + expect(fsWriteFileStub.firstCall.args[0]).to.include(resultfile); + expect(JSON.parse(fsWriteFileStub.firstCall.args[1])).to.deep.equal([listResponse]); + }); + + it('should report to a file (display)', async () => { + const resultfile = 'listResults.json'; + fsStatStub.returns({ isFile: () => true }); + listMetadataStub.resolves(listResponse); + const result = await runListMetadataCmd(['--metadatatype', 'ApexClass', '--resultfile', resultfile]); + expect(result).to.deep.equal([listResponse]); + expect(uxLogStub.firstCall.args[0]).to.include('Wrote result file to'); + expect(uxLogStub.firstCall.args[0]).to.include(resultfile); + expect(uxStyledJsonStub.called).to.be.false; + expect(fsWriteFileStub.firstCall.args[0]).to.include(resultfile); + expect(JSON.parse(fsWriteFileStub.firstCall.args[1])).to.deep.equal([listResponse]); + }); + + it('should report with a folder', async () => { + const folder = 'testFolder'; + listMetadataStub.resolves(listResponse); + const result = await runListMetadataCmd(['--metadatatype', 'ApexClass', '--folder', folder, '--json']); + expect(result).to.deep.equal([listResponse]); + expect(listMetadataStub.firstCall.args[0]).to.deep.equal({ + type: 'ApexClass', + folder, + }); + expect(uxLogStub.called).to.be.false; + expect(uxStyledJsonStub.called).to.be.false; + }); + + it('should report with a specific API version', async () => { + const apiversion = '46.0'; + listMetadataStub.resolves(listResponse); + const result = await runListMetadataCmd(['--metadatatype', 'ApexClass', '--apiversion', apiversion, '--json']); + expect(result).to.deep.equal([listResponse]); + expect(listMetadataStub.firstCall.args[1]).to.equal(apiversion); + expect(uxLogStub.called).to.be.false; + expect(uxStyledJsonStub.called).to.be.false; + }); +}); diff --git a/test/commands/source/retrieve.test.ts b/test/commands/source/retrieve.test.ts index b5d84de1c..d25ad0e14 100644 --- a/test/commands/source/retrieve.test.ts +++ b/test/commands/source/retrieve.test.ts @@ -81,6 +81,8 @@ describe('force:source:retrieve', () => { cmd.setOrg(orgStub); }); stubMethod(sandbox, UX.prototype, 'log'); + stubMethod(sandbox, UX.prototype, 'styledHeader'); + stubMethod(sandbox, UX.prototype, 'table'); return cmd.runIt(); }; diff --git a/test/nuts/mdapi.nut.ts b/test/nuts/mdapi.nut.ts new file mode 100644 index 000000000..f52f333da --- /dev/null +++ b/test/nuts/mdapi.nut.ts @@ -0,0 +1,122 @@ +/* + * Copyright (c) 2021, 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 { expect } from 'chai'; +import { TestSession, execCmd } from '@salesforce/cli-plugins-testkit'; +import { DescribeMetadataResult } from 'jsforce'; + +let session: TestSession; + +describe('mdapi NUTs', () => { + before(async () => { + session = await TestSession.create({ + project: { + gitClone: 'https://github.com/trailheadapps/dreamhouse-lwc.git', + }, + setupCommands: [ + // default org + 'sfdx force:org:create -d 1 -s -f config/project-scratch-def.json', + ], + }); + process.env.SFDX_USE_PROGRESS_BAR = 'false'; + }); + + after(async () => { + await session?.zip(undefined, 'artifacts'); + await session?.clean(); + }); + + describe('mdapi:listmetadata', () => { + it('should successfully execute listmetadata', () => { + const result = execCmd('force:mdapi:listmetadata --json --metadatatype CustomObject'); + expect(result.jsonOutput.status).to.equal(0); + expect(result.jsonOutput.result).to.be.an('array').with.length.greaterThan(100); + expect(result.jsonOutput.result[0]).to.have.property('type', 'CustomObject'); + }); + }); + + describe('mdapiDescribemetadataCommand', () => { + it('should successfully execute describemetadata', () => { + const result = execCmd('force:mdapi:describemetadata --json'); + expect(result.jsonOutput.status).to.equal(0); + const json = result.jsonOutput.result; + expect(json).to.have.property('metadataObjects'); + const mdObjects = json.metadataObjects; + expect(mdObjects).to.be.an('array').with.length.greaterThan(100); + const customLabelsDef = mdObjects.find((md) => md.xmlName === 'CustomLabels'); + expect(customLabelsDef).to.deep.equal({ + childXmlNames: ['CustomLabel'], + directoryName: 'labels', + inFolder: false, + metaFile: false, + suffix: 'labels', + xmlName: 'CustomLabels', + }); + }); + }); + + // *** More NUTs will be added/uncommented here as commands are moved from toolbelt to + // this plugin. Keeping these toolbelt tests here for reference. + + // describe('Test stash', () => { + // describe('Deploy using soap with non default username', () => { + // it('should deploy zip file to the scratch org and request deploy report', () => { + // execCmd('force:mdapi:deploy --zipfile unpackaged.zip --json --soapdeploy -u nonDefaultOrg', { + // ensureExitCode: 0, + // }); + // const reportCommandResponse = getString( + // execCmd('force:mdapi:deploy:report --wait 2 -u nonDefaultOrg', { + // ensureExitCode: 0, + // }), + // 'shellOutput.stdout' + // ); + + // expect(reportCommandResponse).to.include('Status: Succeeded', reportCommandResponse); + // expect(reportCommandResponse).to.include('Components deployed: 2', reportCommandResponse); + // }); + // }); + + // describe('Retrieve using non default username', () => { + // it('should perform retrieve from the scratch org and request retrieve report', () => { + // const retrieveCommandResponse = getString( + // execCmd( + // 'force:mdapi:retrieve --retrievetargetdir retrieveDir --unpackaged package.xml --wait 0 -u nonDefaultOrg', + // { ensureExitCode: 0 } + // ), + // 'shellOutput.stdout' + // ); + // expect(retrieveCommandResponse).to.include( + // 'The retrieve request did not complete within the specified wait time' + // ); + + // const retrieveReportCommand = getString( + // execCmd('force:mdapi:retrieve:report --wait 2 -u nonDefaultOrg', { + // ensureExitCode: 0, + // }), + // 'shellOutput.stdout' + // ); + // expect(retrieveReportCommand).to.include('Wrote retrieve zip to'); + // }); + // }); + + // describe('Deploy using non default username and request report using jobid parameter', () => { + // it('should fail report', () => { + // const deployCommandResponse = execCmd<{ id: string }>( + // 'force:mdapi:deploy --zipfile unpackaged.zip --json --soapdeploy', + // { ensureExitCode: 0 } + // ).jsonOutput.result; + // const reportCommandResponse = getString( + // execCmd( + // `force:mdapi:deploy:report --wait 2 --jobid ${deployCommandResponse.id} --targetusername nonDefaultOrg`, + // { ensureExitCode: 1 } + // ), + // 'shellOutput.stderr' + // ); + // expect(reportCommandResponse).to.include('INVALID_CROSS_REFERENCE_KEY: invalid cross reference id'); + // }); + // }); + // }); +});