testy.js

import process from 'node:process';
import console from 'node:console';
import { TestRunner } from './core/test_runner.js';
import { Asserter, FailureGenerator, PendingMarker } from './core/asserter.js';
import { ConsoleUI } from './ui/console_ui.js';
import { allFilesMatching, errorDetailOf, resolvePathFor } from './utils.js';
import { I18nMessage } from './i18n/i18n_messages.js';

const ui = new ConsoleUI(process, console);
const testRunner = new TestRunner(ui.testRunnerCallbacks());

/**
 * Object used for writing assertions. [Assertions]{@link Assertion} are created with method calls to this object.
 * Please refer to the comment of each assertion for more information.
 *
 * @example
 * assert.isFalse(3 > 4)
 * @example
 * assert.that(['hey']).isNotEmpty()
 *
 * @type {Asserter}
 */
const assert = new Asserter(testRunner);

/**
 * Generates an explicit failure.
 *
 * @example
 * fail.with('a descriptive message')
 *
 * @type {FailureGenerator}
 */
const fail = new FailureGenerator(testRunner);

/**
 * Marks a test as pending, which is a status that is reported separately, and it's not considered success/failure.
 * It is useful to mark in-progress work and catch the developers attention in the resulting report.
 *
 * @example
 * pending.dueTo('finish the set-up process')
 *
 * @type {PendingMarker}
 */
const pending = new PendingMarker(testRunner);

/**
 * Defines a new test. Each test belongs to a [test suite]{@link suite} and defines assertions in the body.
 *
 * For info about assertions, take a look at the {@link assert} object.
 *
 * Tests are represented internally as instances of {@link Test}.
 *
 * @example
 * test('arithmetic works', () => {
 *   assert.areEqual(3 + 4, 7);
 * });
 *
 * @param {!string} name how you would like to call the test. Non-empty string.
 * @param {!function} testBody the test definition, written as a zero-argument function.
 * @returns {Test}
 */
function test(name, testBody) {
  return testRunner.registerTest(name, testBody, ui.testCallbacks());
}

/**
 * Defines a new test suite. Suites are expected to define [tests]{@link test} inside it.
 * There can be more than one suite per file, but it is not possible to nest suites.
 *
 * @example
 * suite('arithmetic operations', () => {
 *   test('the sum of two number works', () => {
 *     assert.areEqual(3 + 4, 7);
 *   });
 * });
 *
 * @param {!string} name how you would like to call the suite. Non-empty string.
 * @param {!function} suiteBody the suite definition, written as a zero-argument function.
 * @returns {TestSuite}
 */
function suite(name, suiteBody) {
  return testRunner.registerSuite(name, suiteBody, ui.suiteCallbacks());
}

/**
 * Specifies a piece of code that should be executed before each {@link test} in a {@link suite}.
 * Only one before block is allowed per suite. The most common use of this feature is to initialize objects you want
 * to reference in each test.
 *
 * @example
 * suite('using a before() initializer', () => {
 *   let number;
 *
 *   before(() => {
 *     number = 42;
 *   });
 *
 *   test('has the initialized value', () => {
 *     assert.that(number).isEqualTo(42);
 *   });
 * });
 *
 * @param {!function} initialization a zero argument function containing the code you want to execute before each test.
 * @returns {void}
 */
function before(initialization) {
  testRunner.registerBefore(initialization);
}

/**
 * Specifies a piece of code that should be executed after each {@link test} in a {@link suite}.
 * Only one after block is allowed per suite. The most common use of this feature is to ensure you are releasing
 * resources that are used on each test, like files.
 *
 * @param {!function} releaseBlock a zero argument function containing the code you want to execute after each test.
 * @returns {void}
 */
function after(releaseBlock) {
  testRunner.registerAfter(releaseBlock);
}

class Testy {
  #configuration;
  #requestedPaths;

  // instance creation

  static configuredWith(configuration) {
    return new Testy(configuration);
  }

  constructor(configuration) {
    this.#initializeConfiguredWith(configuration);
  }

  // running

  async run(requestedPaths) {
    this.#requestedPaths = requestedPaths;
    await this.#loadAllRequestedFiles();
    ui.start(this.#configuration, this.#testFilesPathsToRun());
    try {
      await testRunner.run();
    } catch (err) {
      ui.exitWithError(I18nMessage.of('error_running_suites'), errorDetailOf(err));
    }
  }

  // initialization

  #initializeConfiguredWith(configuration) {
    this.#configuration = configuration;
    ui.configureWith(this.#configuration);
    testRunner.configureWith(this.#configuration);
  }

  // private

  async #loadAllRequestedFiles() {
    try {
      // eslint-disable-next-line no-restricted-syntax
      for (const path of this.#resolvedTestFilesPathsToRun()) {
        // eslint-disable-next-line no-await-in-loop
        await this.#loadAllFilesIn(path);
      }
    } catch (err) {
      ui.exitWithError(I18nMessage.of('error_path_not_found', err.path));
    }
  }

  async #loadAllFilesIn(path) {
    // eslint-disable-next-line no-restricted-syntax
    for (const file of allFilesMatching(path, this.#testFilesFilter())) {
      // eslint-disable-next-line no-await-in-loop
      await this.#loadFileHandlingErrors(file);
    }
  }

  async #loadFileHandlingErrors(file) {
    try {
      await import(file);
    } catch (err) {
      ui.exitWithError(
        I18nMessage.of('error_loading_suite', file), errorDetailOf(err),
        I18nMessage.of('feedback_for_error_loading_suite'),
      );
    }
  }

  #testFilesPathsToRun() {
    const requestedPaths = this.#requestedPaths;
    return requestedPaths.length > 0 ? requestedPaths : [this.#pathForAllTests()];
  }

  #resolvedTestFilesPathsToRun() {
    return this.#testFilesPathsToRun().map(path => resolvePathFor(path));
  }

  #pathForAllTests() {
    return this.#configuration.directory();
  }

  #testFilesFilter() {
    return this.#configuration.filter();
  }
}

export { Testy, suite, test, assert, fail, pending, before, after };