Skip to content
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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"@oclif/config": "^1",
"@salesforce/command": "^4.0.4",
"@salesforce/core": "^2.26.1",
"@salesforce/source-deploy-retrieve": "^4.0.0",
"@salesforce/source-deploy-retrieve": "^4.0.1",
"chalk": "^4.1.1",
"cli-ux": "^5.6.3",
"tslib": "^2"
Expand Down
100 changes: 97 additions & 3 deletions src/formatters/deployResultFormatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,15 @@
import * as chalk from 'chalk';
import { UX } from '@salesforce/command';
import { Logger, Messages, SfdxError } from '@salesforce/core';
import { get, getBoolean, getString, getNumber } from '@salesforce/ts-types';
import { get, getBoolean, getString, getNumber, asString } from '@salesforce/ts-types';
import { DeployResult } from '@salesforce/source-deploy-retrieve';
import {
CodeCoverage,
FileResponse,
MetadataApiDeployStatus,
RequestStatus,
} from '@salesforce/source-deploy-retrieve/lib/src/client/types';
import { ResultFormatter, ResultFormatterOptions } from './resultFormatter';
import { ResultFormatter, ResultFormatterOptions, toArray } from './resultFormatter';

Messages.importMessagesDirectory(__dirname);
const messages = Messages.loadMessages('@salesforce/plugin-source', 'deploy');
Expand Down Expand Up @@ -145,7 +146,9 @@ export class DeployResultFormatter extends ResultFormatter {
if (this.isRunTestsEnabled()) {
this.ux.log('');
if (this.isVerbose()) {
this.ux.log('TBD: Show test successes, failures, and code coverage');
this.verboseTestFailures();
this.verboseTestSuccess();
this.verboseTestTime();
} else {
this.ux.styledHeader(chalk.blue('Test Results Summary'));
this.ux.log(`Passing: ${this.getNumResult('numberTestsCompleted')}`);
Expand All @@ -155,4 +158,95 @@ export class DeployResultFormatter extends ResultFormatter {
}
}
}

protected verboseTestFailures(): void {
if (this.result?.response?.numberTestErrors) {
const failures = toArray(this.result.response.details?.runTestResult?.failures);

const tests = this.sortTestResults(failures);

this.ux.log('');
this.ux.styledHeader(
chalk.red(`Test Failures [${asString(this.result.response.details.runTestResult?.numFailures)}]`)
);
this.ux.table(tests, {
columns: [
{ key: 'name', label: 'Name' },
{ key: 'methodName', label: 'Method' },
{ key: 'message', label: 'Message' },
{ key: 'stackTrace', label: 'Stacktrace' },
],
});
}
}

protected verboseTestSuccess(): void {
const success = toArray(this.result?.response?.details?.runTestResult?.successes);
if (success.length) {
const tests = this.sortTestResults(success);
this.ux.log('');
this.ux.styledHeader(chalk.green(`Test Success [${success.length}]`));
this.ux.table(tests, {
columns: [
{ key: 'name', label: 'Name' },
{ key: 'methodName', label: 'Method' },
],
});
}
const codeCoverage = toArray(this.result?.response?.details?.runTestResult?.codeCoverage);

if (codeCoverage.length) {
const coverage = codeCoverage.sort((a, b) => {
return a.name.toUpperCase() > b.name.toUpperCase() ? 1 : -1;
});

this.ux.log('');
this.ux.styledHeader(chalk.blue('Apex Code Coverage'));

coverage.map((cov: CodeCoverage & { lineNotCovered: string }) => {
const numLocationsNum = parseInt(cov.numLocations, 10);
const numLocationsNotCovered: number = parseInt(cov.numLocationsNotCovered, 10);
const color = numLocationsNotCovered > 0 ? chalk.red : chalk.green;

let pctCovered = 100;
const coverageDecimal: number = parseFloat(
((numLocationsNum - numLocationsNotCovered) / numLocationsNum).toFixed(2)
);
if (numLocationsNum > 0) {
pctCovered = coverageDecimal * 100;
}
cov.numLocations = color(`${pctCovered}%`);

if (!cov.locationsNotCovered) {
cov.lineNotCovered = '';
}
const locations = toArray(cov.locationsNotCovered);
cov.lineNotCovered = locations.map((location) => location.line).join(',');
});

this.ux.table(coverage, {
columns: [
{ key: 'name', label: 'Name' },
{
key: 'numLocations',
label: '% Covered',
},
{
key: 'lineNotCovered',
label: 'Uncovered Lines',
},
],
});
}
}

protected verboseTestTime(): void {
if (
this.result.response?.details?.runTestResult?.successes ||
this.result?.response?.details?.runTestResult?.failures
) {
this.ux.log('');
this.ux.log(`Total Test Time: ${this.result?.response?.details?.runTestResult?.totalTime}`);
}
}
}
17 changes: 17 additions & 0 deletions src/formatters/resultFormatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,20 @@ import { UX } from '@salesforce/command';
import { Logger } from '@salesforce/core';
import { FileResponse } from '@salesforce/source-deploy-retrieve';
import { getBoolean, getNumber } from '@salesforce/ts-types';
import { Failures, Successes } from '@salesforce/source-deploy-retrieve/lib/src/client/types';

