platform-helpers/reporter-helper.js

/* 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);
  }
};