diff --git a/src/formatters/deployResultFormatter.ts b/src/formatters/deployResultFormatter.ts index 8533cb2f..4690c720 100644 --- a/src/formatters/deployResultFormatter.ts +++ b/src/formatters/deployResultFormatter.ts @@ -25,6 +25,7 @@ import { JUnitReporter, TestResult, } from '@salesforce/apex-node'; +import { getState } from '@salesforce/source-deploy-retrieve/lib/src/client/deployMessages.js'; import { DeployResultJson, isSdrFailure, @@ -51,10 +52,10 @@ import { import { TestResultsFormatter } from '../formatters/testResultsFormatter.js'; export class DeployResultFormatter extends TestResultsFormatter implements Formatter { - private relativeFiles: FileResponse[]; - private absoluteFiles: FileResponse[]; - private coverageOptions: CoverageReporterOptions; - private resultsDir: string; + private readonly relativeFiles: FileResponse[]; + private readonly absoluteFiles: FileResponse[]; + private readonly coverageOptions: CoverageReporterOptions; + private readonly resultsDir: string; private readonly junit: boolean | undefined; public constructor( @@ -285,6 +286,26 @@ export class DeployResultFormatter extends TestResultsFormatter implements Forma private displaySuccesses(): void { const successes = this.relativeFiles.filter(isSdrSuccess); + ensureArray(this.result.response.details.componentSuccesses) + .filter( + (fromServer) => + // removes package.xml, other manifests + fromServer.componentType !== '' && + // if we don't find the file in the response, it's because it doesn't exist locally, yet + !successes.find( + (fromLocal) => fromServer.fullName === fromLocal.fullName && fromServer.componentType === fromLocal.type + ) + ) + .map((s) => + successes.push({ + fullName: s.fullName, + // @ts-expect-error getState can return 'failed' which isn't applicable to FileSuccess + state: getState(s), + type: s.componentType ?? '', + filePath: 'Not found in project', + }) + ); + if (!successes.length || this.result.response.status === RequestStatus.Failed) return; const columns = { diff --git a/test/utils/deployResultFormatter.test.ts b/test/utils/deployResultFormatter.test.ts new file mode 100644 index 00000000..7b9fb144 --- /dev/null +++ b/test/utils/deployResultFormatter.test.ts @@ -0,0 +1,265 @@ +/* + * Copyright (c) 2020, salesforce.com, inc. + * All rights reserved. + * 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 * as path from 'node:path'; +import { assert, expect, config } from 'chai'; +import sinon from 'sinon'; +import { DeployMessage, DeployResult, FileResponse } from '@salesforce/source-deploy-retrieve'; +import { ux } from '@oclif/core'; +import { ensureArray } from '@salesforce/kit'; +import { getCoverageFormattersOptions } from '../../src/utils/coverage.js'; +import { DeployResultFormatter } from '../../src/formatters/deployResultFormatter.js'; +import { getDeployResult } from './deployResponses.js'; + +config.truncateThreshold = 0; + +describe('deployResultFormatter', () => { + const sandbox = sinon.createSandbox(); + + afterEach(() => { + sandbox.restore(); + }); + + describe('displaySuccesses', () => { + const deployResultSuccess = getDeployResult('successSync'); + let tableStub: sinon.SinonStub; + + beforeEach(() => { + tableStub = sandbox.stub(ux, 'table'); + }); + + it('finds components in server response, and adds them if not in fileResponses', () => { + ensureArray(deployResultSuccess.response.details.componentSuccesses).push({ + changed: 'false', + created: 'true', + createdDate: '123', + fullName: 'remoteClass', + componentType: 'ApexClass', + success: 'true', + deleted: 'false', + fileName: '', + }); + const formatter = new DeployResultFormatter(deployResultSuccess, { verbose: true }); + formatter.display(); + expect(tableStub.callCount).to.equal(1); + expect(tableStub.firstCall.args[0]).to.deep.equal([ + { + filePath: 'classes/ProductController.cls', + fullName: 'ProductController', + state: 'Changed', + type: 'ApexClass', + }, + { + filePath: 'Not found in project', + fullName: 'remoteClass', + state: 'Created', + type: 'ApexClass', + }, + ]); + }); + }); + + describe('displayFailures', () => { + const deployResultFailure = getDeployResult('failed'); + let tableStub: sinon.SinonStub; + + beforeEach(() => { + tableStub = sandbox.stub(ux, 'table'); + }); + + it('prints file responses, and messages from server', () => { + const formatter = new DeployResultFormatter(deployResultFailure, { verbose: true }); + formatter.display(); + expect(tableStub.callCount).to.equal(1); + expect(tableStub.firstCall.args[0]).to.deep.equal([ + { + error: 'This component has some problems', + fullName: 'ProductController', + loc: '27:18', + problemType: 'Error', + }, + ]); + }); + + it('displays errors from the server not in file responses', () => { + const deployFailure = getDeployResult('failed'); + const error1 = { + changed: false, + componentType: 'ApexClass', + created: false, + createdDate: '2021-04-27T22:18:07.000Z', + deleted: false, + fileName: 'classes/ProductController.cls', + fullName: 'ProductController', + success: false, + problemType: 'Error', + problem: 'This component has some problems', + lineNumber: '27', + columnNumber: '18', + } as DeployMessage; + + // add package.xml error, which is different from a FileResponse error + const error2 = { + changed: false, + componentType: '', + created: false, + createdDate: '2023-11-17T21:18:36.000Z', + deleted: false, + fileName: 'package.xml', + fullName: 'Create_property', + problem: + "An object 'Create_property' of type Flow was named in package.xml, but was not found in zipped directory", + problemType: 'Error', + success: false, + } as DeployMessage; + + deployFailure.response.details.componentFailures = [error1, error2]; + sandbox.stub(deployFailure, 'getFileResponses').returns([ + { + fullName: error1.fullName, + filePath: error1.fileName, + type: error1.componentType, + state: 'Failed', + lineNumber: error1.lineNumber, + columnNumber: error1.columnNumber, + error: error1.problem, + problemType: error1.problemType, + }, + ] as FileResponse[]); + const formatter = new DeployResultFormatter(deployFailure, { verbose: true }); + formatter.display(); + expect(tableStub.callCount).to.equal(1); + expect(tableStub.firstCall.args[0]).to.deep.equal([ + { + error: error2.problem, + fullName: error2.fullName, + loc: '', + problemType: error2.problemType, + }, + { + error: 'This component has some problems', + fullName: 'ProductController', + loc: '27:18', + problemType: 'Error', + }, + ]); + }); + }); + + describe('coverage functions', () => { + describe('getCoverageFormattersOptions', () => { + it('clover, json', () => { + const result = getCoverageFormattersOptions(['clover', 'json']); + expect(result).to.deep.equal({ + reportFormats: ['clover', 'json'], + reportOptions: { + clover: { file: path.join('coverage', 'clover.xml'), projectRoot: '.' }, + json: { file: path.join('coverage', 'coverage.json') }, + }, + }); + }); + + it('will warn when code coverage warning present from server', () => { + const deployResult = getDeployResult('codeCoverageWarning'); + const formatter = new DeployResultFormatter(deployResult, {}); + const warnStub = sandbox.stub(ux, 'warn'); + formatter.display(); + expect(warnStub.callCount).to.equal(1); + expect(warnStub.firstCall.args[0]).to.equal( + 'Average test coverage across all Apex Classes and Triggers is 25%, at least 75% test coverage is required.' + ); + }); + + it('will write test output when in json mode', async () => { + const deployResult = getDeployResult('passedTest'); + const formatter = new DeployResultFormatter(deployResult, { + junit: true, + 'coverage-formatters': ['text', 'cobertura'], + }); + // private method stub + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const coverageReportStub = sandbox.stub(formatter, 'createCoverageReport'); + // private method stub + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const junitStub = sandbox.stub(formatter, 'createJunitResults'); + await formatter.getJson(); + expect(coverageReportStub.calledOnce).to.equal(true); + expect(junitStub.calledOnce).to.equal(true); + }); + + it('teamcity', () => { + const result = getCoverageFormattersOptions(['teamcity']); + expect(result).to.deep.equal({ + reportFormats: ['teamcity'], + reportOptions: { + teamcity: { file: path.join('coverage', 'teamcity.txt'), blockName: 'coverage' }, + }, + }); + }); + }); + }); + + describe('replacements', () => { + const deployResultSuccess = getDeployResult('successSync'); + const deployResultSuccessWithReplacements = { + ...getDeployResult('successSync'), + replacements: new Map([['foo', ['bar', 'baz']]]), + } as DeployResult; + + describe('json', () => { + it('shows replacements when not concise', async () => { + const formatter = new DeployResultFormatter(deployResultSuccessWithReplacements, { verbose: true }); + const json = await formatter.getJson(); + assert('replacements' in json && json.replacements); + expect(json.replacements).to.deep.equal({ foo: ['bar', 'baz'] }); + }); + it('no replacements when concise', () => { + const formatter = new DeployResultFormatter(deployResultSuccessWithReplacements, { concise: true }); + const json = formatter.getJson(); + expect(json).to.not.have.property('replacements'); + }); + }); + describe('human', () => { + let uxStub: sinon.SinonStub; + beforeEach(() => { + uxStub = sandbox.stub(process.stdout, 'write'); + }); + + const getStdout = () => + uxStub + .getCalls() + // args are typed as any[] + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + .flatMap((call) => call.args) + .join('\n'); + + it('shows replacements when verbose and replacements exist', () => { + const formatter = new DeployResultFormatter(deployResultSuccessWithReplacements, { verbose: true }); + formatter.display(); + expect(getStdout()).to.include('Metadata Replacements'); + expect(getStdout()).to.include('TEXT REPLACED'); + }); + + it('no replacements when verbose but there are none', () => { + const formatter = new DeployResultFormatter(deployResultSuccess, { verbose: true }); + formatter.display(); + expect(getStdout()).to.not.include('Metadata Replacements'); + }); + it('no replacements when not verbose', () => { + const formatter = new DeployResultFormatter(deployResultSuccessWithReplacements, { verbose: false }); + formatter.display(); + expect(getStdout()).to.not.include('Metadata Replacements'); + }); + it('no replacements when concise', () => { + const formatter = new DeployResultFormatter(deployResultSuccessWithReplacements, { concise: true }); + formatter.display(); + expect(getStdout()).to.not.include('Metadata Replacements'); + }); + }); + }); +});