Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: display replacements on push/deploy #628

Merged
merged 6 commits into from
Nov 1, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -188,4 +188,4 @@
"publishConfig": {
"access": "public"
}
}
}
23 changes: 22 additions & 1 deletion src/formatters/deployResultFormatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -30,6 +31,7 @@ export interface DeployCommandResult extends MdDeployResult {
outboundFiles: string[];
deploys: MetadataApiDeployStatus[];
deletes?: MetadataApiDeployStatus[];
replacements?: Record<string, string[]>;
}

export class DeployResultFormatter extends ResultFormatter {
Expand All @@ -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;
}

Expand Down Expand Up @@ -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()) {
Expand Down Expand Up @@ -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) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

only displaying replacements with --verbose? Same comment for pushResultFormatter below too

I would expect if there were any replacements, they'd be displayed without needing another flag?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for deploy, I could go either way. It felt noisy, like a thing I wouldn't want / pay attention to unless something weird was happening and I needed to investigate.

for push, they'll be there by default because it already has a quiet mode which omits them (from human and json). Or at least that's what I meant.

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));
Expand Down
46 changes: 41 additions & 5 deletions src/formatters/source/pushResultFormatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<Pick<FileResponse, 'filePath' | 'fullName' | 'state' | 'type'>> };
export type PushResponse = {
pushedSource: Array<Pick<FileResponse, 'filePath' | 'fullName' | 'state' | 'type'>>;
replacements?: Record<string, string[]>;
};

export class PushResultFormatter extends ResultFormatter {
protected fileResponses: FileResponse[];

protected replacements: Map<string, string[]>;
public constructor(
logger: Logger,
ux: UX,
Expand All @@ -39,6 +42,7 @@ export class PushResultFormatter extends ResultFormatter {
) {
super(logger, ux, options);
this.fileResponses = this.correctFileResponses();
this.replacements = mergeReplacements(results);
}

/**
Expand All @@ -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) } : {}),
};
}

Expand All @@ -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)
Expand Down Expand Up @@ -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<FileResponse | DeployMessage> = [];
const fileResponseFailures: Map<string, string> = new Map<string, string>();
Expand Down Expand Up @@ -219,3 +240,18 @@ export class PushResultFormatter extends ResultFormatter {
}
}
}

export const mergeReplacements = (results: DeployResult[]): DeployResult['replacements'] => {
const merged = new Map<string, string[]>();
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;
};
78 changes: 77 additions & 1 deletion test/formatters/deployResultFormatter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -53,6 +53,7 @@ describe('DeployResultFormatter', () => {

afterEach(() => {
sandbox.restore();
process.exitCode = undefined;
});

describe('getJson', () => {
Expand Down Expand Up @@ -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<string, string[]>([['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<string, string[]>(),
};
const formatter = new DeployResultFormatter(logger, ux as UX, {}, resultWithEmptyReplacements as DeployResult);
const json = formatter.getJson();
expect(json.replacements).to.be.undefined;
});
});
});

describe('display', () => {
Expand Down Expand Up @@ -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<string, string[]>([['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<string, string[]>([['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');
});
});
});
});
66 changes: 65 additions & 1 deletion test/formatters/pushResultFormatter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string[]>([['foo', ['bar', 'baz']]]) },
] as DeployResult[];
const deployResultFailure = [getDeployResult('failed')];

const sandbox = sinon.createSandbox();
Expand Down Expand Up @@ -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;
Expand All @@ -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);
Expand All @@ -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)';
Expand Down Expand Up @@ -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<string, string[]>([
['foo', ['bar']],
['quux', ['baz']],
]),
} as DeployResult;
const deployResultSuccessWithReplacements2 = {
...getDeployResult('successSync'),
replacements: new Map<string, string[]>([['foo', ['baz']]]),
} as DeployResult;
const result = mergeReplacements([deployResultSuccessWithReplacements1, deployResultSuccessWithReplacements2]);
expect(result).to.deep.equal(
new Map<string, string[]>([
['foo', ['bar', 'baz']],
['quux', ['baz']],
])
);
});
});
});
});
Loading