diff --git a/package-lock.json b/package-lock.json index 5c72b876..156cb255 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17311,7 +17311,6 @@ }, "node_modules/xmlbuilder": { "version": "15.1.1", - "devOptional": true, "license": "MIT", "engines": { "node": ">=8.0" @@ -17601,7 +17600,7 @@ }, "packages/bruno-cli": { "name": "@usebruno/cli", - "version": "1.2.1", + "version": "1.2.2", "license": "MIT", "dependencies": { "@usebruno/js": "0.9.4", @@ -17620,6 +17619,7 @@ "mustache": "^4.2.0", "qs": "^6.11.0", "socks-proxy-agent": "^8.0.2", + "xmlbuilder": "^15.1.1", "yargs": "^17.6.2" }, "bin": { @@ -21840,6 +21840,7 @@ "mustache": "^4.2.0", "qs": "^6.11.0", "socks-proxy-agent": "^8.0.2", + "xmlbuilder": "^15.1.1", "yargs": "^17.6.2" }, "dependencies": { @@ -30144,8 +30145,7 @@ } }, "xmlbuilder": { - "version": "15.1.1", - "devOptional": true + "version": "15.1.1" }, "xtend": { "version": "4.0.2" diff --git a/packages/bruno-cli/package.json b/packages/bruno-cli/package.json index 41b586a5..c19c8c8a 100644 --- a/packages/bruno-cli/package.json +++ b/packages/bruno-cli/package.json @@ -1,6 +1,6 @@ { "name": "@usebruno/cli", - "version": "1.2.1", + "version": "1.2.2", "license": "MIT", "main": "src/index.js", "bin": { @@ -40,6 +40,7 @@ "mustache": "^4.2.0", "qs": "^6.11.0", "socks-proxy-agent": "^8.0.2", + "xmlbuilder": "^15.1.1", "yargs": "^17.6.2" } } diff --git a/packages/bruno-cli/src/commands/run.js b/packages/bruno-cli/src/commands/run.js index d1eea59a..2151ff56 100644 --- a/packages/bruno-cli/src/commands/run.js +++ b/packages/bruno-cli/src/commands/run.js @@ -5,6 +5,7 @@ const { forOwn } = require('lodash'); const { exists, isFile, isDirectory } = require('../utils/filesystem'); const { runSingleRequest } = require('../runner/run-single-request'); const { bruToEnvJson, getEnvVars } = require('../utils/bru'); +const makeJUnitOutput = require('../reporters/junit'); const { rpad } = require('../utils/common'); const { bruToJson, getOptions, collectionBruToJson } = require('../utils/bru'); const { dotenvToJson } = require('@usebruno/lang'); @@ -186,7 +187,13 @@ const builder = async (yargs) => { }) .option('output', { alias: 'o', - describe: 'Path to write JSON results to', + describe: 'Path to write file results to', + type: 'string' + }) + .option('format', { + alias: 'f', + describe: 'Format for the file results', + default: 'json', type: 'string' }) .option('insecure', { @@ -204,12 +211,16 @@ const builder = async (yargs) => { .example( '$0 run request.bru --output results.json', 'Run a request and write the results to results.json in the current directory' + ) + .example( + '$0 run request.bru --output results.xml --format junit', + 'Run a request and write the results to results.xml in junit format in the current directory' ); }; const handler = async function (argv) { try { - let { filename, cacert, env, envVar, insecure, r: recursive, output: outputPath } = argv; + let { filename, cacert, env, envVar, insecure, r: recursive, output: outputPath, format } = argv; const collectionPath = process.cwd(); // todo @@ -297,6 +308,11 @@ const handler = async function (argv) { } } + if (['json', 'junit'].indexOf(format) === -1) { + console.error(chalk.red(`Format must be one of "json" or "junit"`)); + return; + } + // load .env file at root of collection if it exists const dotEnvPath = path.join(collectionPath, '.env'); const dotEnvExists = await exists(dotEnvPath); @@ -360,6 +376,8 @@ const handler = async function (argv) { while (currentRequestIndex < bruJsons.length) { const iter = bruJsons[currentRequestIndex]; const { bruFilepath, bruJson } = iter; + + const start = process.hrtime(); const result = await runSingleRequest( bruFilepath, bruJson, @@ -371,7 +389,11 @@ const handler = async function (argv) { collectionRoot ); - results.push(result); + results.push({ + ...result, + runtime: process.hrtime(start)[0] + process.hrtime(start)[1] / 1e9, + suitename: bruFilepath.replace('.bru', '') + }); // determine next request const nextRequestName = result?.nextRequestName; @@ -413,7 +435,12 @@ const handler = async function (argv) { results }; - fs.writeFileSync(outputPath, JSON.stringify(outputJson, null, 2)); + if (format === 'json') { + fs.writeFileSync(outputPath, JSON.stringify(outputJson, null, 2)); + } else if (format === 'junit') { + makeJUnitOutput(results, outputPath); + } + console.log(chalk.dim(chalk.grey(`Wrote results to ${outputPath}`))); } diff --git a/packages/bruno-cli/src/reporters/junit.js b/packages/bruno-cli/src/reporters/junit.js new file mode 100644 index 00000000..30fb5193 --- /dev/null +++ b/packages/bruno-cli/src/reporters/junit.js @@ -0,0 +1,85 @@ +const os = require('os'); +const fs = require('fs'); +const xmlbuilder = require('xmlbuilder'); + +const makeJUnitOutput = async (results, outputPath) => { + const output = { + testsuites: { + testsuite: [] + } + }; + + results.forEach((result) => { + const assertionTestCount = result.assertionResults ? result.assertionResults.length : 0; + const testCount = result.testResults ? result.testResults.length : 0; + const totalTests = assertionTestCount + testCount; + + const suite = { + '@name': result.suitename, + '@errors': 0, + '@failures': 0, + '@skipped': 0, + '@tests': totalTests, + '@timestamp': new Date().toISOString().split('Z')[0], + '@hostname': os.hostname(), + '@time': result.runtime.toFixed(3), + testcase: [] + }; + + result.assertionResults && + result.assertionResults.forEach((assertion) => { + const testcase = { + '@name': `${assertion.lhsExpr} ${assertion.rhsExpr}`, + '@status': assertion.status, + '@classname': result.request.url, + '@time': (result.runtime / totalTests).toFixed(3) + }; + + if (assertion.status === 'fail') { + suite['@failures']++; + + testcase.failure = [{ '@type': 'failure', '@message': assertion.error }]; + } + + suite.testcase.push(testcase); + }); + + result.testResults && + result.testResults.forEach((test) => { + const testcase = { + '@name': test.description, + '@status': test.status, + '@classname': result.request.url, + '@time': (result.runtime / totalTests).toFixed(3) + }; + + if (test.status === 'fail') { + suite['@failures']++; + + testcase.failure = [{ '@type': 'failure', '@message': test.error }]; + } + + suite.testcase.push(testcase); + }); + + if (result.error) { + suite['@errors'] = 1; + suite['@tests'] = 1; + suite.testcase = [ + { + '@name': 'Test suite has no errors', + '@status': 'fail', + '@classname': result.request.url, + '@time': result.runtime.toFixed(3), + error: [{ '@type': 'error', '@message': result.error }] + } + ]; + } + + output.testsuites.testsuite.push(suite); + }); + + fs.writeFileSync(outputPath, xmlbuilder.create(output).end({ pretty: true })); +}; + +module.exports = makeJUnitOutput; diff --git a/packages/bruno-cli/tests/reporters/junit.spec.js b/packages/bruno-cli/tests/reporters/junit.spec.js new file mode 100644 index 00000000..5d2154a8 --- /dev/null +++ b/packages/bruno-cli/tests/reporters/junit.spec.js @@ -0,0 +1,135 @@ +const { describe, it, expect } = require('@jest/globals'); +const xmlbuilder = require('xmlbuilder'); +const fs = require('fs'); + +const makeJUnitOutput = require('../../src/reporters/junit'); + +describe('makeJUnitOutput', () => { + let createStub = jest.fn(); + + beforeEach(() => { + jest.spyOn(xmlbuilder, 'create').mockImplementation(() => { + return { end: createStub }; + }); + jest.spyOn(fs, 'writeFileSync').mockImplementation(() => {}); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should produce a junit spec object for serialization', () => { + const results = [ + { + description: 'description provided', + suitename: 'Tests/Suite A', + request: { + method: 'GET', + url: 'https://ima.test' + }, + assertionResults: [ + { + lhsExpr: 'res.status', + rhsExpr: 'eq 200', + status: 'pass' + }, + { + lhsExpr: 'res.status', + rhsExpr: 'neq 200', + status: 'fail', + error: 'expected 200 to not equal 200' + } + ], + runtime: 1.2345678 + }, + { + request: { + method: 'GET', + url: 'https://imanother.test' + }, + suitename: 'Tests/Suite B', + testResults: [ + { + lhsExpr: 'res.status', + rhsExpr: 'eq 200', + description: 'A test that passes', + status: 'pass' + }, + { + description: 'A test that fails', + status: 'fail', + error: 'expected 200 to not equal 200', + status: 'fail' + } + ], + runtime: 2.3456789 + } + ]; + + makeJUnitOutput(results, '/tmp/testfile.xml'); + expect(createStub).toBeCalled; + + const junit = xmlbuilder.create.mock.calls[0][0]; + + expect(junit.testsuites).toBeDefined; + expect(junit.testsuites.testsuite.length).toBe(2); + expect(junit.testsuites.testsuite[0].testcase.length).toBe(2); + expect(junit.testsuites.testsuite[1].testcase.length).toBe(2); + + expect(junit.testsuites.testsuite[0]['@name']).toBe('Tests/Suite A'); + expect(junit.testsuites.testsuite[1]['@name']).toBe('Tests/Suite B'); + + expect(junit.testsuites.testsuite[0]['@tests']).toBe(2); + expect(junit.testsuites.testsuite[1]['@tests']).toBe(2); + + const testcase = junit.testsuites.testsuite[0].testcase[0]; + + expect(testcase['@name']).toBe('res.status eq 200'); + expect(testcase['@status']).toBe('pass'); + + const failcase = junit.testsuites.testsuite[0].testcase[1]; + + expect(failcase['@name']).toBe('res.status neq 200'); + expect(failcase.failure).toBeDefined; + expect(failcase.failure[0]['@type']).toBe('failure'); + }); + + it('should handle request errors', () => { + const results = [ + { + description: 'description provided', + suitename: 'Tests/Suite A', + request: { + method: 'GET', + url: 'https://ima.test' + }, + assertionResults: [ + { + lhsExpr: 'res.status', + rhsExpr: 'eq 200', + status: 'fail' + } + ], + runtime: 1.2345678, + error: 'timeout of 2000ms exceeded' + } + ]; + + makeJUnitOutput(results, '/tmp/testfile.xml'); + + const junit = xmlbuilder.create.mock.calls[0][0]; + + expect(createStub).toBeCalled; + + expect(junit.testsuites).toBeDefined; + expect(junit.testsuites.testsuite.length).toBe(1); + expect(junit.testsuites.testsuite[0].testcase.length).toBe(1); + + const failcase = junit.testsuites.testsuite[0].testcase[0]; + + expect(failcase['@name']).toBe('Test suite has no errors'); + expect(failcase.error).toBeDefined; + expect(failcase.error[0]['@type']).toBe('error'); + expect(failcase.error[0]['@message']).toBe('timeout of 2000ms exceeded'); + }); +});