Skip to content

Commit 1485c0e

Browse files
fix: add verbose test detail when deploying with tests (#151)
* fix: add verbose test detail when deploying with tests * chore: fix a few undefined errors * chore: code review I * chore: bump SDR to 4.0.1 * chore: use normalizeToArray * chore: use toArray
1 parent 9171a70 commit 1485c0e

File tree

9 files changed

+321
-19
lines changed

9 files changed

+321
-19
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
"@oclif/config": "^1",
99
"@salesforce/command": "^4.0.4",
1010
"@salesforce/core": "^2.26.1",
11-
"@salesforce/source-deploy-retrieve": "^4.0.0",
11+
"@salesforce/source-deploy-retrieve": "^4.0.1",
1212
"chalk": "^4.1.1",
1313
"cli-ux": "^5.6.3",
1414
"tslib": "^2"

src/formatters/deployResultFormatter.ts

Lines changed: 97 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,15 @@
88
import * as chalk from 'chalk';
99
import { UX } from '@salesforce/command';
1010
import { Logger, Messages, SfdxError } from '@salesforce/core';
11-
import { get, getBoolean, getString, getNumber } from '@salesforce/ts-types';
11+
import { get, getBoolean, getString, getNumber, asString } from '@salesforce/ts-types';
1212
import { DeployResult } from '@salesforce/source-deploy-retrieve';
1313
import {
14+
CodeCoverage,
1415
FileResponse,
1516
MetadataApiDeployStatus,
1617
RequestStatus,
1718
} from '@salesforce/source-deploy-retrieve/lib/src/client/types';
18-
import { ResultFormatter, ResultFormatterOptions } from './resultFormatter';
19+
import { ResultFormatter, ResultFormatterOptions, toArray } from './resultFormatter';
1920

2021
Messages.importMessagesDirectory(__dirname);
2122
const messages = Messages.loadMessages('@salesforce/plugin-source', 'deploy');
@@ -145,7 +146,9 @@ export class DeployResultFormatter extends ResultFormatter {
145146
if (this.isRunTestsEnabled()) {
146147
this.ux.log('');
147148
if (this.isVerbose()) {
148-
this.ux.log('TBD: Show test successes, failures, and code coverage');
149+
this.verboseTestFailures();
150+
this.verboseTestSuccess();
151+
this.verboseTestTime();
149152
} else {
150153
this.ux.styledHeader(chalk.blue('Test Results Summary'));
151154
this.ux.log(`Passing: ${this.getNumResult('numberTestsCompleted')}`);
@@ -155,4 +158,95 @@ export class DeployResultFormatter extends ResultFormatter {
155158
}
156159
}
157160
}
161+
162+
protected verboseTestFailures(): void {
163+
if (this.result?.response?.numberTestErrors) {
164+
const failures = toArray(this.result.response.details?.runTestResult?.failures);
165+
166+
const tests = this.sortTestResults(failures);
167+
168+
this.ux.log('');
169+
this.ux.styledHeader(
170+
chalk.red(`Test Failures [${asString(this.result.response.details.runTestResult?.numFailures)}]`)
171+
);
172+
this.ux.table(tests, {
173+
columns: [
174+
{ key: 'name', label: 'Name' },
175+
{ key: 'methodName', label: 'Method' },
176+
{ key: 'message', label: 'Message' },
177+
{ key: 'stackTrace', label: 'Stacktrace' },
178+
],
179+
});
180+
}
181+
}
182+
183+
protected verboseTestSuccess(): void {
184+
const success = toArray(this.result?.response?.details?.runTestResult?.successes);
185+
if (success.length) {
186+
const tests = this.sortTestResults(success);
187+
this.ux.log('');
188+
this.ux.styledHeader(chalk.green(`Test Success [${success.length}]`));
189+
this.ux.table(tests, {
190+
columns: [
191+
{ key: 'name', label: 'Name' },
192+
{ key: 'methodName', label: 'Method' },
193+
],
194+
});
195+
}
196+
const codeCoverage = toArray(this.result?.response?.details?.runTestResult?.codeCoverage);
197+
198+
if (codeCoverage.length) {
199+
const coverage = codeCoverage.sort((a, b) => {
200+
return a.name.toUpperCase() > b.name.toUpperCase() ? 1 : -1;
201+
});
202+
203+
this.ux.log('');
204+
this.ux.styledHeader(chalk.blue('Apex Code Coverage'));
205+
206+
coverage.map((cov: CodeCoverage & { lineNotCovered: string }) => {
207+
const numLocationsNum = parseInt(cov.numLocations, 10);
208+
const numLocationsNotCovered: number = parseInt(cov.numLocationsNotCovered, 10);
209+
const color = numLocationsNotCovered > 0 ? chalk.red : chalk.green;
210+
211+
let pctCovered = 100;
212+
const coverageDecimal: number = parseFloat(
213+
((numLocationsNum - numLocationsNotCovered) / numLocationsNum).toFixed(2)
214+
);
215+
if (numLocationsNum > 0) {
216+
pctCovered = coverageDecimal * 100;
217+
}
218+
cov.numLocations = color(`${pctCovered}%`);
219+
220+
if (!cov.locationsNotCovered) {
221+
cov.lineNotCovered = '';
222+
}
223+
const locations = toArray(cov.locationsNotCovered);
224+
cov.lineNotCovered = locations.map((location) => location.line).join(',');
225+
});
226+
227+
this.ux.table(coverage, {
228+
columns: [
229+
{ key: 'name', label: 'Name' },
230+
{
231+
key: 'numLocations',
232+
label: '% Covered',
233+
},
234+
{
235+
key: 'lineNotCovered',
236+
label: 'Uncovered Lines',
237+
},
238+
],
239+
});
240+
}
241+
}
242+
243+
protected verboseTestTime(): void {
244+
if (
245+
this.result.response?.details?.runTestResult?.successes ||
246+
this.result?.response?.details?.runTestResult?.failures
247+
) {
248+
this.ux.log('');
249+
this.ux.log(`Total Test Time: ${this.result?.response?.details?.runTestResult?.totalTime}`);
250+
}
251+
}
158252
}

src/formatters/resultFormatter.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,20 @@ import { UX } from '@salesforce/command';
1010
import { Logger } from '@salesforce/core';
1111
import { FileResponse } from '@salesforce/source-deploy-retrieve';
1212
import { getBoolean, getNumber } from '@salesforce/ts-types';
13+
import { Failures, Successes } from '@salesforce/source-deploy-retrieve/lib/src/client/types';
1314

1415
export interface ResultFormatterOptions {
1516
verbose?: boolean;
1617
waitTime?: number;
1718
}
1819

20+
export function toArray<T>(entryOrArray: T | T[] | undefined): T[] {
21+
if (entryOrArray) {
22+
return Array.isArray(entryOrArray) ? entryOrArray : [entryOrArray];
23+
}
24+
return [];
25+
}
26+
1927
export abstract class ResultFormatter {
2028
public logger: Logger;
2129
public ux: UX;
@@ -50,6 +58,15 @@ export abstract class ResultFormatter {
5058
});
5159
}
5260

