@testomatio/reporter
Version:
Testomatio Reporter Client
308 lines (307 loc) • 14.1 kB
JavaScript
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const debug_1 = __importDefault(require("debug"));
const lodash_merge_1 = __importDefault(require("lodash.merge"));
const fs_1 = __importDefault(require("fs"));
const path_1 = __importDefault(require("path"));
const picocolors_1 = __importDefault(require("picocolors"));
const handlebars_1 = __importDefault(require("handlebars"));
const file_url_1 = __importDefault(require("file-url"));
const utils_js_1 = require("../utils/utils.js");
const constants_js_1 = require("../constants.js");
const node_url_1 = require("node:url");
const debug = (0, debug_1.default)('@testomatio/reporter:pipe:html');
// @ts-ignore – this line will be removed in compiled code (already defined in the global scope of commonjs)
class HtmlPipe {
constructor(params, store = {}) {
this.store = store || {};
this.title = params.title || process.env.TESTOMATIO_TITLE;
this.apiKey = params.apiKey || process.env.TESTOMATIO;
this.isHtml = process.env.TESTOMATIO_HTML_REPORT_SAVE;
debug('HTML Pipe: ', this.apiKey ? 'API KEY' : '*no api key provided*');
this.isEnabled = false;
this.htmlOutputPath = '';
this.fullHtmlOutputPath = '';
this.filenameMsg = '';
this.tests = [];
if (this.isHtml) {
this.isEnabled = true;
this.htmlReportDir = process.env.TESTOMATIO_HTML_REPORT_FOLDER || constants_js_1.HTML_REPORT.FOLDER;
if (process.env.TESTOMATIO_HTML_FILENAME && process.env.TESTOMATIO_HTML_FILENAME.endsWith('.html')) {
this.htmlReportName = process.env.TESTOMATIO_HTML_FILENAME;
}
if (process.env.TESTOMATIO_HTML_FILENAME && !process.env.TESTOMATIO_HTML_FILENAME.endsWith('.html')) {
this.htmlReportName = constants_js_1.HTML_REPORT.REPORT_DEFAULT_NAME;
this.filenameMsg =
'HTML filename must include the extension ".html".' +
` The default report name "${this.htmlReportDir}/${constants_js_1.HTML_REPORT.REPORT_DEFAULT_NAME}" is used!`;
}
if (!process.env.TESTOMATIO_HTML_FILENAME) {
this.htmlReportName = constants_js_1.HTML_REPORT.REPORT_DEFAULT_NAME;
}
this.templateFolderPath = path_1.default.resolve(__dirname, '..', 'template');
this.templateHtmlPath = path_1.default.resolve(this.templateFolderPath, constants_js_1.HTML_REPORT.TEMPLATE_NAME);
this.htmlOutputPath = path_1.default.join(this.htmlReportDir, this.htmlReportName);
// create a new folder for the HTML reports
utils_js_1.fileSystem.createDir(this.htmlReportDir);
debug(picocolors_1.default.yellow('HTML Pipe:'), `Save HTML report: ${this.isEnabled}`, `HTML report folder: ${this.htmlReportDir}, report name: ${this.htmlReportName}`);
}
}
async createRun() {
// empty
}
async prepareRun() { }
updateRun() {
// empty
}
/**
* Add test data to the result array for saving. As a result of this function, we get a result object to save.
* @param {import('../../types/types.js').RunData} test - object which includes each test entry.
*/
addTest(test) {
if (!this.isEnabled)
return;
if (!test.status)
return;
const index = this.tests.findIndex(t => (0, utils_js_1.isSameTest)(t, test));
// update if they were already added
if (index >= 0) {
this.tests[index] = (0, lodash_merge_1.default)(this.tests[index], test);
return;
}
this.tests.push(test);
}
async finishRun(runParams) {
if (!this.isEnabled)
return;
if (this.isHtml) {
// GENERATE HTML reports based on the results data
this.buildReport({
runParams,
// TODO: this.tests=[] in case of Mocha, need retest by Vitalii
tests: this.tests,
outputPath: this.htmlOutputPath,
templatePath: this.templateHtmlPath,
warningMsg: this.filenameMsg,
});
}
}
/**
* Generates an HTML report based on provided test data and a template.
* @param {object} opts - Test options used to generate the HTML report:
* runParams, tests, outputPath, templatePath
* @returns {void} - This function does not return anything.
*/
buildReport(opts) {
const { runParams, tests, outputPath, templatePath, warningMsg: msg } = opts;
debug('HTML tests data:', tests);
if (!outputPath) {
console.log(picocolors_1.default.yellow(`🚨 HTML export path is not set, ignoring...`));
return;
}
console.log(picocolors_1.default.yellow(`⏳ The test results will be added to the HTML report. It will take some time...`));
if (msg) {
console.log(picocolors_1.default.blue(msg));
}
tests.forEach(test => {
// steps could be an array or a string
test.steps = Array.isArray(test.steps)
? (test.steps = test.steps
.map(step => (0, utils_js_1.formatStep)(step))
.flat()
.join('\n'))
: test.steps;
if (!test.message?.trim()) {
test.message = "This test has no 'message' code";
}
if (!test.suite_title?.trim()) {
test.suite_title = 'Unknown suite';
}
if (!test.title?.trim()) {
test.title = 'Unknown test title';
}
if (!test.files?.length) {
test.files = 'This test has no files';
}
if (!test.steps?.trim()) {
test.steps = "This test has no 'steps' code";
}
else {
test.steps = removeAnsiColorCodes(test.steps);
}
// TODO: future-proof: currently there is no need to display Artifacts and Metadata in HTML
test.artifacts = test.artifacts || [];
test.meta = test.meta || {};
// TODO: u can added an additional test values to this checks in the future
});
const data = {
runId: this.store.runId || '',
status: runParams.status || 'No status info',
parallel: runParams.isParallel || 'No parallel info',
runUrl: this.store.runUrl || '',
executionTime: testExecutionSumTime(tests),
executionDate: getCurrentDateTimeFormatted(),
tests,
};
// generate output HTML based on the template
const html = this.#generateHTMLReport(data, templatePath);
if (!html)
return;
fs_1.default.writeFileSync(outputPath, html, 'utf-8');
// Check if the file exists
if (fs_1.default.existsSync(outputPath)) {
// Get the absolute path of the file
const absolutePath = path_1.default.resolve(outputPath);
// Convert the file path to a file URL
const fileUrlPath = (0, file_url_1.default)(absolutePath, { resolve: true });
debug('HTML tests data:', fileUrlPath);
console.log(picocolors_1.default.green(`📊 The HTML report was successfully generated. Full filepath: ${fileUrlPath}`));
}
else {
console.log(picocolors_1.default.red(`🚨 Failed to generate the HTML report.`));
}
}
/**
* Generates an HTML report based on provided test data and a template path.
* @param {any} data - Test data used to generate the HTML report.
* @param {string} [templatePath=""] - The path to the HTML template used for generating the report.
* @returns {string | void} - The generated HTML report as a string or void if templatePath is not provided.
*/
#generateHTMLReport(data, templatePath = '') {
if (!templatePath) {
console.log(picocolors_1.default.red(`🚨 HTML template not found. Report generation is impossible!`));
return;
}
const templateSource = fs_1.default.readFileSync(templatePath, 'utf8');
this.#loadReportHelpers();
try {
const template = handlebars_1.default.compile(templateSource);
return template(data);
}
catch (e) {
console.log(picocolors_1.default.red('❌ Oops! An unknown error occurred when generating an HTML report'));
console.log(picocolors_1.default.red(e));
}
}
#loadReportHelpers() {
handlebars_1.default.registerHelper('getTestsByStatus', (tests, status) => tests.filter(test => test.status.toLowerCase() === status.toLowerCase()).length);
handlebars_1.default.registerHelper('selectComponent', () => new handlebars_1.default.SafeString(`<select style="width: 70px;height: 38px;" class="form-select" aria-label="Tests counter on page">
<option value="0">10</option>
<option value="1">25</option>
<option value="2">50</option>
</select>`));
handlebars_1.default.registerHelper('emptyDataComponent', () => {
const svgFilePath = path_1.default.join(__dirname, '..', 'template', 'emptyData.svg');
const svgContent = fs_1.default.readFileSync(svgFilePath, 'utf8');
return new handlebars_1.default.SafeString(`
<div class="noData">
<div class="noDataSvg">
${svgContent}
</div>
<div class="noDataText">
NO MATCHING TESTS
</div>
<div>`);
});
handlebars_1.default.registerHelper('pageDispleyElements', tests => {
// We wrapp the lines to the HTML format we need
const totalTests = JSON.parse(JSON.stringify(tests)
.replace(/<script>/g, '<script>')
.replace(/<\/script>/g, '</script>'));
const paginationOptions = {
0: 10,
1: 25,
2: 50,
};
const statuses = ['all', 'passed', 'failed', 'skipped'];
const pageItemGroups = {
all: {},
passed: {},
failed: {},
skipped: {},
totalTests,
};
function paginateItems(items, pageSize) {
const paginatedItems = [];
for (let i = 0; i < items.length; i += pageSize) {
paginatedItems.push(items.slice(i, i + pageSize));
}
return paginatedItems;
}
statuses.forEach(status => {
for (const option in paginationOptions) {
if (paginationOptions.hasOwnProperty(option)) {
const pageSize = paginationOptions[option];
let filteredItems = totalTests;
if (status !== 'all') {
filteredItems = totalTests.filter(item => item.status === status);
}
pageItemGroups[status][option] = paginateItems(filteredItems, pageSize);
}
}
});
pageItemGroups.totalTests = totalTests;
return JSON.stringify(pageItemGroups);
});
}
toString() {
return 'HTML Reporter';
}
}
/**
* Calculates the total execution time for an array of tests.
* @param {Object[]} tests - An array of test objects.
* @param {number} tests[].run_time - The execution time of each test in milliseconds.
* @returns {string} - The total execution time in a formatted duration string.
*/
function testExecutionSumTime(tests) {
const totalMilliseconds = tests.reduce((sum, test) => {
if (typeof test.run_time === 'number' && !Number.isNaN(test.run_time)) {
return sum + test.run_time;
}
return sum;
}, 0);
return formatDuration(totalMilliseconds);
}
/**
* Removes ANSI color codes and converts newline characters to HTML line breaks in a given string.
* @param {string} str - The input string containing ANSI color codes.
* @returns {string} - The updated string with removed ANSI color codes and replaced newline characters.
*/
function removeAnsiColorCodes(str) {
let updatedStr = str.replace((0, utils_js_1.ansiRegExp)(), '');
updatedStr = updatedStr.replace(/\n/g, '<br>');
return updatedStr;
}
/**
* Formats duration in milliseconds into a human-readable string representation.
* @param {number} duration - The duration in milliseconds.
* @returns {string} - The formatted duration string (e.g., "2h 30m 15s 500ms").
*/
function formatDuration(duration) {
const milliseconds = duration % 1000;
duration = (duration - milliseconds) / 1000;
const seconds = duration % 60;
duration = (duration - seconds) / 60;
const minutes = duration % 60;
const hours = (duration - minutes) / 60;
return `${hours}h ${minutes}m ${seconds}s ${milliseconds}ms`;
}
/**
* Retrieves the current date and time in a formatted string.
* @returns {string} - The formatted date and time string (e.g., "(01/01/2023 12:00:00)").
*/
function getCurrentDateTimeFormatted() {
const currentDate = new Date();
const day = currentDate.getDate().toString().padStart(2, '0');
const month = (currentDate.getMonth() + 1).toString().padStart(2, '0');
const year = currentDate.getFullYear();
const hours = currentDate.getHours().toString().padStart(2, '0');
const minutes = currentDate.getMinutes().toString().padStart(2, '0');
const seconds = currentDate.getSeconds().toString().padStart(2, '0');
return `(${day}/${month}/${year} ${hours}:${minutes}:${seconds})`;
}
module.exports = HtmlPipe;