diff --git a/src/formatters/testResultsFormatter.ts b/src/formatters/testResultsFormatter.ts index c3ae12fb..67513d6f 100644 --- a/src/formatters/testResultsFormatter.ts +++ b/src/formatters/testResultsFormatter.ts @@ -21,6 +21,7 @@ import { ensureArray } from '@salesforce/kit'; import { TestLevel, Verbosity } from '../utils/types.js'; import { tableHeader, error, success, check } from '../utils/output.js'; import { coverageOutput } from '../utils/coverage.js'; +import { isCI } from '../utils/deployStages.js'; const ux = new Ux(); @@ -45,10 +46,14 @@ export class TestResultsFormatter { return; } - displayVerboseTestFailures(this.result.response); + if (!isCI()) { + displayVerboseTestFailures(this.result.response); + } if (this.verbosity === 'verbose') { - displayVerboseTestSuccesses(this.result.response.details.runTestResult?.successes); + if (!isCI()) { + displayVerboseTestSuccesses(this.result.response.details.runTestResult?.successes); + } displayVerboseTestCoverage(this.result.response.details.runTestResult?.codeCoverage); } @@ -122,7 +127,7 @@ const displayVerboseTestCoverage = (coverage?: CodeCoverage | CodeCoverage[]): v } }; -const testResultSort = (a: T, b: T): number => +export const testResultSort = (a: T, b: T): number => a.methodName === b.methodName ? a.name.localeCompare(b.name) : a.methodName.localeCompare(b.methodName); const coverageSort = (a: CodeCoverage, b: CodeCoverage): number => diff --git a/src/utils/deployStages.ts b/src/utils/deployStages.ts index dd2b3da8..b63849ed 100644 --- a/src/utils/deployStages.ts +++ b/src/utils/deployStages.ts @@ -4,12 +4,21 @@ * 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 os from 'node:os'; import { MultiStageOutput } from '@oclif/multi-stage-output'; import { Lifecycle, Messages } from '@salesforce/core'; -import { MetadataApiDeploy, MetadataApiDeployStatus, RequestStatus } from '@salesforce/source-deploy-retrieve'; +import { + Failures, + MetadataApiDeploy, + MetadataApiDeployStatus, + RequestStatus, +} from '@salesforce/source-deploy-retrieve'; import { SourceMemberPollingEvent } from '@salesforce/source-tracking'; import terminalLink from 'terminal-link'; +import ansis from 'ansis'; +import { testResultSort } from '../formatters/testResultsFormatter.js'; import { getZipFileSize } from './output.js'; +import { isTruthy } from './types.js'; Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); const mdTransferMessages = Messages.loadMessages('@salesforce/plugin-deploy-retrieve', 'metadata.transfer'); @@ -47,8 +56,14 @@ function formatProgress(current: number, total: number): string { export class DeployStages { private mso: MultiStageOutput; + /** + * Set of Apex test failures that were already rendered in the `Running Tests` block. + * This is used in the `Failed` stage block for CI output to ensure test failures aren't duplicated when rendering new failures on polling. + */ + private printedApexTestFailures: Set; public constructor({ title, jsonEnabled }: Options) { + this.printedApexTestFailures = new Set(); this.mso = new MultiStageOutput({ title, stages: [ @@ -129,7 +144,7 @@ export class DeployStages { type: 'dynamic-key-value', }, { - label: 'Tests', + label: 'Successful', get: (data): string | undefined => data?.mdapiDeploy?.numberTestsTotal && data?.mdapiDeploy?.numberTestsCompleted ? formatProgress(data?.mdapiDeploy?.numberTestsCompleted, data?.mdapiDeploy?.numberTestsTotal) @@ -137,6 +152,43 @@ export class DeployStages { stage: 'Running Tests', type: 'dynamic-key-value', }, + { + label: 'Failed', + alwaysPrintInCI: true, + get: (data): string | undefined => { + let testFailures: Failures[] = []; + + // only render new test failures + if (isCI() && Array.isArray(data?.mdapiDeploy.details.runTestResult?.failures)) { + // skip failure counter/progress info if there's no new failures to render. + if ( + this.printedApexTestFailures.size > 0 && + data.mdapiDeploy.numberTestErrors === this.printedApexTestFailures.size + ) { + return undefined; + } + + testFailures = data.mdapiDeploy.details.runTestResult?.failures.filter( + (f) => !this.printedApexTestFailures.has(`${f.name}.${f.methodName}`) + ); + + data?.mdapiDeploy.details.runTestResult?.failures.forEach((f) => + this.printedApexTestFailures.add(`${f.name}.${f.methodName}`) + ); + + return data?.mdapiDeploy?.numberTestsTotal && data?.mdapiDeploy?.numberTestErrors + ? formatProgress(data?.mdapiDeploy?.numberTestErrors, data?.mdapiDeploy?.numberTestsTotal) + + (isCI() ? os.EOL + formatTestFailures(testFailures) : '') + : undefined; + } + + return data?.mdapiDeploy?.numberTestsTotal && data?.mdapiDeploy?.numberTestErrors + ? formatProgress(data?.mdapiDeploy?.numberTestErrors, data?.mdapiDeploy?.numberTestsTotal) + : undefined; + }, + stage: 'Running Tests', + type: 'dynamic-key-value', + }, { label: 'Members', get: (data): string | undefined => @@ -232,3 +284,34 @@ export class DeployStages { this.mso.skipTo('Done', data); } } + +function formatTestFailures(failuresData: Failures[]): string { + const failures = failuresData.sort(testResultSort); + + let output = ''; + + for (const test of failures) { + const testName = ansis.underline(`${test.name}.${test.methodName}`); + output += ` • ${testName}${os.EOL}`; + output += ` message: ${test.message}${os.EOL}`; + if (test.stackTrace) { + const stackTrace = test.stackTrace.replace(/\n/g, `${os.EOL} `); + output += ` stacktrace:${os.EOL} ${stackTrace}${os.EOL}${os.EOL}`; + } + } + + // remove last EOL char + return output.slice(0, -1); +} + +export function isCI(): boolean { + if ( + isTruthy(process.env.CI) && + ('CI' in process.env || + 'CONTINUOUS_INTEGRATION' in process.env || + Object.keys(process.env).some((key) => key.startsWith('CI_'))) + ) + return true; + + return false; +} diff --git a/src/utils/types.ts b/src/utils/types.ts index e8a7a8c9..10a4ad62 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -124,3 +124,7 @@ export const isFileResponseDeleted = (fileResponse: FileResponseSuccess): boolea fileResponse.state === ComponentStatus.Deleted; export const isDefined = (value?: T): value is T => value !== undefined; + +export function isTruthy(value: string | undefined): boolean { + return value !== '0' && value !== 'false'; +}