diff --git a/package.json b/package.json index 7c8133a1..0d66af23 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "bugs": "https://github.com/forcedotcom/cli/issues", "dependencies": { "@oclif/core": "^4.0.37", - "@oclif/multi-stage-output": "^0.7.15", + "@oclif/multi-stage-output": "^0.8.0", "@salesforce/apex-node": "^8.1.18", "@salesforce/core": "^8.6.4", "@salesforce/kit": "^3.2.3", 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'; +} diff --git a/yarn.lock b/yarn.lock index 3310ad86..86ae87b8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1396,16 +1396,16 @@ wordwrap "^1.0.0" wrap-ansi "^7.0.0" -"@oclif/multi-stage-output@^0.7.15": - version "0.7.15" - resolved "https://registry.yarnpkg.com/@oclif/multi-stage-output/-/multi-stage-output-0.7.15.tgz#fe568e4db01d8d406bb735a120f1a6b4bb31213e" - integrity sha512-6qDhWbbUbdSFTEtwzWvVcEKvWY5E+ioECqS0UPOmwt2Nx1TdW1HZkHAmJ6P+4FheJhAJseFaD/hV6gdaYVwsaA== +"@oclif/multi-stage-output@^0.8.0": + version "0.8.0" + resolved "https://registry.yarnpkg.com/@oclif/multi-stage-output/-/multi-stage-output-0.8.0.tgz#868fcce009981afbb614b71246b80cd02c483782" + integrity sha512-B858dgCQPZWHRnzcU42t/cHq3u858UWMQk635qjn/lNlUpFDZKpoVgjVqLlGkQ9iEnqpwTXtyOXSYrLnPjVUeg== dependencies: "@oclif/core" "^4" "@types/react" "^18.3.12" cli-spinners "^2" figures "^6.1.0" - ink "^5.0.1" + ink "^5.1.0" react "^18.3.1" wrap-ansi "^9.0.0" @@ -4072,6 +4072,11 @@ es-to-primitive@^1.2.1: is-date-object "^1.0.1" is-symbol "^1.0.2" +es-toolkit@^1.22.0: + version "1.30.1" + resolved "https://registry.yarnpkg.com/es-toolkit/-/es-toolkit-1.30.1.tgz#311be8eec88f53b0b1a9d40117f3f3c1e763e274" + integrity sha512-ZXflqanzH8BpHkDhFa10bBf6ONDCe84EPUm7SSICGzuuROSluT2ynTPtwn9PcRelMtorCRozSknI/U0MNYp0Uw== + es6-error@^4.0.1: version "4.1.1" resolved "https://registry.yarnpkg.com/es6-error/-/es6-error-4.1.1.tgz#9e3af407459deed47e9a91f9b885a84eb05c561d" @@ -5239,6 +5244,36 @@ ink@^5.0.1: ws "^8.15.0" yoga-wasm-web "~0.3.3" +ink@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/ink/-/ink-5.1.0.tgz#8ed050bf7a468489f231c99031f8bb1393c44079" + integrity sha512-3vIO+CU4uSg167/dZrg4wHy75llUINYXxN4OsdaCkE40q4zyOTPwNc2VEpLnnWsIvIQeo6x6lilAhuaSt+rIsA== + dependencies: + "@alcalzone/ansi-tokenize" "^0.1.3" + ansi-escapes "^7.0.0" + ansi-styles "^6.2.1" + auto-bind "^5.0.1" + chalk "^5.3.0" + cli-boxes "^3.0.0" + cli-cursor "^4.0.0" + cli-truncate "^4.0.0" + code-excerpt "^4.0.0" + es-toolkit "^1.22.0" + indent-string "^5.0.0" + is-in-ci "^1.0.0" + patch-console "^2.0.0" + react-reconciler "^0.29.0" + scheduler "^0.23.0" + signal-exit "^3.0.7" + slice-ansi "^7.1.0" + stack-utils "^2.0.6" + string-width "^7.2.0" + type-fest "^4.27.0" + widest-line "^5.0.0" + wrap-ansi "^9.0.0" + ws "^8.18.0" + yoga-wasm-web "~0.3.3" + internal-slot@^1.0.7: version "1.0.7" resolved "https://registry.yarnpkg.com/internal-slot/-/internal-slot-1.0.7.tgz#c06dcca3ed874249881007b0a5523b172a190802" @@ -5373,6 +5408,11 @@ is-in-ci@^0.1.0: resolved "https://registry.yarnpkg.com/is-in-ci/-/is-in-ci-0.1.0.tgz#5e07d6a02ec3a8292d3f590973357efa3fceb0d3" integrity sha512-d9PXLEY0v1iJ64xLiQMJ51J128EYHAaOR4yZqQi8aHGfw6KgifM3/Viw1oZZ1GCVmb3gBuyhLyHj0HgR2DhSXQ== +is-in-ci@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-in-ci/-/is-in-ci-1.0.0.tgz#9a86bbda7e42c6129902e0574c54b018fbb6ab88" + integrity sha512-eUuAjybVTHMYWm/U+vBO1sY/JOCgoPCXRxzdju0K+K0BiGW0SChEL1MLC0PoCIR1OlPo5YAp8HuQoUlsWEICwg== + is-inside-container@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/is-inside-container/-/is-inside-container-1.0.0.tgz#e81fba699662eb31dbdaf26766a61d4814717ea4" @@ -7786,16 +7826,7 @@ static-eval@2.0.2: dependencies: escodegen "^1.8.1" -"string-width-cjs@npm:string-width@^4.2.0": - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -7864,14 +7895,7 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - -strip-ansi@6.0.1, strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@6.0.1, strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -8164,6 +8188,11 @@ type-fest@^1.0.2: resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-1.4.0.tgz#e9fb813fe3bf1744ec359d55d1affefa76f14be1" integrity sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA== +type-fest@^4.27.0: + version "4.30.1" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-4.30.1.tgz#120b9e15177310ec4e9d5d6f187d86c0f4b55e0e" + integrity sha512-ojFL7eDMX2NF0xMbDwPZJ8sb7ckqtlAi1GsmgsFXvErT9kFTk1r0DuQKvrCh73M6D4nngeHJmvogF9OluXs7Hw== + type-fest@^4.8.3: version "4.26.1" resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-4.26.1.tgz#a4a17fa314f976dd3e6d6675ef6c775c16d7955e" @@ -8472,7 +8501,7 @@ workerpool@^6.5.1: resolved "https://registry.yarnpkg.com/workerpool/-/workerpool-6.5.1.tgz#060f73b39d0caf97c6db64da004cd01b4c099544" integrity sha512-Fs4dNYcsdpYSAfVxhnl1L5zTksjvOJxtC5hzMNl+1t9B8hTJTdKDyZ5ju7ztgPy+ft9tBFXoOlDNiOT9WUXZlA== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -8490,15 +8519,6 @@ wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" -wrap-ansi@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" @@ -8532,7 +8552,7 @@ write-file-atomic@^3.0.0: signal-exit "^3.0.2" typedarray-to-buffer "^3.1.5" -ws@^8.15.0: +ws@^8.15.0, ws@^8.18.0: version "8.18.0" resolved "https://registry.yarnpkg.com/ws/-/ws-8.18.0.tgz#0d7505a6eafe2b0e712d232b42279f53bc289bbc" integrity sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==