Source: plugin/assertivedocs/assertivedocs.js

/**
 * @file assertivedocs
 * @fileoverview Defines the @assert tag and the supporting logic
 */

const fs = require('fs');
const path = require('path');
const logger = require('jsdoc/util/logger');
const env = require('jsdoc/env');

const cwd = process.cwd();

const destination = env.opts.destination || '';

/**
 * Current working file.
 * @memberof assertivedocs
 */
let file;

/**
 * @namespace typeMappings
 */
const typeMappings = {
  /**
   * Converts the argument to a string. Arguments are a string by default.
   * @param {String} arg - Argument from the unit test specification
   * @returns {String}
   */
  string: function(arg) { return arg },
  /**
   * Converts the argument to an integer.
   * @param {String} arg - Argument from the unit test specification
   * @returns {Number}
   */
  int: function(arg) { return parseInt(arg) },
  /**
   * Converts the argument to a number.
   * @param {String} arg Argument from the unit test specification
   * @returns {Number}
   */
  number: function(arg) { return parseFloat(arg) },
  /**
   * Converts the argument to a boolean value.
   * @param {String} arg - Argument from the unit test specification
   * @returns {Boolean}
   */
  bool: function(arg) { return ['true', 'false', '1', '0'].includes(arg) },
  /**
   * Converts the argument to an array.
   * @param {String} arg - Argument from the unit test specification
   * @returns {Array}
   */
  array: function(arg) { return JSON.parse(arg.replaceAll(';', ',')) },
  /**
   * Returns undefined.
   * @param {String} arg - Argument from the unit test specification
   * @returns {undefined}
   */
  undefined: function(arg) { return undefined },
  /**
   * Returns null.
   * @param {String} arg - Argument from the unit test specification
   * @returns {null}
   */
  null: function(arg) { return null },
  /**
   * Returns NaN.
   * @param {String} arg - Argument from the unit test specification
   * @returns {NaN}
   */
  NaN: function(arg) { return NaN },
  /**
   * Attempts to convert the argument to the given type
   * @param {String} arg - Argument to convert
   * @param {String} type - The type to convert to
   * @returns {String|Number|Boolean|any[]|undefined|null|NaN}
   */
  convert: function(arg, type) {
    try {
      switch (type) {
        case 'string':
          return typeMappings.string(arg); 
        case 'int':
          return typeMappings.int(arg);
        case 'number':
          return typeMappings.number(arg);
        case 'bool':
          return typeMappings.bool(arg);
        case 'array':
          return typeMappings.array(arg);
        case 'undefined':
          return typeMappings.undefined(arg);
        case 'null':
          return typeMappings.null(arg);
        case 'NaN':
          return typeMappings.NaN(arg);
        case 'object':
          return typeMappings.object ? typeMappings.object(arg) : {};
        default:
          return arg;
      };
    } catch (error) {
      logger.error(error);
      return arg;
    }
  },
  /**
   * Checks that a custom object map has been provided and attempts to add it to 
   * typeMappings as a mixin.
   */
  modify: function() {
    if (env.opts.assertivedocs.customObjects) {
      const { typeMappingsMixin } = require(path.join(cwd, env.opts.assertivedocs.customObjects));
      Object.assign(typeMappings, typeMappingsMixin);
    }
  }
}

// Attempt to load mixin
typeMappings.modify();


/**
 * An object for asserting the truth of the 
 * @namespace Assertion
 * @param {Function} func - The function to test
 * @param {any[]} args - List of arguments to pass to the function
 * @param {any} expected - The expected result of the function
 * @example
 * function foo(bar) {
 *  return bar;
 * }
 * 
 * const test = Assertion(foo, ['hello'], 'hello');
 * // Returns 'true'
 * console.log(test.assert());
 * 
 * @assert {Assertion} Test1 - console.log,["hello"]:json=>:undefined
 */
