diff --git a/.gitignore b/.gitignore index 63922148..2687a239 100644 --- a/.gitignore +++ b/.gitignore @@ -96,3 +96,4 @@ jspm_packages/ # parcel-bundler cache (https://parceljs.org/) .cache +.idea/ diff --git a/package.json b/package.json index 7b08baae..307bf5b2 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@salesforce/apex-node", "description": "Salesforce JS library for Apex", - "version": "4.0.2", + "version": "4.0.6", "author": "Salesforce", "bugs": "https://github.com/forcedotcom/salesforcedx-apex/issues", "main": "lib/src/index.js", diff --git a/src/streaming/codeCoverageStringifyStream.ts b/src/streaming/codeCoverageStringifyStream.ts index e58f8bbf..abca5eac 100644 --- a/src/streaming/codeCoverageStringifyStream.ts +++ b/src/streaming/codeCoverageStringifyStream.ts @@ -65,10 +65,10 @@ export class CodeCoverageStringifyStream extends Transform { const { coverage, ...theRest } = perClassCoverage; // stringify all properties except coverage and strip off the closing '}' this.push(JSON.stringify(theRest).slice(0, -1)); - this.push(',"coverage": {'); - this.push('"coveredLines": ['); + this.push(',"coverage":{'); + this.push('"coveredLines":['); pushArrayToStream(coverage.coveredLines ?? [], this); - this.push('],"uncoveredLines": ['); + this.push('],"uncoveredLines":['); pushArrayToStream(coverage.uncoveredLines ?? [], this); this.push(']}}'); diff --git a/src/streaming/testResultStringifyStream.ts b/src/streaming/testResultStringifyStream.ts index f80c5f1a..0f05e67d 100644 --- a/src/streaming/testResultStringifyStream.ts +++ b/src/streaming/testResultStringifyStream.ts @@ -31,7 +31,7 @@ export class TestResultStringifyStream extends Readable { // outer curly this.push('{'); // summary - this.push(`"summary": ${JSON.stringify(summary)},`); + this.push(`"summary":${JSON.stringify(summary)},`); this.buildTests(); this.buildCodeCoverage(); @@ -47,19 +47,19 @@ export class TestResultStringifyStream extends Readable { const numberOfTests = this.testResult.tests.length - 1; this.testResult.tests.forEach((test, index) => { const { perClassCoverage, ...testRest } = test; - this.push(`${JSON.stringify(testRest).slice(0, -1)},`); + this.push(`${JSON.stringify(testRest).slice(0, -1)}`); if (perClassCoverage) { const numberOfPerClassCoverage = perClassCoverage.length - 1; - this.push('"perClassCoverage": ['); + this.push(',"perClassCoverage":['); perClassCoverage.forEach((pcc, index) => { const { coverage, ...coverageRest } = pcc; - this.push(`${JSON.stringify(coverageRest).slice(0, -1)},`); - this.push(`"coverage": ${JSON.stringify(coverage)}}`); + this.push(`${JSON.stringify(coverageRest).slice(0, -1)}`); + this.push(`,"coverage":${JSON.stringify(coverage)}}`); if (numberOfPerClassCoverage !== index) { this.push(','); } }); - this.push('],'); + this.push(']'); } // close the tests this.push('}'); @@ -68,18 +68,18 @@ export class TestResultStringifyStream extends Readable { } }); - this.push('],'); + this.push(']'); } @elapsedTime() buildCodeCoverage(): void { if (this.testResult.codecoverage) { - this.push('"codecoverage":['); + this.push(',"codecoverage":['); const numberOfCodeCoverage = this.testResult.codecoverage.length - 1; this.testResult.codecoverage.forEach((coverage, index) => { const { coveredLines, uncoveredLines, ...theRest } = coverage; - this.push(`${JSON.stringify(theRest).slice(0, -1)},`); - this.push('"coveredLines":['); + this.push(`${JSON.stringify(theRest).slice(0, -1)}`); + this.push(',"coveredLines":['); pushArrayToStream(coveredLines, this); this.push('],"uncoveredLines":['); pushArrayToStream(uncoveredLines, this); diff --git a/src/tests/testService.ts b/src/tests/testService.ts index 02227613..72541562 100644 --- a/src/tests/testService.ts +++ b/src/tests/testService.ts @@ -39,7 +39,7 @@ import { isTestResult, isValidApexClassID } from '../narrowing'; export class TestService { private readonly connection: Connection; - private readonly asyncService: AsyncTests; + public readonly asyncService: AsyncTests; private readonly syncService: SyncTests; constructor(connection: Connection) { diff --git a/test/streaming/codeCoverageStringifyStream.test.ts b/test/streaming/codeCoverageStringifyStream.test.ts new file mode 100644 index 00000000..bc3dd146 --- /dev/null +++ b/test/streaming/codeCoverageStringifyStream.test.ts @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2024, 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 { expect } from 'chai'; +import { CodeCoverageStringifyStream } from '../../src/streaming/codeCoverageStringifyStream'; +import { PerClassCoverage } from '../../src/tests'; + +describe('CodeCoverageStringifyStream', () => { + let stream: CodeCoverageStringifyStream; + let data: PerClassCoverage[]; + + beforeEach(() => { + data = [ + { + apexClassOrTriggerName: 'TestClass3', + apexClassOrTriggerId: '01p3h00000KoP4UAAV', + apexTestClassId: '01p3h00000KoP4VAAV', + apexTestMethodName: 'testMethod3', + numLinesCovered: 12, + numLinesUncovered: 3, + percentage: '80.00', + coverage: { + coveredLines: [26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37], + uncoveredLines: [38, 39, 40] + } + }, + { + apexClassOrTriggerName: 'TestClass4', + apexClassOrTriggerId: '01p3h00000KoP4WAAV', + apexTestClassId: '01p3h00000KoP4XAAV', + apexTestMethodName: 'testMethod4', + numLinesCovered: 15, + numLinesUncovered: 0, + percentage: '100.00', + coverage: { + coveredLines: [ + 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55 + ], + uncoveredLines: [] + } + } + ]; + stream = new CodeCoverageStringifyStream(); + }); + + it('should transform data correctly', (done) => { + const expectedOutput = JSON.stringify([data]); + + let output = ''; + stream.on('data', (chunk: string) => { + output += chunk; + }); + + stream.on('end', () => { + expect(output).to.equal(expectedOutput); + done(); + }); + + stream.write(data); + stream.end(); + }); + + it('should handle empty data', (done) => { + const expectedOutput = '[[]]'; + + let output = ''; + stream.on('data', (chunk: string) => { + output += chunk; + }); + + stream.on('end', () => { + expect(output).to.equal(expectedOutput); + done(); + }); + + stream.write([]); + stream.end(); + }); +}); diff --git a/test/streaming/jsonStringifyStream.test.ts b/test/streaming/jsonStringifyStream.test.ts index 14201f2e..fbdf1b8c 100644 --- a/test/streaming/jsonStringifyStream.test.ts +++ b/test/streaming/jsonStringifyStream.test.ts @@ -28,6 +28,7 @@ describe('JSONStringifyStream', () => { stream.on('end', () => { expect(result).to.equal(JSON.stringify(json)); + expect(() => JSON.parse(result)).to.not.throw(); done(); }); }); @@ -43,15 +44,18 @@ describe('JSONStringifyStream', () => { stream.on('end', () => { expect(result).to.equal(JSON.stringify(json)); + expect(() => JSON.parse(result)).to.not.throw(); done(); }); }); - it('should handle complex objects ending with a key/array', (done) => { + it('should handle complex objects and arrays without dangling commas', (done) => { const json = { key1: 'value1', - key2: { key3: 'value3' }, - key4: ['value4', 'value5'] + key2: { key3: 'value3', key5: ['value6', ['value7', 'value8', null]] }, + key4: ['value4', 'value5', null], + // @ts-ignore + key6: { key7: 'value7', key8: null } }; const stream = JSONStringifyStream.from(json); @@ -62,6 +66,7 @@ describe('JSONStringifyStream', () => { stream.on('end', () => { expect(result).to.equal(JSON.stringify(json)); + expect(() => JSON.parse(result)).to.not.throw(); done(); }); }); @@ -81,6 +86,7 @@ describe('JSONStringifyStream', () => { stream.on('end', () => { expect(result).to.equal(JSON.stringify(json)); + expect(() => JSON.parse(result)).to.not.throw(); done(); }); }); @@ -96,6 +102,7 @@ describe('JSONStringifyStream', () => { stream.on('end', () => { expect(result).to.equal(JSON.stringify(value)); + expect(() => JSON.parse(result)).to.not.throw(); done(); }); }); @@ -111,6 +118,7 @@ describe('JSONStringifyStream', () => { stream.on('end', () => { expect(result).to.equal(JSON.stringify(value)); + expect(() => JSON.parse(result)).to.not.throw(); done(); }); }); @@ -126,6 +134,7 @@ describe('JSONStringifyStream', () => { stream.on('end', () => { expect(result).to.equal(JSON.stringify(value)); + expect(() => JSON.parse(result)).to.not.throw(); done(); }); }); @@ -141,6 +150,7 @@ describe('JSONStringifyStream', () => { stream.on('end', () => { expect(result).to.equal(JSON.stringify(value)); + expect(() => JSON.parse(result)).to.not.throw(); done(); }); }); diff --git a/test/streaming/testResultStringifyStream.test.ts b/test/streaming/testResultStringifyStream.test.ts new file mode 100644 index 00000000..e5bd1416 --- /dev/null +++ b/test/streaming/testResultStringifyStream.test.ts @@ -0,0 +1,209 @@ +/* + * Copyright (c) 2021, 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 { expect } from 'chai'; +import { ApexTestResultOutcome, TestResult } from '../../src'; +import { TestResultStringifyStream } from '../../src/streaming'; +import { CodeCoverageResult, PerClassCoverage } from '../../src/tests'; + +const tests = [ + { + id: 'testId1', + queueItemId: 'queueItemId1', + stackTrace: null, + message: null, + asyncApexJobId: 'asyncApexJobId1', + methodName: 'testMethod1', + outcome: ApexTestResultOutcome.Pass, + apexLogId: null, + apexClass: { + id: 'classId1', + name: 'TestClass1', + namespacePrefix: 'ns1', + fullName: 'ns1.TestClass1' + }, + runTime: 1.23, + testTimestamp: '2023-01-01T00:00:00Z', + fullName: 'ns1.TestClass1.testMethod1', + perClassCoverage: [] as PerClassCoverage[] // Add PerClassCoverage objects here if needed + }, + { + id: 'testId2', + queueItemId: 'queueItemId2', + stackTrace: 'Exception in thread "main" java.lang.NullPointerException', + message: 'Null pointer exception', + asyncApexJobId: 'asyncApexJobId2', + methodName: 'testMethod2', + outcome: ApexTestResultOutcome.Fail, + apexLogId: 'logId2', + apexClass: { + id: 'classId2', + name: 'TestClass2', + namespacePrefix: 'ns2', + fullName: 'ns2.TestClass2' + }, + runTime: 2.34, + testTimestamp: '2023-01-02T00:00:00Z', + fullName: 'ns2.TestClass2.testMethod2', + perClassCoverage: [] as PerClassCoverage[] // Add PerClassCoverage objects here if needed + } +]; +const perClassCoverageData: PerClassCoverage[] = [ + { + apexClassOrTriggerName: tests[0].apexClass.name, + apexClassOrTriggerId: tests[0].apexClass.id, + apexTestClassId: tests[0].id, + apexTestMethodName: tests[0].methodName, + numLinesCovered: 80, + numLinesUncovered: 20, + percentage: '80%', + coverage: { + coveredLines: [1, 2, 3, 4, 5, 6, 7, 8], + uncoveredLines: [9, 10] + } + }, + { + apexClassOrTriggerName: tests[1].apexClass.name, + apexClassOrTriggerId: tests[1].apexClass.id, + apexTestClassId: tests[1].id, + apexTestMethodName: tests[1].methodName, + numLinesCovered: 60, + numLinesUncovered: 40, + percentage: '60%', + coverage: { + coveredLines: [1, 2, 3, 4, 5, 6], + uncoveredLines: [7, 8, 9, 10] + } + } +]; +const coverageData: CodeCoverageResult[] = [ + { + apexId: 'apexId1', + name: 'ApexClass1', + type: 'ApexClass', + numLinesCovered: 80, + numLinesUncovered: 20, + percentage: '80%', + coveredLines: [1, 2, 3, 4, 5, 6, 7, 8], + uncoveredLines: [9, 10] + }, + { + apexId: 'apexId2', + name: 'ApexTrigger1', + type: 'ApexTrigger', + numLinesCovered: 60, + numLinesUncovered: 40, + percentage: '60%', + coveredLines: [1, 2, 3, 4, 5, 6], + uncoveredLines: [7, 8, 9, 10] + } +]; +describe('TestResultStringifyStream', () => { + let testResult: TestResult; + let stream: TestResultStringifyStream; + + beforeEach(() => { + // Initialize testResult with some default values + testResult = { + summary: { + failRate: '0%', + testsRan: 1, + orgId: '00Dxx0000001gPL', + outcome: 'Passed', + passing: 1, + failing: 0, + skipped: 0, + passRate: '100%', + skipRate: '0%', + testStartTime: '1641340181000', + testExecutionTimeInMs: 1, + testTotalTimeInMs: 1, + commandTimeInMs: 1, + hostname: 'test.salesforce.com', + username: 'test-user@test.com', + testRunId: '707xx0000BfHFQA', + userId: '005xx000001Swi2', + testRunCoverage: '100%', + orgWideCoverage: '80%', + totalLines: 100, + coveredLines: 80 + }, + tests: [], + codecoverage: [] + }; + }); + + it('should transform TestResult into a JSON string with empty tests and no coverage', (done) => { + let output = ''; + const emptyTestsNoCoverage = structuredClone(testResult); + delete emptyTestsNoCoverage.codecoverage; + // Initialize the stream with the testResult + stream = new TestResultStringifyStream(emptyTestsNoCoverage); + + stream.on('data', (chunk: string) => { + output += chunk; + }); + + stream.on('end', () => { + expect(() => JSON.parse(output)).to.not.throw(); + const expectedOutput = JSON.stringify(emptyTestsNoCoverage); + expect(output).to.equal(expectedOutput); + done(); + }); + + stream._read(); + }); + it('should transform TestResult into a JSON string with tests and no coverage', (done) => { + let output = ''; + const testsWithoutCoverage = structuredClone(tests); + const resultsWithTests = { + ...testResult, + tests: testsWithoutCoverage + }; + delete resultsWithTests.codecoverage; + // Initialize the stream with the testResult + stream = new TestResultStringifyStream(resultsWithTests); + + stream.on('data', (chunk: string) => { + output += chunk; + }); + + stream.on('end', () => { + expect(() => JSON.parse(output)).to.not.throw(); + const expectedOutput = JSON.stringify(resultsWithTests); + expect(output).to.equal(expectedOutput); + done(); + }); + + stream._read(); + }); + it('should transform TestResult into a JSON string with tests and coverage both present', (done) => { + let output = ''; + const testsWithCoverage = structuredClone(tests); + testsWithCoverage[0].perClassCoverage = [perClassCoverageData[0]]; + testsWithCoverage[1].perClassCoverage = perClassCoverageData; + const resultsWithTests = { + ...testResult, + tests: testsWithCoverage, + codecoverage: coverageData + }; + // Initialize the stream with the testResult + stream = new TestResultStringifyStream(resultsWithTests); + + stream.on('data', (chunk: string) => { + output += chunk; + }); + + stream.on('end', () => { + expect(() => JSON.parse(output)).to.not.throw(); + const expectedOutput = JSON.stringify(resultsWithTests); + expect(output).to.equal(expectedOutput); + done(); + }); + + stream._read(); + }); +}); diff --git a/yarn.lock b/yarn.lock index 19a3e808..45eb300e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -756,12 +756,7 @@ resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.1.tgz#d3357479a0fdfdd5907fe67e17e0a85c906e1301" integrity sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw== -"@types/semver@^7.5.0": - version "7.5.7" - resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.5.7.tgz#326f5fdda70d13580777bcaa1bc6fa772a5aef0e" - integrity sha512-/wdoPq1QqkSj9/QOeKkFquEuPzQbHTWAMPH/PaUMB+JuR31lXhlWXRZ52IpfDYVlDOUBvX09uBrPwxGT1hjNBg== - -"@types/semver@^7.5.8": +"@types/semver@^7.5.0", "@types/semver@^7.5.8": version "7.5.8" resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.5.8.tgz#8268a8c57a3e4abd25c165ecd36237db7948a55e" integrity sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ== @@ -4273,7 +4268,7 @@ secure-json-parse@^2.4.0: resolved "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz" integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ== -semver@7.5.4, semver@^7.3.4, semver@^7.5.3, semver@^7.5.4: +semver@7.5.4: version "7.5.4" resolved "https://registry.yarnpkg.com/semver/-/semver-7.5.4.tgz#483986ec4ed38e1c6c48c34894a9182dbff68a6e" integrity sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA== @@ -4285,7 +4280,7 @@ semver@^6.0.0, semver@^6.3.0, semver@^6.3.1: resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== -semver@^7.6.0: +semver@^7.3.4, semver@^7.5.3, semver@^7.5.4, semver@^7.6.0: version "7.6.0" resolved "https://registry.yarnpkg.com/semver/-/semver-7.6.0.tgz#1a46a4db4bffcccd97b743b5005c8325f23d4e2d" integrity sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==