export interface ResultFormatterOptions {
verbose?: boolean;
waitTime?: number;
}

export function toArray<T>(entryOrArray: T | T[] | undefined): T[] {
if (entryOrArray) {
return Array.isArray(entryOrArray) ? entryOrArray : [entryOrArray];
}
return [];
}

export abstract class ResultFormatter {
public logger: Logger;
public ux: UX;
Expand Down Expand Up @@ -50,6 +58,15 @@ export abstract class ResultFormatter {
});
}

protected sortTestResults(results: Failures[] | Successes[] = []): Failures[] | Successes[] {
return results.sort((a: Successes, b: Successes) => {
if (a.methodName === b.methodName) {
return a.name > b.name ? 1 : -1;
}
return a.methodName > b.methodName ? 1 : -1;
});
}

// Convert absolute paths to relative for better table output.
protected asRelativePaths(fileResponses: FileResponse[]): void {
fileResponses.forEach((file) => {
Expand Down
6 changes: 3 additions & 3 deletions src/formatters/retrieveResultFormatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {
RequestStatus,
RetrieveMessage,
} from '@salesforce/source-deploy-retrieve/lib/src/client/types';
import { ResultFormatter, ResultFormatterOptions } from './resultFormatter';
import { ResultFormatter, ResultFormatterOptions, toArray } from './resultFormatter';

Messages.importMessagesDirectory(__dirname);
const messages = Messages.loadMessages('@salesforce/plugin-source', 'retrieve');
Expand Down Expand Up @@ -48,7 +48,7 @@ export class RetrieveResultFormatter extends ResultFormatter {
this.result = result;
this.fileResponses = result?.getFileResponses ? result.getFileResponses() : [];
const warnMessages = get(result, 'response.messages', []) as RetrieveMessage | RetrieveMessage[];
this.warnings = Array.isArray(warnMessages) ? warnMessages : [warnMessages];
this.warnings = toArray(warnMessages);
this.packages = options.packages || [];
// zipFile can become massive and unweildy with JSON parsing/terminal output and, isn't useful
delete this.result.response.zipFile;
Expand Down Expand Up @@ -141,7 +141,7 @@ export class RetrieveResultFormatter extends ResultFormatter {
}
const unknownMsg: RetrieveMessage[] = [{ fileName: 'unknown', problem: 'unknown' }];
const responseMsgs = get(this.result, 'response.messages', unknownMsg) as RetrieveMessage | RetrieveMessage[];
const errMsgs = Array.isArray(responseMsgs) ? responseMsgs : [responseMsgs];
const errMsgs = toArray(responseMsgs);
const errMsgsForDisplay = errMsgs.reduce<string>((p, c) => `${p}\n${c.fileName}: ${c.problem}`, '');
this.ux.log(`Retrieve Failed due to: ${errMsgsForDisplay}`);
}
Expand Down
161 changes: 158 additions & 3 deletions test/commands/source/deployResponses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
RequestStatus,
} from '@salesforce/source-deploy-retrieve/lib/src/client/types';
import { cloneJson } from '@salesforce/kit';
import { toArray } from '../../../src/formatters/resultFormatter';

const baseDeployResponse = {
checkOnly: false,
Expand Down Expand Up @@ -72,7 +73,10 @@ export type DeployResponseType =
| 'successRecentValidation'
| 'canceled'
| 'inProgress'
| 'failed';
| 'failed'
| 'failedTest'
| 'passedTest'
| 'passedAndFailedTest';

export const getDeployResponse = (
type: DeployResponseType,
Expand All @@ -98,6 +102,157 @@ export const getDeployResponse = (
response.details.componentFailures.problem = 'This component has some problems';
}

if (type === 'failedTest') {
response.status = RequestStatus.Failed;
response.success = false;
response.details.componentFailures = cloneJson(baseDeployResponse.details.componentSuccesses[1]) as DeployMessage;
response.details.componentSuccesses = cloneJson(baseDeployResponse.details.componentSuccesses[0]) as DeployMessage;
response.details.componentFailures.success = 'false';
delete response.details.componentFailures.id;
response.details.componentFailures.problemType = 'Error';
response.details.componentFailures.problem = 'This component has some problems';
response.details.runTestResult.numFailures = '1';
response.runTestsEnabled = true;
response.numberTestErrors = 1;
response.details.runTestResult.successes = [];
response.details.runTestResult.failures = [
{
name: 'ChangePasswordController',
methodName: 'testMethod',
message: 'testMessage',
id: 'testId',
time: 'testTime',
packageName: 'testPkg',
stackTrace: 'test stack trace',
type: 'ApexClass',
},
];
response.details.runTestResult.codeCoverage = [
{
id: 'ChangePasswordController',
type: 'ApexClass',
name: 'ChangePasswordController',
numLocations: '1',
locationsNotCovered: {
column: '54',
line: '2',
numExecutions: '1',
time: '2',
},
numLocationsNotCovered: '5',
},
];
}

if (type === 'passedTest') {
response.status = RequestStatus.Failed;
response.success = false;
response.details.componentFailures = cloneJson(baseDeployResponse.details.componentSuccesses[1]) as DeployMessage;
response.details.componentSuccesses = cloneJson(baseDeployResponse.details.componentSuccesses[0]) as DeployMessage;
response.details.componentFailures.success = 'false';
delete response.details.componentFailures.id;
response.details.componentFailures.problemType = 'Error';
response.details.componentFailures.problem = 'This component has some problems';
response.details.runTestResult.numFailures = '0';
response.runTestsEnabled = true;
response.numberTestErrors = 0;
response.details.runTestResult.successes = [
{
name: 'ChangePasswordController',
methodName: 'testMethod',
id: 'testId',
time: 'testTime',
},
];
response.details.runTestResult.failures = [];
response.details.runTestResult.codeCoverage = [
{
id: 'ChangePasswordController',
type: 'ApexClass',
name: 'ChangePasswordController',
numLocations: '1',
locationsNotCovered: {
column: '54',
line: '2',
numExecutions: '1',
time: '2',
},
numLocationsNotCovered: '5',
},
];
}
if (type === 'passedAndFailedTest') {
response.status = RequestStatus.Failed;
response.success = false;
response.details.componentFailures = cloneJson(baseDeployResponse.details.componentSuccesses[1]) as DeployMessage;
response.details.componentSuccesses = cloneJson(baseDeployResponse.details.componentSuccesses[0]) as DeployMessage;
response.details.componentFailures.success = 'false';
delete response.details.componentFailures.id;
response.details.componentFailures.problemType = 'Error';
response.details.componentFailures.problem = 'This component has some problems';
response.details.runTestResult.numFailures = '2';
response.runTestsEnabled = true;
response.numberTestErrors = 2;
response.details.runTestResult.successes = [
{
name: 'ChangePasswordController',
methodName: 'testMethod',
id: 'testId',
time: 'testTime',
},
];
response.details.runTestResult.failures = [
{
name: 'ChangePasswordController',
methodName: 'testMethod',
message: 'testMessage',
id: 'testId',
time: 'testTime',
packageName: 'testPkg',
stackTrace: 'test stack trace',
type: 'ApexClass',
},
{
name: 'ApexTestClass',
methodName: 'testMethod',
message: 'testMessage',
id: 'testId',
time: 'testTime',
packageName: 'testPkg',
stackTrace: 'test stack trace',
type: 'ApexClass',
},
];
response.details.runTestResult.codeCoverage = [
{
id: 'ChangePasswordController',
type: 'ApexClass',
name: 'ChangePasswordController',
numLocations: '1',
locationsNotCovered: {
column: '54',
line: '2',
numExecutions: '1',
time: '2',
},
numLocationsNotCovered: '5',
},
{
id: 'ApexTestClass',
type: 'ApexClass',
name: 'ApexTestClass',
numLocations: '1',
locationsNotCovered: {
column: '54',
line: '2',
numExecutions: '1',
time: '2',
},
numLocationsNotCovered: '5',
},
];
}

return response;
};

Expand All @@ -113,7 +268,7 @@ export const getDeployResult = (
let fileProps: DeployMessage[] = [];
if (type === 'failed') {
const failures = response.details.componentFailures || [];
fileProps = Array.isArray(failures) ? failures : [failures];
fileProps = toArray(failures);
return fileProps.map((comp) => ({
fullName: comp.fullName,
filePath: comp.fileName,
Expand All @@ -124,7 +279,7 @@ export const getDeployResult = (
}));
} else {
const successes = response.details.componentSuccesses;
fileProps = Array.isArray(successes) ? successes : [successes];
fileProps = toArray(successes);
return fileProps
.filter((p) => p.fileName !== 'package.xml')
.map((comp) => ({
Expand Down
Loading