gkushang/cucumber-html-reporter

View on GitHub
lib/reporter.js

Summary

Maintainability
F
1 wk
Test Coverage
// '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;
      },
    },
    failedSummaryReport: options.failedSummaryReport,
  };

  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,
};