diff --git a/package.json b/package.json index 09f5d4491..7538c3aeb 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "@salesforce/command": "^5.2.13", "@salesforce/core": "^3.26.2", "@salesforce/kit": "^1.7.1", - "@salesforce/source-deploy-retrieve": "^7.4.0", + "@salesforce/source-deploy-retrieve": "^7.5.0", "@salesforce/source-tracking": "^2.2.10", "chalk": "^4.1.2", "got": "^11.8.3", @@ -188,4 +188,4 @@ "publishConfig": { "access": "public" } -} \ No newline at end of file +} diff --git a/src/formatters/deployResultFormatter.ts b/src/formatters/deployResultFormatter.ts index a3536a64c..73b0f5d60 100644 --- a/src/formatters/deployResultFormatter.ts +++ b/src/formatters/deployResultFormatter.ts @@ -5,6 +5,7 @@ * 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 chalk from 'chalk'; import { UX } from '@salesforce/command'; import { Logger, Messages, SfError } from '@salesforce/core'; @@ -30,6 +31,7 @@ export interface DeployCommandResult extends MdDeployResult { outboundFiles: string[]; deploys: MetadataApiDeployStatus[]; deletes?: MetadataApiDeployStatus[]; + replacements?: Record; } export class DeployResultFormatter extends ResultFormatter { @@ -56,7 +58,9 @@ export class DeployResultFormatter extends ResultFormatter { json.coverage = this.getCoverageFileInfo(); json.junit = this.getJunitFileInfo(); } - + if (this.result.replacements?.size) { + json.replacements = Object.fromEntries(this.result.replacements); + } return json; } @@ -86,6 +90,7 @@ export class DeployResultFormatter extends ResultFormatter { this.displayFailures(); this.displayTestResults(); this.displayOutputFileLocations(); + this.displayReplacements(); // Throw a DeployFailed error unless the deployment was successful. if (!this.isSuccess()) { @@ -117,6 +122,22 @@ export class DeployResultFormatter extends ResultFormatter { return get(this.result, 'response', {}) as MetadataApiDeployStatus; } + protected displayReplacements(): void { + if (this.isVerbose() && this.result.replacements?.size) { + this.ux.log(''); + this.ux.styledHeader(chalk.blue('Metadata Replacements')); + const replacements = Array.from(this.result.replacements.entries()).flatMap(([filepath, stringsReplaced]) => + stringsReplaced.map((replaced) => ({ + filePath: path.relative(process.cwd(), filepath), + replaced, + })) + ); + this.ux.table(replacements, { + filePath: { header: 'PROJECT PATH' }, + replaced: { header: 'TEXT REPLACED' }, + }); + } + } protected displaySuccesses(): void { if (this.isSuccess() && this.fileResponses?.length) { const successes = this.fileResponses.filter((f) => !['Failed', 'Deleted'].includes(f.state)); diff --git a/src/formatters/source/pushResultFormatter.ts b/src/formatters/source/pushResultFormatter.ts index d8060ffc2..9ae3cc5a4 100644 --- a/src/formatters/source/pushResultFormatter.ts +++ b/src/formatters/source/pushResultFormatter.ts @@ -4,7 +4,7 @@ * 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 { resolve as pathResolve } from 'path'; +import { relative, resolve as pathResolve } from 'path'; import * as chalk from 'chalk'; import { UX } from '@salesforce/command'; import { Logger, Messages, SfError } from '@salesforce/core'; @@ -24,11 +24,14 @@ import { ResultFormatter, ResultFormatterOptions } from '../resultFormatter'; Messages.importMessagesDirectory(__dirname); const messages = Messages.loadMessages('@salesforce/plugin-source', 'push'); -export type PushResponse = { pushedSource: Array> }; +export type PushResponse = { + pushedSource: Array>; + replacements?: Record; +}; export class PushResultFormatter extends ResultFormatter { protected fileResponses: FileResponse[]; - + protected replacements: Map; public constructor( logger: Logger, ux: UX, @@ -39,6 +42,7 @@ export class PushResultFormatter extends ResultFormatter { ) { super(logger, ux, options); this.fileResponses = this.correctFileResponses(); + this.replacements = mergeReplacements(results); } /** @@ -65,9 +69,9 @@ export class PushResultFormatter extends ResultFormatter { const toReturn = this.isQuiet() ? this.fileResponses.filter((fileResponse) => fileResponse.state === ComponentStatus.Failed) : this.fileResponses; - return { pushedSource: toReturn.map(({ state, fullName, type, filePath }) => ({ state, fullName, type, filePath })), + ...(!this.isQuiet() && this.replacements.size ? { replacements: Object.fromEntries(this.replacements) } : {}), }; } @@ -82,7 +86,7 @@ export class PushResultFormatter extends ResultFormatter { public display(): void { this.displaySuccesses(); this.displayFailures(); - + this.displayReplacements(); // Throw a DeployFailed error unless the deployment was successful. if (!this.isSuccess()) { // Add error message directly on the DeployResult (e.g., a GACK) @@ -163,6 +167,23 @@ export class PushResultFormatter extends ResultFormatter { } } + protected displayReplacements(): void { + if (!this.isQuiet() && this.replacements.size) { + this.ux.log(''); + this.ux.styledHeader(chalk.blue('Metadata Replacements')); + const replacements = Array.from(this.replacements.entries()).flatMap(([filepath, stringsReplaced]) => + stringsReplaced.map((replaced) => ({ + filePath: relative(process.cwd(), filepath), + replaced, + })) + ); + this.ux.table(replacements, { + filePath: { header: 'PROJECT PATH' }, + replaced: { header: 'TEXT REPLACED' }, + }); + } + } + protected displayFailures(): void { const failures: Array = []; const fileResponseFailures: Map = new Map(); @@ -219,3 +240,18 @@ export class PushResultFormatter extends ResultFormatter { } } } + +export const mergeReplacements = (results: DeployResult[]): DeployResult['replacements'] => { + const merged = new Map(); + const replacements = results.filter((result) => result.replacements?.size).map((result) => result.replacements); + replacements.forEach((replacement) => { + replacement.forEach((value, key) => { + if (!merged.has(key)) { + merged.set(key, value); + } else { + merged.set(key, Array.from(new Set([...merged.get(key), ...value]))); + } + }); + }); + return merged; +}; diff --git a/test/formatters/deployResultFormatter.test.ts b/test/formatters/deployResultFormatter.test.ts index 8f3969d7d..07c5a9b81 100644 --- a/test/formatters/deployResultFormatter.test.ts +++ b/test/formatters/deployResultFormatter.test.ts @@ -10,7 +10,7 @@ import * as sinon from 'sinon'; import { expect } from 'chai'; import { Logger } from '@salesforce/core'; import { UX } from '@salesforce/command'; -import { FileResponse } from '@salesforce/source-deploy-retrieve'; +import { DeployResult, FileResponse } from '@salesforce/source-deploy-retrieve'; import { stubInterface } from '@salesforce/ts-sinon'; import { getDeployResult } from '../commands/source/deployResponses'; import { DeployCommandResult, DeployResultFormatter } from '../../src/formatters/deployResultFormatter'; @@ -53,6 +53,7 @@ describe('DeployResultFormatter', () => { afterEach(() => { sandbox.restore(); + process.exitCode = undefined; }); describe('getJson', () => { @@ -87,6 +88,36 @@ describe('DeployResultFormatter', () => { const formatter = new DeployResultFormatter(logger, ux as UX, {}, deployResultPartialSuccess); expect(formatter.getJson()).to.deep.equal(expectedPartialSuccessResponse); }); + + describe('replacements', () => { + it('includes expected json property when there are replacements', () => { + const resultWithReplacements = { + ...(JSON.parse(JSON.stringify(deployResultSuccess)) as DeployResult), + replacements: new Map([['MyApexClass.cls', ['foo', 'bar']]]), + }; + const formatter = new DeployResultFormatter(logger, ux as UX, {}, resultWithReplacements as DeployResult); + const json = formatter.getJson(); + + expect(json.replacements).to.deep.equal({ 'MyApexClass.cls': ['foo', 'bar'] }); + }); + it('omits json property when there are no replacements', () => { + const resultWithoutReplacements = { + ...(JSON.parse(JSON.stringify(deployResultSuccess)) as DeployResult), + }; + const formatter = new DeployResultFormatter(logger, ux as UX, {}, resultWithoutReplacements as DeployResult); + const json = formatter.getJson(); + expect(json.replacements).to.be.undefined; + }); + it('omits json property when replacements exists but is empty', () => { + const resultWithEmptyReplacements = { + ...(JSON.parse(JSON.stringify(deployResultSuccess)) as DeployResult), + replacements: new Map(), + }; + const formatter = new DeployResultFormatter(logger, ux as UX, {}, resultWithEmptyReplacements as DeployResult); + const json = formatter.getJson(); + expect(json.replacements).to.be.undefined; + }); + }); }); describe('display', () => { @@ -183,5 +214,50 @@ describe('DeployResultFormatter', () => { expect(styledHeaderStub.args[0][0]).to.include('Deployed Source'); expect(styledHeaderStub.args[1][0]).to.include('Component Failures'); }); + + describe('replacements', () => { + it('omits replacements when there are none', async () => { + process.exitCode = 0; + const resultWithoutReplacements = { + ...deployResultSuccess, + } as DeployResult; + const formatter = new DeployResultFormatter(logger, ux as UX, { verbose: true }, resultWithoutReplacements); + + formatter.display(); + + expect(logStub.callCount, 'logStub.callCount').to.equal(2); + expect(tableStub.callCount, 'tableStub.callCount').to.equal(1); + expect(styledHeaderStub.args[0][0]).to.include('Deployed Source'); + }); + it('displays replacements on verbose', async () => { + process.exitCode = 0; + + const resultWithReplacements = { + ...deployResultSuccess, + replacements: new Map([['MyApexClass.cls', ['foo', 'bar']]]), + } as DeployResult; + const formatter = new DeployResultFormatter(logger, ux as UX, { verbose: true }, resultWithReplacements); + formatter.display(); + + expect(logStub.callCount, 'logStub.callCount').to.equal(3); + // expect(tableStub.callCount, 'tableStub.callCount').to.equal(2); + expect(styledHeaderStub.args[0][0]).to.include('Deployed Source'); + expect(styledHeaderStub.args[1][0]).to.include('Metadata Replacements'); + }); + it('omits replacements unless verbose', async () => { + process.exitCode = 0; + + const resultWithReplacements = { + ...deployResultSuccess, + replacements: new Map([['MyApexClass.cls', ['foo', 'bar']]]), + } as DeployResult; + const formatter = new DeployResultFormatter(logger, ux as UX, {}, resultWithReplacements); + formatter.display(); + + expect(logStub.callCount, 'logStub.callCount').to.equal(2); + expect(tableStub.callCount, 'tableStub.callCount').to.equal(1); + expect(styledHeaderStub.args[0][0]).to.include('Deployed Source'); + }); + }); }); }); diff --git a/test/formatters/pushResultFormatter.test.ts b/test/formatters/pushResultFormatter.test.ts index 6be482ec6..212f0a764 100644 --- a/test/formatters/pushResultFormatter.test.ts +++ b/test/formatters/pushResultFormatter.test.ts @@ -9,12 +9,16 @@ import { Logger } from '@salesforce/core'; import { UX } from '@salesforce/command'; import * as sinon from 'sinon'; import { stubInterface } from '@salesforce/ts-sinon'; +import { DeployResult } from '@salesforce/source-deploy-retrieve'; import { getDeployResult } from '../commands/source/deployResponses'; -import { PushResultFormatter } from '../../src/formatters/source/pushResultFormatter'; +import { PushResultFormatter, mergeReplacements } from '../../src/formatters/source/pushResultFormatter'; describe('PushResultFormatter', () => { const logger = Logger.childFromRoot('deployTestLogger').useMemoryLogging(); const deployResultSuccess = [getDeployResult('successSync')]; + const deployResultSuccessWithReplacements = [ + { ...getDeployResult('successSync'), replacements: new Map([['foo', ['bar', 'baz']]]) }, + ] as DeployResult[]; const deployResultFailure = [getDeployResult('failed')]; const sandbox = sinon.createSandbox(); @@ -59,6 +63,22 @@ describe('PushResultFormatter', () => { }, ]); }); + it('returns expected json for success with replaements', () => { + process.exitCode = 0; + const formatter = new PushResultFormatter(logger, new UX(logger), {}, deployResultSuccessWithReplacements); + const result = formatter.getJson(); + expect(result.pushedSource).to.deep.equal([ + { + filePath: 'classes/ProductController.cls', + fullName: 'ProductController', + state: 'Changed', + type: 'ApexClass', + }, + ]); + expect(result.replacements).to.deep.equal({ + foo: ['bar', 'baz'], + }); + }); it('returns expected json for failure', () => { const formatter = new PushResultFormatter(logger, new UX(logger), {}, deployResultFailure); process.exitCode = 1; @@ -80,6 +100,18 @@ describe('PushResultFormatter', () => { process.exitCode = 0; const formatter = new PushResultFormatter(logger, new UX(logger), { quiet: true }, deployResultSuccess); expect(formatter.getJson().pushedSource).to.deep.equal([]); + expect(formatter.getJson().replacements).to.be.undefined; + }); + it('omits replacements', () => { + process.exitCode = 0; + const formatter = new PushResultFormatter( + logger, + new UX(logger), + { quiet: true }, + deployResultSuccessWithReplacements + ); + expect(formatter.getJson().pushedSource).to.deep.equal([]); + expect(formatter.getJson().replacements).to.be.undefined; }); it('honors quiet flag for json failure', () => { const formatter = new PushResultFormatter(logger, new UX(logger), { quiet: true }, deployResultFailure); @@ -102,6 +134,15 @@ describe('PushResultFormatter', () => { expect(headerStub.callCount, JSON.stringify(headerStub.args)).to.equal(1); expect(tableStub.callCount, JSON.stringify(tableStub.args)).to.equal(1); }); + it('returns expected output for success with replacements', () => { + process.exitCode = 0; + const formatter = new PushResultFormatter(logger, uxMock as UX, {}, deployResultSuccessWithReplacements); + formatter.display(); + expect(headerStub.callCount, JSON.stringify(headerStub.args)).to.equal(2); + expect(headerStub.args[0][0]).to.include('Pushed Source'); + expect(headerStub.args[1][0]).to.include('Metadata Replacements'); + expect(tableStub.callCount, JSON.stringify(tableStub.args)).to.equal(2); + }); it('should output as expected for a deploy failure (GACK)', async () => { const errorMessage = 'UNKNOWN_EXCEPTION: An unexpected error occurred. Please include this ErrorId if you contact support: 1730955361-49792 (-1117026034)'; @@ -142,5 +183,28 @@ describe('PushResultFormatter', () => { } }); }); + + describe('replacement merging when multiple pushes', () => { + it('merges the replacements from 2 pushes', () => { + const deployResultSuccessWithReplacements1 = { + ...getDeployResult('successSync'), + replacements: new Map([ + ['foo', ['bar']], + ['quux', ['baz']], + ]), + } as DeployResult; + const deployResultSuccessWithReplacements2 = { + ...getDeployResult('successSync'), + replacements: new Map([['foo', ['baz']]]), + } as DeployResult; + const result = mergeReplacements([deployResultSuccessWithReplacements1, deployResultSuccessWithReplacements2]); + expect(result).to.deep.equal( + new Map([ + ['foo', ['bar', 'baz']], + ['quux', ['baz']], + ]) + ); + }); + }); }); }); diff --git a/yarn.lock b/yarn.lock index 95c63142f..3541ad12a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1260,10 +1260,10 @@ chalk "^4" inquirer "^8.2.5" -"@salesforce/source-deploy-retrieve@^7.0.1", "@salesforce/source-deploy-retrieve@^7.4.0": - version "7.4.2" - resolved "https://registry.npmjs.org/@salesforce/source-deploy-retrieve/-/source-deploy-retrieve-7.4.2.tgz#1a3edab87bf3a985291e4bbf56200d3eca87b406" - integrity sha512-4HGAk0C827gKdPr9wl4G+xHLhhyfeRCezaO6yfD8mxhj2PdGfvatKQtQin1dLzL74jBv37RPsgdeH4e9sT/W4w== +"@salesforce/source-deploy-retrieve@^7.0.1", "@salesforce/source-deploy-retrieve@^7.4.0", "@salesforce/source-deploy-retrieve@^7.5.0": + version "7.5.0" + resolved "https://registry.yarnpkg.com/@salesforce/source-deploy-retrieve/-/source-deploy-retrieve-7.5.0.tgz#ff83df9699925bd832f43f07816f30ca424258e3" + integrity sha512-sMvWt9TqMHtCKAgBmoOkueye3oWwsRahO1BVqIJVIZJeibARXi7JXEHQp9q7cEQKQt2qsV532wITyfHUePntDg== dependencies: "@salesforce/core" "^3.31.17" "@salesforce/kit" "^1.7.0" @@ -1274,6 +1274,7 @@ graceful-fs "^4.2.10" ignore "^5.2.0" mime "2.6.0" + minimatch "^5.1.0" proxy-agent "^5.0.0" proxy-from-env "^1.1.0" unzipper "0.10.11"