feat(cli): add support for JUnit reporter (#4189)
This commit is contained in:
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
178
packages/hoppscotch-cli/src/utils/reporters/junit.ts
Normal file
178
packages/hoppscotch-cli/src/utils/reporters/junit.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user