current commit
251017.9 · assert: allow printf-style messages as assertion error
'use strict';

const {
  Error,
  ErrorCaptureStackTrace,
  ErrorPrototypeToString,
  StringPrototypeCharCodeAt,
  StringPrototypeReplace,
} = primordials;

const {
  codes: {
    ERR_AMBIGUOUS_ARGUMENT,
    ERR_INVALID_ARG_TYPE,
  },
  isErrorStackTraceLimitWritable,
} = require('internal/errors');
const AssertionError = require('internal/assert/assertion_error');
const { isError } = require('internal/util');
const { format } = require('internal/util/inspect');

const {
  getErrorSourceExpression,
} = require('internal/errors/error_source');

// Escape control characters but not \n and \t to keep the line breaks and
// indentation intact.
// eslint-disable-next-line no-control-regex
const escapeSequencesRegExp = /[\x00-\x08\x0b\x0c\x0e-\x1f]/g;
const meta = [
  '\\u0000', '\\u0001', '\\u0002', '\\u0003', '\\u0004',
  '\\u0005', '\\u0006', '\\u0007', '\\b', '',
  '', '\\u000b', '\\f', '', '\\u000e',
  '\\u000f', '\\u0010', '\\u0011', '\\u0012', '\\u0013',
  '\\u0014', '\\u0015', '\\u0016', '\\u0017', '\\u0018',
  '\\u0019', '\\u001a', '\\u001b', '\\u001c', '\\u001d',
  '\\u001e', '\\u001f',
];

const escapeFn = (str) => meta[StringPrototypeCharCodeAt(str, 0)];

/**
 * A function that derives the failure message from the actual and expected values.
 * It is invoked only when the assertion fails.
 *
 * Other return values than a string are ignored.
 * @callback MessageFactory
 * @param {any} actual
 * @param {any} expected
 * @returns {string}
 */

/**
 * Raw message input is always passed internally as a tuple array.
 * Accepted shapes:
 *  - []
 *  - [string]
 *  - [string, ...any[]] (printf-like substitutions)
 *  - [Error]
 *  - [MessageFactory]
 *
 * Additional elements after [Error] or [MessageFactory] are rejected with ERR_AMBIGUOUS_ARGUMENT.
 * A first element that is neither string, Error nor function is rejected with ERR_INVALID_ARG_TYPE.
 * @typedef {[] | [string] | [string, ...any[]] | [Error] | [MessageFactory]} MessageTuple
 */

/**
 * Options consumed by innerFail to construct and throw the AssertionError.
 * @typedef {object} InnerFailOptions
 * @property {any} actual Actual value
 * @property {any} expected Expected value
 * @property {MessageTuple} message Message
 * @property {string} operator Operator
 * @property {Function} stackStartFn Stack start function
 * @property {'simple' | 'full'} [diff] Diff mode
 * @property {boolean} [generatedMessage] Generated message
 */

function getErrMessage(fn) {
  const tmpLimit = Error.stackTraceLimit;
  const errorStackTraceLimitIsWritable = isErrorStackTraceLimitWritable();
  // Make sure the limit is set to 1. Otherwise it could fail (<= 0) or it
  // does to much work.
  if (errorStackTraceLimitIsWritable) Error.stackTraceLimit = 1;
  // We only need the stack trace. To minimize the overhead use an object
  // instead of an error.
  const err = {};
  ErrorCaptureStackTrace(err, fn);
  if (errorStackTraceLimitIsWritable) Error.stackTraceLimit = tmpLimit;

  let source = getErrorSourceExpression(err);
  if (source) {
    source = StringPrototypeReplace(source, escapeSequencesRegExp, escapeFn);
    return `The expression evaluated to a falsy value:\n\n  ${source}\n`;
  }
}

/**
 * @param {InnerFailOptions} obj
 */
function innerFail(obj) {
  if (obj.message.length === 0) {
    obj.message = undefined;
  } else if (typeof obj.message[0] === 'string') {
    if (obj.message.length > 1) {
      obj.message = format(...obj.message);
    } else {
      obj.message = obj.message[0];
    }
  } else if (isError(obj.message[0])) {
    if (obj.message.length > 1) {
      throw new ERR_AMBIGUOUS_ARGUMENT(
        'message',
        `The error message was passed as error object "${ErrorPrototypeToString(obj.message[0])}" has trailing arguments that would be ignored.`,
      );
    }
    throw obj.message[0];
  } else if (typeof obj.message[0] === 'function') {
    if (obj.message.length > 1) {
      throw new ERR_AMBIGUOUS_ARGUMENT(
        'message',
        `The error message with function "${obj.message[0].name || 'anonymous'}" has trailing arguments that would be ignored.`,
      );
    }
    try {
      obj.message = obj.message[0](obj.actual, obj.expected);
      if (typeof obj.message !== 'string') {
        obj.message = undefined;
      }
    } catch {
      // Ignore and use default message instead
      obj.message = undefined;
    }
  } else {
    throw new ERR_INVALID_ARG_TYPE(
      'message',
      ['string', 'function'],
      obj.message[0],
    );
  }

  const error = new AssertionError(obj);
  if (obj.generatedMessage !== undefined) {
    error.generatedMessage = obj.generatedMessage;
  }
  throw error;
}

/**
 * Internal ok handler delegating to innerFail for message handling.
 * @param {Function} fn
 * @param {...any} args
 */
function innerOk(fn, ...args) {
  if (!args[0]) {
    let generatedMessage = false;
    let messageArgs;

    if (args.length === 0) {
      generatedMessage = true;
      messageArgs = ['No value argument passed to `assert.ok()`'];
    } else if (args.length === 1 || args[1] == null) {
      generatedMessage = true;
      messageArgs = [getErrMessage(fn)];
    } else {
      messageArgs = args.slice(1);
    }

    innerFail({
      actual: args[0],
      expected: true,
      message: messageArgs,
      operator: '==',
      stackStartFn: fn,
      generatedMessage,
    });
  }
}

module.exports = {
  innerOk,
  innerFail,
};