UNPKG

cucumber-html-reporter

Version:

Generates Cucumber HTML reports in three different themes

608 lines (528 loc) 19.3 kB
// 'use strict'; const chalk = require('chalk'); const jsonFile = require('jsonfile'); const _ = require('lodash'); const fs = require('fs-extra'); const path = require('path'); const jsonDir = require('./jsonDir'); const emoji = require('node-emoji'); const searchFileUp = require('./searchFileUp'); const hierarchyReporter = require('./hierarchyReporter'); const guid = require('uuid').v4; const open = require('open'); let isBase64 = (data) => /^(?:[A-Z0-9+/]{4})*(?:[A-Z0-9+/]{2}==|[A-Z0-9+/]{3}=|[A-Z0-9+/]{4})$/i.test(data); let stageRerunCount = getStageReRunCount(); const generateReport = function (options) { let featureOutput = jsonFile.readFileSync(options.jsonFile); let packageJsonPath = searchFileUp('package.json'); let packageJson = {}; try { packageJson = packageJsonPath && jsonFile.readFileSync(packageJsonPath, 'utf8'); } catch (err) { console.warn('No package.json file found in: ' + packageJsonPath + ', using default name and version.'); packageJson.name = 'default'; packageJson.version = '0.0.0'; } const sanitize = function (name, find) { const unsafeCharacters = find || /[/|:"*?<>]/g; if (name !== undefined) { name = name.trim().replace(unsafeCharacters, '_'); } return name; }; featureOutput.summary = { isFailed: false, passed: 0, failed: 0, ambiguous: 0, skipped: 0, notdefined: 0, pending: 0, }; const result = { status: { passed: 'passed', failed: 'failed', skipped: 'skipped', pending: 'pending', undefined: 'undefined', ambiguous: 'ambiguous', }, }; let suite = { name: { plain: options.name || (packageJson && packageJson.name), sanitized: sanitize(options.name || (packageJson && packageJson.name), /[^a-z|0-9]/g), }, brandTitle: options.brandTitle, version: packageJson && packageJson.version, time: new Date(), features: featureOutput, passed: 0, failed: 0, ambiguous: 0, rerun: 0, totalTime: 0, suites: [], scenarios: { passed: 0, failed: 0, skipped: 0, pending: 0, notdefined: 0, ambiguous: 0, rerun: 0, getTotal: function () { return this.passed + this.failed + this.skipped + this.pending + this.notdefined + this.ambiguous; }, }, }; const createReportDirectoryIfNotExists = function () { if (!fs.existsSync(options.output)) { fs.mkdirsSync(path.dirname(options.output)); } }; createReportDirectoryIfNotExists(); function getColumnLayoutWidth() { const FULL_WIDTH = 12; const HALF_WIDTH = 6; if (options.columnLayout === 1) { return FULL_WIDTH; } else { return HALF_WIDTH; } } /** * Make human-readable duration for scenario steps * Sample Input: "2005366787" * Sample Output: "2s 5ms" */ const calculateDuration = function (durationInNanoSeconds) { // convert it to MILLI_SECONDS const durationInMillis = _.floor(durationInNanoSeconds / 1000000); let oneMilliSecond = 1000; let oneMinute = 60 * oneMilliSecond; let formattedDuration = '0s'; function format(min, sec, ms) { const MINUTES = 'm '; const SECONDS = 's '; const MILLI_SECONDS = 'ms'; let formattedTimeStamp = ''; min > 0 ? (formattedTimeStamp += min + MINUTES) : ''; sec > 0 ? (formattedTimeStamp += sec + SECONDS) : ''; ms > 0 ? (formattedTimeStamp += ms + MILLI_SECONDS) : ''; return formattedTimeStamp.trim().length === 0 ? '< 1ms' : formattedTimeStamp; } if (!isNaN(durationInMillis)) { const min = _.floor(durationInMillis / oneMinute); const sec = _.floor((durationInMillis % oneMinute) / oneMilliSecond); const ms = durationInMillis % oneMilliSecond; formattedDuration = format(min, sec, ms); } return formattedDuration; }; const preventOverlappingTheScenarioTitle = function (element) { let counter = 0; if (element.passed) counter++; if (element.notdefined) counter++; if (element.pending) counter++; if (element.skipped) counter++; if (element.failed) counter++; if (element.ambiguous) counter++; counter = counter * 20 + 10; if (element.retried) { // 90px fixed width counter = Math.max(counter, 90); } return counter + 'px'; }; const readFileForRespectiveTemplates = function (filename) { if (filename === 'script.js' && options.theme === 'foundation') { return readFile('../_common/foundation/' + filename); } return options.theme === 'bootstrap' || options.theme === 'hierarchy' ? readFile('../_common/bootstrap.hierarchy/' + filename) : readFile(filename); }; /** * NOTE: This method is used by hierarchy report template, harmless for others. * Creates the HTML fragments for any features assigned to this suite, * and stores them in `featureMarkup` attribute of the suite so we can render them in index.tmpl * * @param suite */ const getFeaturesTemplate = function (suite) { return _.template(readFileForRespectiveTemplates('features.html'))({ suite: suite, _: _, calculateDuration: calculateDuration, columnLayoutWidth: getColumnLayoutWidth(), decideScenarioTitlePadding: preventOverlappingTheScenarioTitle, guid: guid, }); }; const setupSubSuiteTemplates = function (suite) { suite.featureMarkup = '<div style="display: none;">No features</div>'; if (suite.features && suite.features.length) { suite.featureMarkup = getFeaturesTemplate(suite); } for (let i = 0; i < suite.suites.length; i++) { const subSuite = suite.suites[i]; setupSubSuiteTemplates(subSuite); } }; const parseScenarioHooks = function (data) { return data.map((step) => { const match = step.match && step.match.location ? step.match : { location: 'can not be determined' }; if (step.embeddings === undefined) { return {}; } return { arguments: step.arguments || [], result: step.result, match, embeddings: step.embeddings || [], }; }); }; const setStats = function (suite) { const featureOutput = suite.features; const topLevelFeatures = []; const featuresSummary = suite.features.summary; let screenshotsDirectory; suite.reportAs = 'Features'; if (options.screenshotsDirectory) { screenshotsDirectory = options.screenshotsDirectory; } else { screenshotsDirectory = options.output ? path.join(options.output, '..', 'screenshots') : 'screenshots'; } const basedir = hierarchyReporter.getBaseDir(suite); featureOutput.forEach(function (feature) { feature.hierarchy = hierarchyReporter.getFeatureHierarchy(feature.uri, basedir); feature.scenarios = {}; feature.scenarios.passed = 0; feature.scenarios.failed = 0; feature.scenarios.notdefined = 0; feature.scenarios.skipped = 0; feature.scenarios.pending = 0; feature.scenarios.ambiguous = 0; feature.scenarios.rerun = 0; feature.scenarios.count = 0; feature.time = 0; featuresSummary.isFailed = false; featuresSummary.isAmbiguous = false; if (!feature.elements) { return; } if (feature.elements) { feature.elements.map((scenario) => { const { before, after } = scenario; if (before) { scenario.steps = parseScenarioHooks(before).concat(scenario.steps); } if (after) { scenario.steps = scenario.steps.concat(parseScenarioHooks(after)); } }); } feature.elements.forEach(function (element) { element.passed = 0; element.failed = 0; element.notdefined = 0; element.skipped = 0; element.pending = 0; element.ambiguous = 0; element.time = 0; element.timestamp = ''; element.notes = ''; if (element.type === 'background') { return; } element.steps.forEach(function (step, count) { if (step.embeddings !== undefined) { step.embeddings.forEach(function (embedding) { let embeddingType = {}; if (embedding.mime_type) { embeddingType = embedding.mime_type; } else if (embedding.media) { embeddingType = embedding.media.type; } if (['text/plain', 'text/html', 'application/json'].includes(embeddingType)) { let decoded; if (isBase64(embedding.data)) { decoded = Buffer.from(embedding.data, 'base64').toString('utf8'); } else { decoded = embedding.data; } if (!step.text) { step.text = decoded; } else { step.text = step.text.concat(`<br>${decoded}`); } } else if (embeddingType === 'image/png') { step.image = 'data:image/png;base64,' + embedding.data; if (options.storeScreenshots && options.storeScreenshots === true) { let name = sanitize(step.name || step.keyword, /[^a-zA-Z0-9-]+/g); // Only allow URL-friendly file names if (!fs.existsSync(screenshotsDirectory)) { fs.mkdirSync(screenshotsDirectory); } name = name + '_' + Math.round(Math.random() * 10000) + '.png'; //randomize the file name const filename = path.join(screenshotsDirectory, name); fs.writeFileSync(filename, embedding.data, 'base64'); if (options.noInlineScreenshots) step.image = path.relative(path.join(options.output, '..'), filename); } } else if (embeddingType === 'image/gif') { step.image = 'data:image/gif;base64,' + embedding.data; if (options.storeScreenshots && options.storeScreenshots === true) { let name = sanitize(step.name || step.keyword, /[^a-zA-Z0-9-]+/g); // Only allow URL-friendly file names if (!fs.existsSync(screenshotsDirectory)) { fs.mkdirSync(screenshotsDirectory); } name = name + '_' + Math.round(Math.random() * 10000) + '.gif'; //randomize the file name const filename = path.join(screenshotsDirectory, name); fs.writeFileSync(filename, embedding.data, 'base64'); if (options.noInlineScreenshots) step.image = path.relative(path.join(options.output, '..'), filename); } } else { const file = 'data:application/octet-stream;base64,' + embedding.data; const fileType = embedding.mime_type.split('/')[1]; step.text = step.text || ''; step.text = step.text.concat( '<a href="' + file + '" download="file.' + fileType + '">download file</a>' ); } }); } if (!step.result || (step.hidden && !step.text && !step.image)) { return 0; } if (step.result.duration) element.time += step.result.duration; if (step.output) { if (options.scenarioTimestamp && count === 0) { element.timestamp = step.output[0]; } step.output.forEach(function (o) { element.notes += o + '<br/>'; }); } switch (step.result.status) { case result.status.passed: return element.passed++; case result.status.failed: return element.failed++; case result.status.undefined: return element.notdefined++; case result.status.pending: return element.pending++; case result.status.ambiguous: return element.ambiguous++; default: break; } element.skipped++; }); if (element.time > 0) { feature.time += element.time; } feature.scenarios.count++; if (element.failed > 0) { feature.scenarios.failed++; featuresSummary.isFailed = true; return suite.scenarios.failed++; } if (element.ambiguous > 0) { feature.scenarios.ambiguous++; featuresSummary.isAmbiguous = true; return suite.scenarios.ambiguous++; } if (element.notdefined > 0) { feature.scenarios.notdefined++; return suite.scenarios.notdefined++; } if (element.pending > 0) { feature.scenarios.pending++; return suite.scenarios.pending++; } if (element.skipped > 0) { feature.scenarios.skipped++; return suite.scenarios.skipped++; } if (element.passed > 0) { feature.scenarios.passed++; return suite.scenarios.passed++; } }); if (stageRerunCount > 0) { feature.scenarios.rerun = stageRerunCount; suite.scenarios.rerun = stageRerunCount; } let subSuite = undefined; if (options.theme === 'hierarchy') { subSuite = hierarchyReporter.findOrCreateSubSuite(suite, feature.hierarchy); } if (subSuite) { subSuite.features.push(feature); } else { topLevelFeatures.push(feature); } if (featuresSummary.isFailed) { featuresSummary.failed++; subSuite ? hierarchyReporter.recursivelyIncrementStat(subSuite, 'failed') : suite.failed++; } else if (featuresSummary.isAmbiguous) { featuresSummary.ambiguous++; subSuite ? hierarchyReporter.recursivelyIncrementStat(subSuite, 'ambiguous') : suite.ambiguous++; } else if (feature.scenarios.count === feature.scenarios.skipped) { featuresSummary.skipped++; subSuite ? hierarchyReporter.recursivelyIncrementStat(subSuite, 'skipped') : suite.skipped++; } else if (feature.scenarios.count === feature.scenarios.notdefined) { featuresSummary.notdefined++; subSuite ? hierarchyReporter.recursivelyIncrementStat(subSuite, 'passed') : suite.passed++; } else if (feature.scenarios.count === feature.scenarios.pending) { featuresSummary.pending++; subSuite ? hierarchyReporter.recursivelyIncrementStat(subSuite, 'passed') : suite.passed++; } else { featuresSummary.passed++; subSuite ? hierarchyReporter.recursivelyIncrementStat(subSuite, 'passed') : suite.passed++; } if (options.reportSuiteAsScenarios) { suite.failed = suite.scenarios.failed; suite.passed = suite.scenarios.passed; suite.ambiguous = suite.scenarios.ambiguous; suite.skipped = suite.scenarios.skipped; suite.rerun = suite.scenarios.rerun; suite.reportAs = 'scenarios'; } if (feature.time) { suite.totalTime += feature.time; } suite.features = topLevelFeatures; suite.features.summary = featuresSummary; return suite; }); suite.totalTime = calculateDuration(suite.totalTime); if (options.theme === 'hierarchy') { setupSubSuiteTemplates(suite); } if (options.metadata) suite.metadata = options.metadata; return suite; }; function readFile(fileName) { function getPath(name) { //use custom template based on user's requirement if (options.templateDir && fs.existsSync(path.join(options.templateDir, name))) { return path.join(options.templateDir, name); } else { return path.join(__dirname, '..', 'templates', options.theme, name); } } return fs.readFileSync(getPath(fileName), 'utf-8'); } suite = setStats(suite); fs.writeFileSync( options.output, _.template(readFile('index.html'))({ suite: suite, features: getFeaturesTemplate(suite), styles: readFileForRespectiveTemplates('style.css'), script: readFileForRespectiveTemplates('script.js'), screenshot: readFile('../_common/screenshot.js'), piechart: options.theme === 'bootstrap' || options.theme === 'hierarchy' ? readFileForRespectiveTemplates('piechart.js') : undefined, guid: guid, }) ); console.log( '\n' + chalk.green.bold( emoji.emojify(':rocket:') + ' Cucumber HTML report ' + chalk.blue.bold(options.output) + ' generated successfully ' ) + emoji.emojify(':thumbsup:') ); }; function generate(options, callback) { function isValidJsonFile() { // options.jsonFile = options.jsonFile || options.output + '.json'; options.jsonFile = options.jsonFile || options.output + '.json' || options.output + '.ndjson'; function isAFile(filePath) { try { return fs.statSync(filePath).isFile(); } catch (err) { return false; } } if (!isAFile(options.jsonFile)) { const jsonFilePath = options.jsonFile; const dynamicReportJsonFileName = fs.readdirSync(jsonFilePath, 'utf-8'); options.jsonFile = jsonFilePath + '/' + dynamicReportJsonFileName[0]; } try { JSON.parse(JSON.stringify(jsonFile.readFileSync(options.jsonFile))); return true; } catch (e) { console.log( '\n' + '\n' + chalk.bold.red( emoji.emojify(':warning: ') + emoji.emojify(':disappointed: ') + "Unable to parse cucumberjs output into json: '%s'", options.jsonFile, e ) ); if (callback) { callback('Unable to parse cucumberjs output into json: \'' + options.jsonFile + '\'. Error: ' + e); } else { return false; } } } async function launchReport() { if (fs.existsSync(options.output) && (options.launchReport || options.launchReport === 'true')) { await open(options.output, { wait: false }); } } if (options.jsonDir) { jsonDir.collectJSONS(options); } if (isValidJsonFile()) { generateReport(options); launchReport(); return callback ? callback() : true; } } // Specific to LambdaTest function getStageReRunCount() { let retryCount = undefined; const metaFile = process.env.META_FILE; try { const jobReports = jsonFile.readFileSync(metaFile); retryCount = 0; if ( !!jobReports && !!jobReports['JobSummary'] && !!jobReports['JobSummary']['Tasks'] && jobReports['JobSummary']['Tasks'] instanceof Array ) { jobReports['JobSummary']['Tasks'].forEach((task) => { if (!!task && !!task['stages'] && !!task['stages']['retried'] && !isNaN(task['stages']['retried'])) { // +<num> makes sure that a numeric constiable is getting read as a number retryCount += +task['stages']['retried']; } }); } } catch (err) { retryCount = undefined; } return retryCount; } module.exports = { generate: generate, };