/* global browser */
/**
* Report Generator helper functions. Helps add context to the generated HTML reports of the test results.
*
* NOTE: These methods only work inside tests!
* @module reporterHelper
*/
'use strict';
const showdown = require('showdown').setOption('tables', 'true');
const converter = new showdown.Converter();
const Moment = require('moment');
const fse = require('fs-extra');
const path = require('path');
const imagemin = require('imagemin');
const imageminPngquant = require('imagemin-pngquant');
const momentDurationFormatSetup = require('moment-duration-format');
momentDurationFormatSetup(Moment);
const utf8 = require('utf8');
/**
* Logs a message within the current mocha test, with optional Markdown support (defaults to 'off').
*
* @public
* @param {string} message - the message (or Markdown) to be displayed in the html-report
* @param {boolean} [parseAsMarkdown] - if provided and true, will parse the provide message as markdown
* @returns {string} the processed string that will be used in the report
* @example
// only works inside tests!
it('should log some messages', async function () {
// no markdown
reporterHelper.logMessage('Something important you should know!')
// with markdown
reporterHelper.logMessage('Something **important** _you_ should know!', true)
})
*/
exports.logMessage = function (message, parseAsMarkdown) {
// force to string
if (typeof message !== 'string') message = String(message);
// console.log(message)
try {
if (parseAsMarkdown) {
message = converter.makeHtml(message);
}
} catch (e) {
message = `Markdown conversion issue - ${e}!\n\n${message}`;
}
try {
message = utf8.decode(message);
} catch (e) {
// message = `Encoding issue - ${e}!\n\n${message}`
}
process.emit('test:log', message);
return message;
};
/**
* Takes a screenshot with optional message (including Markdown support) and adds to the active mocha test.
*
* Returned Promise resolves when the screenshot has been taken and compressed to reduce file size.
*
* @public
* @param {string} [message] - the message (or Markdown) to be displayed in the html-report
* @param {boolean} [parseAsMarkdown] - if provided and true, will parse the provide message as markdown
* @returns {Promise}
* @yields undefined
* @example
describe('STEP 1 - check google.com title', () => {
it('should have the right title', async function () {
await browser.url('https://google.com')
const title = await browser.getTitle()
await reporterHelper.takeScreenshot('Page load screenshot.')
chai.expect(title).to.strictlyEqual('Google')
})
})
*/
exports.takeScreenshot = function (message, parseAsMarkdown) {
const screenshotFolderPath = 'reports/html-reports/screenshots/';
const timestamp = Moment().format('YYYYMMDD-HHmmss.SSS');
fse.ensureDirSync(screenshotFolderPath);
const filepath = path.join(screenshotFolderPath, timestamp + '.png');
browser.saveScreenshot(filepath);
if (message) {
this.logMessage(message, parseAsMarkdown);
}
process.emit('test:screenshot', filepath);
return imagemin([filepath], {
destination: screenshotFolderPath, // reduce size in place
plugins: [
// https://openbase.io/js/imagemin-pngquant
imageminPngquant({ quality: [0.3, 0.5] })
]
}).then(() => {
// console.log('Screenshot optimized.')
})
.catch((e) => {
console.error('Screenshot optimization error: ' + e);
});
};
/**
* Recurse through specified folder and find specific file extensions, return a list
* @private
* @param {string} dir the directory to recursively scan
* @param {array} extensions a list of file extensions to include in the returned list, like ['.json', '.html']
* @returns {array} the list of matched files
*/
function walk (dir, extensions, filelist = []) {
const files = fse.readdirSync(dir);
files.forEach(function (file) {
const filepath = path.join(dir, file);
const stat = fse.statSync(filepath);
if (stat.isDirectory()) {
filelist = walk(filepath, extensions, filelist);
} else {
extensions.forEach(function (extension) {
if (file.indexOf(extension) === file.length - extension.length) {
filelist.push(filepath);
}
});
}
});
return filelist;
}
/**
* Generates the HTML output for the html-report report (say that five times fast)
*
* @private
* @param {string} message the message (or Markdown) to be displayed in the html-report
* @returns {Promise}
*/
const _htmlGenerator = require('./wdio-html-generator.js').HtmlGenerator;
/**
* Used for our summary report generation. It's basically the same as the Class of the same name within the html-reporter package that we use for the individual reports, but with some tweaks to make it work the way we need it to.
*
* @private
* @class
*/
exports.ReportAggregator = class ReportAggregator {
constructor (opts) {
opts = Object.assign({}, {
outputDir: 'reports/html-reports/',
fileName: 'index.html',
summaryReportTitle: 'Summary Report',
showInBrowser: false,
templateFilename: path.resolve(__dirname, '../templates/wdio-html-reporter-template.hbs'),
templateFuncs: {},
browserName: 'not specified',
LOG: null
}, opts);
this.options = opts;
this.startTime = new Moment();
this.options.reportFile = path.join(process.cwd(), this.options.outputDir, this.options.fileName);
this.reports = [];
}
calculateDuration (startTime, endTime) {
const isMoment = (moment) => {
return (moment && typeof moment.diff === 'function');
};
if (isMoment(startTime) && isMoment(endTime)) {
return endTime.diff(startTime);
}
}
moveFilesBetweenRuns (index) {
return new Promise((resolve, reject) => {
try {
// move the files to the 'runs' folder
const allJsonPaths = walk(this.options.outputDir, ['.json', '.html']);
const folderPaths = [];
allJsonPaths.forEach((path) => {
const re = /\/runs\//;
const alreadyMoved = re.test(path);
const pathParts = path.split('/');
const fileName = pathParts[pathParts.length - 1];
const folderPath = pathParts.slice(0, pathParts.length - 2).join('/');
// don't try to move the files we already moved (which are in the same output dir)
if (!alreadyMoved) {
fse.moveSync(path, `${this.options.outputDir}/runs/${index}/${fileName}`);
folderPaths.push(folderPath);
}
});
folderPaths.forEach((folderPath) => {
// wait for both the .json and the .html to be moved before we delete the folder
// will fail silently when we try to remove the same file twice - that's fine.
fse.removeSync(folderPath);
});
resolve(true);
} catch (e) {
reject(e);
}
});
}
clean () {
// leave the output dir intact, but empty it
fse.emptyDirSync(this.options.outputDir);
// remove the entire 'tmp' folder on start if present (should have been moved already anyway, if the previous report completed successfully)
fse.removeSync('tmp');
}
readJsonFiles () {
return walk(this.options.outputDir, ['.json']);
}
log (message, object) {
if (this.options.LOG) {
this.options.LOG.debug(message + object);
}
}
async createReport (results) {
const metrics = {
passed: 0,
skipped: 0,
failed: 0,
total: 0,
start: this.startTime,
end: 0,
duration: 0
};
const suites = [];
const specs = [];
const files = this.readJsonFiles();
// copy the static JS and CSS assets into the report output folder
const staticSource = 'report-templates/static-assets';
const staticTarget = `${this.options.outputDir}/static-assets`;
fse.ensureDirSync(staticTarget);
fse.copySync(staticSource, staticTarget);
// move the 'logs' folder into the report output folder, to keep it all together for debugging
fse.ensureDirSync('tmp/logs');
fse.moveSync('tmp/logs', `${this.options.outputDir}/logs`);
fse.ensureDirSync('tmp/tests');
fse.moveSync('tmp/tests', `${this.options.outputDir}/tests`);
fse.removeSync('tmp');
const fileList = {};
files.forEach((fileName) => {
// keep track of the capabilities we've seen, so we can do something about the missing ones
const file = JSON.parse(fse.readFileSync(fileName));
const capNumber = (file && file.info && file.info.config && file.info.config.capabilities && file.info.config.capabilities['teal:capabilityNumber']) || 0;
const specPath = (file && file.info && file.info.config && file.info.config.capabilities && file.info.config.capabilities['teal:specPath']) || '';
const browserName = (file && file.info && file.info.config && file.info.config.capabilities && file.info.config.capabilities.browserName) || '';
const browserVersion = (file && file.info && file.info.config && file.info.config.capabilities && file.info.config.capabilities.browserVersion) || '';
const platformName = (file && file.info && file.info.config && file.info.config.capabilities && file.info.config.capabilities.platformName) || '';
const key = capNumber + ' | ' + specPath;
fileList[key] = {
browser: browserName,
version: browserVersion,
platform: platformName,
reportFile: fileName,
spec: specPath,
capNumber: capNumber
};
});
const fullRunList = JSON.parse(results['teal:fullRunList']);
// console.log(fullRunList)
// add placeholders for missing files
fullRunList.forEach((run) => {
const capNumber = run.capability['teal:capabilityNumber'];
const specPath = run.spec;
if (specPath + capNumber) {
const key = capNumber + ' | ' + specPath;
if (typeof fileList[key] === 'undefined') {
fileList[key] = {
browser: run.capability.browserName,
version: run.capability.browserVersion,
platform: run.capability.platformName,
reportFile: '',
spec: specPath,
capNumber: capNumber
};
}
}
});
// push reports or placeholders for each run
const allRuns = Object.keys(fileList);
// sort by capability number
allRuns.sort((a, b) => {
const aCapNumber = parseInt(a.split(' | ')[0], 10);
const bCapNumber = parseInt(b.split(' | ')[0], 10);
if (aCapNumber < bCapNumber) {
return -1;
} else if (aCapNumber > bCapNumber) {
return 1;
} else {
return 0;
}
});
// console.log('allRuns: ' + JSON.stringify(allRuns, null, 2))
for (let i = 0; i < allRuns.length; i++) {
try {
let report;
const key = allRuns[i];
const fileName = fileList[key].reportFile;
// if the file name is blank, that means the run failed (generate stub report for summary)
if (typeof fileName === 'undefined' || fileName === '') {
const dummyReport = {
fileLink: '',
metrics: {
passed: 0,
failed: 1,
skipped: 0
},
info: {
specs: [
fileList[key].spec
],
config: {
capabilities: [{
browserName: fileList[key].browser,
browserVersion: fileList[key].version,
platformName: fileList[key].platform,
'teal:capabilityNumber': fileList[key].capNumber
}]
},
// it's weird that this is plural but not an array, but that's how it is elsewhere
capabilities: {
browserName: fileList[key].browser,
browserVersion: fileList[key].version,
platformName: fileList[key].platformName,
'teal:capabilityNumber': fileList[key].capNumber
}
},
suites: []
};
report = dummyReport;
// console.log(dummyReport)
this.reports.push(dummyReport);
} else {
// run succeeded, generate the full report
report = JSON.parse(fse.readFileSync(fileName));
// remove the dependency on the parent folders for functional links in the summary report
report.fileLink = fileName.replace('reports/html-reports/', '');
report.info.specs.forEach(spec => {
specs.push(spec);
});
this.reports.push(report);
}
metrics.passed += report.metrics.passed;
metrics.total += report.metrics.passed;
metrics.failed += report.metrics.failed;
metrics.total += report.metrics.failed;
metrics.skipped += report.metrics.skipped;
metrics.total += report.metrics.skipped;
for (let k = 0; k < report.suites.length; k++) {
const suite = report.suites[k];
const start = new Moment(suite.start);
if (start.isSameOrBefore(metrics.start)) {
// metrics.start = start
}
const end = new Moment(suite.end);
if (end.isAfter(metrics.end)) {
// metrics.end = end
}
suites.push(suite);
}
} catch (ex) {
console.error(ex);
}
}
metrics.end = new Moment();
const duration = this.calculateDuration(metrics.start, metrics.end);
metrics.duration = duration;
metrics.prettyStart = metrics.start.format('ddd MMMM Do YYYY HH:mm:ss ZZ');
metrics.prettyEnd = metrics.end.format('ddd MMMM Do YYYY HH:mm:ss ZZ');
for (let i = 0; i < this.reports.length; i++) {
const report = this.reports[i];
report.info.specs[0] = report.info.specs[0] || '';
const nameSplit = report.info.specs[0].split('/');
const shortFileName = nameSplit[nameSplit.length - 1];
// console.log('File ' + shortFileName)
const filesFound = report && report.info && report.info.config && report.info.config.capabilities;
if (!filesFound) {
// console.log(report)
throw (new Error('No files found, halting report generation!'));
}
const reportSummaryObject = {
metrics: report.metrics,
title: report.title,
browserName: '',
browserVersion: '',
platformName: '',
sanitizedCapabilities: report.info.sanitizedCapabilities,
capabilityNumber: report.info.config.capabilities['teal:capabilityNumber'] || '',
failedTests: [],
otherNonPasses: [],
cid: report.info.cid,
// browserNumber: report.info.capabilities['teal:capabilityNumber'] || "?",
hasPassedTests: (report.metrics && report.metrics.passed > 0),
hasSkippedTests: (report.metrics && report.metrics.skipped > 0),
hasFailedTests: (report.metrics && report.metrics.failed > 0),
shortFileName: shortFileName,
reportLink: report.fileLink.replace('.json', '.html') // the files are right next to each other
};
if (report.info.capabilities) {
const cap = report.info.capabilities;
reportSummaryObject.browserName = cap.browserName || '';
reportSummaryObject.browserVersion = cap.browserVersion || cap.version || '';
reportSummaryObject.platformName = cap.platformName || cap.platform || '';
}
this.capabilities = results.capabilities.map(function (cap, index) {
cap.browserNumber = index;
return cap;
});
// reportSummaryObject.thisAsString = JSON.stringify(reportSummaryObject, null, 2)
for (let j = 0; j < report.suites.length; j++) {
for (let k = 0; k < report.suites[j].tests.length; k++) {
const test = report.suites[j].tests[k];
const testObject = {
title: test.fullTitle,
state: test.state
};
if (test.state === 'passed') continue;
if (test.state === 'failed') {
reportSummaryObject.failedTests.push(testObject);
continue;
}
reportSummaryObject.otherNonPasses.push(testObject);
}
}
this.specSummaries = this.specSummaries || {};
this.specSummaries[shortFileName] = this.specSummaries[shortFileName] || {};
this.specSummaries[shortFileName].summaries = this.specSummaries[shortFileName].summaries || [];
this.specSummaries[shortFileName].testTitle = report.info.config.capabilities['teal:testTitle'] || false;
this.specSummaries[shortFileName].testContext = report.info.config.capabilities['teal:testContext'] || false;
this.specSummaries[shortFileName].summaries.push(reportSummaryObject);
}
metrics.passedPercentage = metrics.passed / metrics.total;
metrics.failedPercentage = metrics.failed / metrics.total;
metrics.skippedPercentage = metrics.skipped / metrics.total;
const reportOptions = {
data: {
info: (this.reports[0] && this.reports[0].info) || {},
specs: specs,
metrics: metrics,
suites: suites,
title: this.options.reportTitle,
// reports: this.reports,
specSummaries: this.specSummaries,
capabilities: this.capabilities
},
outputDir: this.options.outputDir,
reportFile: this.options.reportFile,
templateFilename: this.options.templateFilename,
LOG: this.options.LOG,
templateFuncs: this.options.templateFuncs,
showInBrowser: this.options.showInBrowser
};
_htmlGenerator.htmlOutput(reportOptions);
}
};