feat(cli): add support for JUnit reporter (#4189)

This commit is contained in:
James George
2024-07-26 10:26:58 -07:00
committed by GitHub
parent ecf0901491
commit 5f96cda5e2
15 changed files with 1200 additions and 67 deletions

View File

@@ -27,6 +27,7 @@ import {
} from "./display";
import { exceptionColors } from "./getters";
import { getPreRequestMetrics } from "./pre-request";
import { buildJUnitReport, generateJUnitReportExport } from "./reporters/junit";
import {
getRequestMetrics,
preProcessRequest,
@@ -56,19 +57,22 @@ export const collectionsRunner = async (
// Pop out top-most collection from stack to be processed.
const { collection, path } = <CollectionStack>collectionStack.pop();
// Processing each request in collection
for (const request of collection.requests) {
const _request = preProcessRequest(request as HoppRESTRequest, collection);
const requestPath = `${path}/${_request.name}`;
const processRequestParams: ProcessRequestParams = {
path: requestPath,
request: _request,
envs,
delay,
};
// Processing each request in collection
for (const request of collection.requests) {
const _request = preProcessRequest(
request as HoppRESTRequest,
collection
);
const requestPath = `${path}/${_request.name}`;
const processRequestParams: ProcessRequestParams = {
path: requestPath,
request: _request,
envs,
delay,
};
// Request processing initiated message.
log(WARN(`\nRunning: ${chalk.bold(requestPath)}`));
// Request processing initiated message.
log(WARN(`\nRunning: ${chalk.bold(requestPath)}`));
// Processing current request.
const result = await processRequest(processRequestParams)();
@@ -78,35 +82,40 @@ export const collectionsRunner = async (
envs.global = global;
envs.selected = selected;
// Storing current request's report.
const requestReport = result.report;
requestsReport.push(requestReport);
}
// Pushing remaining folders realted collection to stack.
for (const folder of collection.folders) {
const updatedFolder: HoppCollection = { ...folder }
if (updatedFolder.auth?.authType === "inherit") {
updatedFolder.auth = collection.auth;
}
if (collection.headers?.length) {
// Filter out header entries present in the parent collection under the same name
// This ensures the folder headers take precedence over the collection headers
const filteredHeaders = collection.headers.filter((collectionHeaderEntries) => {
return !updatedFolder.headers.some((folderHeaderEntries) => folderHeaderEntries.key === collectionHeaderEntries.key)
})
updatedFolder.headers.push(...filteredHeaders);
}
collectionStack.push({
path: `${path}/${updatedFolder.name}`,
collection: updatedFolder,
});
}
// Storing current request's report.
const requestReport = result.report;
requestsReport.push(requestReport);
}
// Pushing remaining folders realted collection to stack.
for (const folder of collection.folders) {
const updatedFolder: HoppCollection = { ...folder };
if (updatedFolder.auth?.authType === "inherit") {
updatedFolder.auth = collection.auth;
}
if (collection.headers?.length) {
// Filter out header entries present in the parent collection under the same name
// This ensures the folder headers take precedence over the collection headers
const filteredHeaders = collection.headers.filter(
(collectionHeaderEntries) => {
return !updatedFolder.headers.some(
(folderHeaderEntries) =>
folderHeaderEntries.key === collectionHeaderEntries.key
);
}
);
updatedFolder.headers.push(...filteredHeaders);
}
collectionStack.push({
path: `${path}/${updatedFolder.name}`,
collection: updatedFolder,
});
}
}
return requestsReport;
};
@@ -134,7 +143,8 @@ const getCollectionStack = (collections: HoppCollection[]): CollectionStack[] =>
* False, if errors occurred or test-cases failed.
*/
export const collectionsRunnerResult = (
requestsReport: RequestReport[]
requestsReport: RequestReport[],
reporterJUnitExportPath?: string
): boolean => {
const overallTestMetrics = <TestMetrics>{
tests: { failed: 0, passed: 0 },
@@ -152,6 +162,9 @@ export const collectionsRunnerResult = (
};
let finalResult = true;
let totalErroredTestCases = 0;
let totalFailedTestCases = 0;
// Printing requests-report details of failed-tests and errors
for (const requestReport of requestsReport) {
const { path, tests, errors, result, duration } = requestReport;
@@ -165,6 +178,19 @@ export const collectionsRunnerResult = (
printErrorsReport(path, errors);
if (reporterJUnitExportPath) {
const { failedRequestTestCases, erroredRequestTestCases } =
buildJUnitReport({
path,
tests,
errors,
duration: duration.test,
});
totalFailedTestCases += failedRequestTestCases;
totalErroredTestCases += erroredRequestTestCases;
}
/**
* Extracting current request report's test-metrics and updating
* overall test-metrics.
@@ -216,6 +242,19 @@ export const collectionsRunnerResult = (
printRequestsMetrics(overallRequestMetrics);
printPreRequestMetrics(overallPreRequestMetrics);
if (reporterJUnitExportPath) {
const totalTestCases =
overallTestMetrics.tests.failed + overallTestMetrics.tests.passed;
generateJUnitReportExport({
totalTestCases,
totalFailedTestCases,
totalErroredTestCases,
testDuration: overallTestMetrics.duration,
reporterJUnitExportPath,
});
}
return finalResult;
};

View File

@@ -47,7 +47,10 @@ export const preRequestScriptRunner = (
),
TE.map(
({ selected, global }) =>
<Environment>{ name: "Env", variables: [...selected, ...global] }
<Environment>{
name: "Env",
variables: [...(selected ?? []), ...(global ?? [])],
}
),
TE.chainEitherKW((env) => getEffectiveRESTRequest(request, env)),
TE.mapLeft((reason) =>

View File

@@ -0,0 +1,178 @@
import { info, log } from "console";
import fs from "fs";
import path from "path";
import { create } from "xmlbuilder2";
import { XMLBuilder } from "xmlbuilder2/lib/interfaces";
import { TestReport } from "../../interfaces/response";
import { error, HoppCLIError } from "../../types/errors";
import { RequestReport } from "../../types/request";
import { exceptionColors } from "../getters";
type BuildJUnitReportArgs = Omit<RequestReport, "result" | "duration"> & {
duration: RequestReport["duration"]["test"];
};
type BuildJUnitReportResult = {
failedRequestTestCases: number;
erroredRequestTestCases: number;
};
type GenerateJUnitReportExportArgs = {
totalTestCases: number;
totalFailedTestCases: number;
totalErroredTestCases: number;
testDuration: number;
reporterJUnitExportPath: string;
};
const { INFO, SUCCESS } = exceptionColors;
// Create the root XML element
const rootEl = create({ version: "1.0", encoding: "UTF-8" }).ele("testsuites");
/**
* Builds a JUnit report based on the provided request report.
* Creates a test suite at the request level populating the XML document structure.
*
* @param {BuildJUnitReportArgs} options - The options to build the JUnit report.
* @param {string} options.path - The path of the request.
* @param {TestReport[]} options.tests - The test suites for the request.
* @param {HoppCLIError[]} options.errors - The errors encountered during the request.
* @param {number} options.duration - Time taken to execute the test suite.
* @returns {BuildJUnitReportResult} An object containing the number of failed and errored test cases.
*/
export const buildJUnitReport = ({
path,
tests: testSuites,
errors: requestTestSuiteErrors,
duration: testSuiteDuration,
}: BuildJUnitReportArgs): BuildJUnitReportResult => {
let requestTestSuiteError: XMLBuilder | null = null;
// Create a test suite at the request level
const requestTestSuite = rootEl.ele("testsuite", {
name: path,
time: testSuiteDuration,
timestamp: new Date().toISOString(),
});
if (requestTestSuiteErrors.length > 0) {
requestTestSuiteError = requestTestSuite.ele("system-err");
}
let systemErrContent = "";
requestTestSuiteErrors.forEach((error) => {
let compiledError = error.code;
if ("data" in error) {
compiledError += ` - ${error.data}`;
}
// Append each error message with a newline for separation
systemErrContent += `\n${" ".repeat(6)}${compiledError}`;
});
// There'll be a single `CDATA` element compiling all the error messages
if (requestTestSuiteError) {
requestTestSuiteError.dat(systemErrContent);
}
let requestTestCases = 0;
let erroredRequestTestCases = 0;
let failedRequestTestCases = 0;
// Test suites correspond to `pw.test()` invocations
testSuites.forEach(({ descriptor, expectResults }) => {
requestTestCases += expectResults.length;
expectResults.forEach(({ status, message }) => {
const testCase = requestTestSuite
.ele("testcase", {
name: `${descriptor} - ${message}`,
})
.att("classname", path);
if (status === "fail") {
failedRequestTestCases += 1;
testCase
.ele("failure")
.att("type", "AssertionFailure")
.att("message", message);
} else if (status === "error") {
erroredRequestTestCases += 1;
testCase.ele("error").att("message", message);
}
});
});
requestTestSuite.att("tests", requestTestCases.toString());
requestTestSuite.att("failures", failedRequestTestCases.toString());
requestTestSuite.att("errors", erroredRequestTestCases.toString());
return {
failedRequestTestCases,
erroredRequestTestCases,
};
};
/**
* Generates the built JUnit report export at the specified path.
*
* @param {GenerateJUnitReportExportArgs} options - The options to generate the JUnit report export.
* @param {number} options.totalTestCases - The total number of test cases.
* @param {number} options.totalFailedTestCases - The total number of failed test cases.
* @param {number} options.totalErroredTestCases - The total number of errored test cases.
* @param {number} options.testDuration - The total duration of test cases.
* @param {string} options.reporterJUnitExportPath - The path to export the JUnit report.
* @returns {void}
*/
export const generateJUnitReportExport = ({
totalTestCases,
totalFailedTestCases,
totalErroredTestCases,
testDuration,
reporterJUnitExportPath,
}: GenerateJUnitReportExportArgs) => {
rootEl
.att("tests", totalTestCases.toString())
.att("failures", totalFailedTestCases.toString())
.att("errors", totalErroredTestCases.toString())
.att("time", testDuration.toString());
// Convert the XML structure to a string
const xmlDocString = rootEl.end({ prettyPrint: true });
// Write the XML string to the specified path
try {
const resolvedExportPath = path.resolve(reporterJUnitExportPath);
if (fs.existsSync(resolvedExportPath)) {
info(
INFO(`\nOverwriting the pre-existing path: ${reporterJUnitExportPath}.`)
);
}
fs.mkdirSync(path.dirname(resolvedExportPath), {
recursive: true,
});
fs.writeFileSync(resolvedExportPath, xmlDocString);
log(
SUCCESS(
`\nSuccessfully exported the JUnit report to: ${reporterJUnitExportPath}.`
)
);
} catch (err) {
const data = err instanceof Error ? err.message : null;
throw error({
code: "REPORT_EXPORT_FAILED",
data,
path: reporterJUnitExportPath,
});
}
};

View File

@@ -52,10 +52,11 @@ const processVariables = (variable: Environment["variables"][number]) => {
* @param envs Global + selected envs used by requests with in collection
* @returns Processed envs with each variable processed
*/
const processEnvs = (envs: HoppEnvs) => {
const processEnvs = (envs: Partial<HoppEnvs>) => {
// This can take the shape `{ global: undefined, selected: undefined }` when no environment is supplied
const processedEnvs = {
global: envs.global.map(processVariables),
selected: envs.selected.map(processVariables),
global: envs.global?.map(processVariables),
selected: envs.selected?.map(processVariables),
};
return processedEnvs;
@@ -270,7 +271,7 @@ export const processRequest =
// Updating report for errors & current result
report.errors.push(preRequestRes.left);
report.result = report.result && false;
report.result = report.result;
} else {
// Updating effective-request and consuming updated envs after pre-request script execution
({ effectiveRequest, updatedEnvs } = preRequestRes.right);
@@ -298,7 +299,7 @@ export const processRequest =
if (E.isLeft(requestRunnerRes)) {
// Updating report for errors & current result
report.errors.push(requestRunnerRes.left);
report.result = report.result && false;
report.result = report.result;
printRequestRunner.fail();
} else {
@@ -321,7 +322,7 @@ export const processRequest =
// Updating report with current errors & result.
report.errors.push(testRunnerRes.left);
report.result = report.result && false;
report.result = report.result;
} else {
const { envs, testsReport, duration } = testRunnerRes.right;
const _hasFailedTestCases = hasFailedTestCases(testsReport);