function Assertion(func, args, expected) {
  /**
   * Function to be assessed.
   * @memberof Assertion
   */
  this.func = func ? func : () => {};
  /**
   * The arguments to be passed to the function.
   * @memberof Assertion
   */
  this.args = args;
  /**
   * The expected result when the function is passed the arguments.
   * @memberof Assertion
   */
  this.expected = expected;

  /**
   * Asserts that the stored function produces
   * the expected result when passed the given
   * arguments.
   * @memberof Assertion
   * @returns {String}
   */
  this.assert = function() {
    try {
      const outcome = this.func(...this.args);
      return outcome === this.expected;
    } catch (error) {
      return error;
    }
  }
}

/**
 * The function to call when an assert tag is found.
 * @namespace assertivedocs
 * @param {jsdoc.Doclet} doclet - The doclet that the tag is in
 * @param {jsdoc.Tag} tag - The found tag
 */
function assertOnTagged(doclet, tag) {
  // Split tag value description on =>
  const parts = tag.value.description.split('=>');

  // Get all the arguments to parse
  let args = parts[0].split(',');

  // Get the type converted arguments
  args = args.map((arg) => {
    arg = arg.split(':');
    return typeMappings.convert(arg[0], arg[1]);
  });

  // Get the expected result
  let expected = parts[1].split(':');
  expected = expected.length > 1 ? typeMappings.convert(expected[0], expected[1]) : expected[0];

  // Create new Assertion
  const test = new Assertion(file[doclet.meta.code.name], args, expected);

  // Create formatted unit test result for docs
  const result = {
    name: tag.value.name ? tag.value.name : "",
    arguments: args.map((arg) => typeof arg === 'object' ? JSON.stringify(arg) : arg ).join(', '),
    expected: expected,
    result: test.assert().toString(),
  }

  // Assign the result to the doclet
  if (!doclet.tests) doclet.tests = [];
  doclet.tests.push(result);
}

const defineTags = function(dictionary) {
  // Definition of @assert
  dictionary.defineTag('assert', {
    mustHaveValue: true,
    canHaveType: true,
    canHaveName: true,
    onTagged: assertOnTagged,
  });
}

const handlers = {
  fileBegin: function(e) {
    file = require(e.filename);
  },
  processingComplete: function(e) {
    let out;
    fs.readFile(path.join(__dirname, '/assertivehead.html'), 'utf8', (_, data) => {
      out = data;
      e.doclets.forEach(doclet => {
        if (doclet.tests) {
          out += `
  <h3>${doclet.meta.filename}:${doclet.meta.code.name}</h3>
  <table class="params">
    <tr>
      <thead>
        <th>Test</th>
        <th>Arguments</th>
        <th>Expected</th>
        <th class="last">Results</th>
      </thead>
    </tr>
    <tbody>
          `;
          doclet.tests.forEach((test) => {
            out += `
      <tr>
        <td>${test.name}</td>
        <td>${test.arguments}</td>
        <td>${test.expected}</td>
        <td class="last">${test.result}</td>
      </tr>
            `;
          });
          out += `
    </tbody>
  </table>
</body>
</html>
          `;
        }
      });

      if (!fs.existsSync(path.join(cwd, destination, 'unit-tests/'))) {
        fs.mkdir(path.join(cwd, destination, 'unit-tests/'), (err) => {
          logger.error(err);
          fs.writeFile(path.join(cwd, destination, 'unit-tests/index.html'), out, {
            encoding: 'utf-8',
          }, (error) => {
            if (error) logger.error(error);
          });
        });
      } else {
        fs.writeFile(path.join(cwd, destination, 'unit-tests/index.html'), out, {
          encoding: 'utf-8',
        }, (error) => {
          if (error) logger.error(error);
        });
      }
    });
  }
}

module.exports = {
  // Plugin definition
  defineTags,
  handlers,

  // Plugin documnentation; allows assertions to be run on this file
  Assertion,
  assertOnTagged,
}