61+
protected sortTestResults(results: Failures[] | Successes[] = []): Failures[] | Successes[] {
62+
return results.sort((a: Successes, b: Successes) => {
63+
if (a.methodName === b.methodName) {
64+
return a.name > b.name ? 1 : -1;
65+
}
66+
return a.methodName > b.methodName ? 1 : -1;
67+
});
68+
}
69+
5370
// Convert absolute paths to relative for better table output.
5471
protected asRelativePaths(fileResponses: FileResponse[]): void {
5572
fileResponses.forEach((file) => {

src/formatters/retrieveResultFormatter.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import {
1616
RequestStatus,
1717
RetrieveMessage,
1818
} from '@salesforce/source-deploy-retrieve/lib/src/client/types';
19-
import { ResultFormatter, ResultFormatterOptions } from './resultFormatter';
19+
import { ResultFormatter, ResultFormatterOptions, toArray } from './resultFormatter';
2020

2121
Messages.importMessagesDirectory(__dirname);
2222
const messages = Messages.loadMessages('@salesforce/plugin-source', 'retrieve');
@@ -48,7 +48,7 @@ export class RetrieveResultFormatter extends ResultFormatter {
4848
this.result = result;
4949
this.fileResponses = result?.getFileResponses ? result.getFileResponses() : [];
5050
const warnMessages = get(result, 'response.messages', []) as RetrieveMessage | RetrieveMessage[];
51-
this.warnings = Array.isArray(warnMessages) ? warnMessages : [warnMessages];
51+
this.warnings = toArray(warnMessages);
5252
this.packages = options.packages || [];
5353
// zipFile can become massive and unweildy with JSON parsing/terminal output and, isn't useful
5454
delete this.result.response.zipFile;
@@ -141,7 +141,7 @@ export class RetrieveResultFormatter extends ResultFormatter {
141141
}
142142
const unknownMsg: RetrieveMessage[] = [{ fileName: 'unknown', problem: 'unknown' }];
143143
const responseMsgs = get(this.result, 'response.messages', unknownMsg) as RetrieveMessage | RetrieveMessage[];
144-
const errMsgs = Array.isArray(responseMsgs) ? responseMsgs : [responseMsgs];
144+
const errMsgs = toArray(responseMsgs);
145145
const errMsgsForDisplay = errMsgs.reduce<string>((p, c) => `${p}\n${c.fileName}: ${c.problem}`, '');
146146
this.ux.log(`Retrieve Failed due to: ${errMsgsForDisplay}`);
147147
}

test/commands/source/deployResponses.ts

Lines changed: 158 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
RequestStatus,
1313
} from '@salesforce/source-deploy-retrieve/lib/src/client/types';
1414
import { cloneJson } from '@salesforce/kit';
15+
import { toArray } from '../../../src/formatters/resultFormatter';
1516

1617
const baseDeployResponse = {
1718
checkOnly: false,
@@ -72,7 +73,10 @@ export type DeployResponseType =
7273
| 'successRecentValidation'
7374
| 'canceled'
7475
| 'inProgress'
75-
| 'failed';
76+
| 'failed'
77+
| 'failedTest'
78+
| 'passedTest'
79+
| 'passedAndFailedTest';
7680

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

105+
if (type === 'failedTest') {
106+
response.status = RequestStatus.Failed;
107+
response.success = false;
108+
response.details.componentFailures = cloneJson(baseDeployResponse.details.componentSuccesses[1]) as DeployMessage;
109+
response.details.componentSuccesses = cloneJson(baseDeployResponse.details.componentSuccesses[0]) as DeployMessage;
110+
response.details.componentFailures.success = 'false';
111+
delete response.details.componentFailures.id;
112+
response.details.componentFailures.problemType = 'Error';
113+
response.details.componentFailures.problem = 'This component has some problems';
114+
response.details.runTestResult.numFailures = '1';
115+
response.runTestsEnabled = true;
116+
response.numberTestErrors = 1;
117+
response.details.runTestResult.successes = [];
118+
response.details.runTestResult.failures = [
119+
{
120+
name: 'ChangePasswordController',
121+
methodName: 'testMethod',
122+
message: 'testMessage',
123+
id: 'testId',
124+
time: 'testTime',
125+
packageName: 'testPkg',
126+
stackTrace: 'test stack trace',
127+
type: 'ApexClass',
128+
},
129+
];
130+
response.details.runTestResult.codeCoverage = [
131+
{
132+
id: 'ChangePasswordController',
133+
type: 'ApexClass',
134+
name: 'ChangePasswordController',
135+
numLocations: '1',
136+
locationsNotCovered: {
137+
column: '54',
138+
line: '2',
139+
numExecutions: '1',
140+
time: '2',
141+
},
142+
numLocationsNotCovered: '5',
143+
},
144+
];
145+
}
146+
147+
if (type === 'passedTest') {
148+
response.status = RequestStatus.Failed;
149+
response.success = false;
150+
response.details.componentFailures = cloneJson(baseDeployResponse.details.componentSuccesses[1]) as DeployMessage;
151+
response.details.componentSuccesses = cloneJson(baseDeployResponse.details.componentSuccesses[0]) as DeployMessage;
152+
response.details.componentFailures.success = 'false';
153+
delete response.details.componentFailures.id;
154+
response.details.componentFailures.problemType = 'Error';
155+
response.details.componentFailures.problem = 'This component has some problems';
156+
response.details.runTestResult.numFailures = '0';
157+
response.runTestsEnabled = true;
158+
response.numberTestErrors = 0;
159+
response.details.runTestResult.successes = [
160+
{
161+
name: 'ChangePasswordController',
162+
methodName: 'testMethod',
163+
id: 'testId',
164+
time: 'testTime',
165+
},
166+
];
167+
response.details.runTestResult.failures = [];
168+
response.details.runTestResult.codeCoverage = [
169+
{
170+
id: 'ChangePasswordController',
171+
type: 'ApexClass',
172+
name: 'ChangePasswordController',
173+
numLocations: '1',
174+
locationsNotCovered: {
175+
column: '54',
176+
line: '2',
177+
numExecutions: '1',
178+
time: '2',
179+
},
180+
numLocationsNotCovered: '5',
181+
},
182+
];
183+
}
184+
if (type === 'passedAndFailedTest') {
185+
response.status = RequestStatus.Failed;
186+
response.success = false;
187+
response.details.componentFailures = cloneJson(baseDeployResponse.details.componentSuccesses[1]) as DeployMessage;
188+
response.details.componentSuccesses = cloneJson(baseDeployResponse.details.componentSuccesses[0]) as DeployMessage;
189+
response.details.componentFailures.success = 'false';
190+
delete response.details.componentFailures.id;
191+
response.details.componentFailures.problemType = 'Error';
192+
response.details.componentFailures.problem = 'This component has some problems';
193+
response.details.runTestResult.numFailures = '2';
194+
response.runTestsEnabled = true;
195+
response.numberTestErrors = 2;
196+
response.details.runTestResult.successes = [
197+
{
198+
name: 'ChangePasswordController',
199+
methodName: 'testMethod',
200+
id: 'testId',
201+
time: 'testTime',
202+
},
203+
];
204+
response.details.runTestResult.failures = [
205+
{
206+
name: 'ChangePasswordController',
207+
methodName: 'testMethod',
208+
message: 'testMessage',
209+
id: 'testId',
210+
time: 'testTime',
211+
packageName: 'testPkg',
212+
stackTrace: 'test stack trace',
213+
type: 'ApexClass',
214+
},
215+
{
216+
name: 'ApexTestClass',
217+
methodName: 'testMethod',
218+
message: 'testMessage',
219+
id: 'testId',
220+
time: 'testTime',
221+
packageName: 'testPkg',
222+
stackTrace: 'test stack trace',
223+
type: 'ApexClass',
224+
},
225+
];
226+
response.details.runTestResult.codeCoverage = [
227+
{
228+
id: 'ChangePasswordController',
229+
type: 'ApexClass',
230+
name: 'ChangePasswordController',
231+
numLocations: '1',
232+
locationsNotCovered: {
233+
column: '54',
234+
line: '2',
235+
numExecutions: '1',
236+
time: '2',
237+
},
238+
numLocationsNotCovered: '5',
239+
},
240+
{
241+
id: 'ApexTestClass',
242+
type: 'ApexClass',
243+
name: 'ApexTestClass',
244+
numLocations: '1',
245+
locationsNotCovered: {
246+
column: '54',
247+
line: '2',
248+
numExecutions: '1',
249+
time: '2',
250+
},
251+
numLocationsNotCovered: '5',
252+
},
253+
];
254+
}
255+
101256
return response;
102257
};
103258

@@ -113,7 +268,7 @@ export const getDeployResult = (
113268
let fileProps: DeployMessage[] = [];
114269
if (type === 'failed') {
115270
const failures = response.details.componentFailures || [];
116-
fileProps = Array.isArray(failures) ? failures : [failures];
271+
fileProps = toArray(failures);
117272
return fileProps.map((comp) => ({
118273
fullName: comp.fullName,
119274
filePath: comp.fileName,
@@ -124,7 +279,7 @@ export const getDeployResult = (
124279
}));
125280
} else {
126281
const successes = response.details.componentSuccesses;
127-
fileProps = Array.isArray(successes) ? successes : [successes];
282+
fileProps = toArray(successes);
128283
return fileProps
129284
.filter((p) => p.fileName !== 'package.xml')
130285
.map((comp) => ({

0 commit comments

Comments
 (0)