github.com/derat/nup@v0.0.0-20230418113745-15592ba7c620/test/web/unit/test.js (about)

     1  // Copyright 2021 Daniel Erat.
     2  // All rights reserved.
     3  
     4  // TODO: Decide if there's a cleaner way to do this. Right now, this stuff gets
     5  // shoved into globals so it can be accessed by exported functions in an
     6  // approximation of the style of other JS test frameworks.
     7  const initResult = newResult('init'); // errors outside of tests
     8  const allSuites = []; // Suite objects registered via suite()
     9  let curSuite = null; // Suite currently being added via suite()
    10  let results = []; // test results from current run
    11  let curResult = null; // in-progress test's result
    12  let lastDone = null; // promise resolve func from last async test
    13  
    14  // Suite contains a collection of tests.
    15  class Suite {
    16    constructor(name) {
    17      this.name = name;
    18      this.tests = []; // Test objects
    19      this.beforeAlls = [];
    20      this.afterAlls = [];
    21      this.beforeEaches = [];
    22      this.afterEaches = [];
    23    }
    24  
    25    // Sequentially run all tests in the suite.
    26    // If |testNameRegExp| is supplied, only the test with names matched by it
    27    // will be run.
    28    async runTests(testNameRegexp) {
    29      const tests = this.tests.filter(
    30        (t) =>
    31          testNameRegexp === undefined ||
    32          testNameRegexp.test(`${this.name}.${t.name}`)
    33      );
    34      if (!tests.length) return;
    35  
    36      try {
    37        this.beforeAlls.forEach((f) => f());
    38        for (const t of tests) {
    39          const fullName = `${this.name}.${t.name}`;
    40          console.log('-'.repeat(80));
    41          console.log(`Starting ${fullName}`);
    42          curResult = newResult(fullName);
    43          try {
    44            this.beforeEaches.forEach((f) => f());
    45            await t.run();
    46            this.afterEaches.forEach((f) => f());
    47          } finally {
    48            console.log(
    49              `Finished ${fullName} with ${curResult.errors.length} error(s)`
    50            );
    51            results.push(curResult);
    52            curResult = null;
    53          }
    54        }
    55      } finally {
    56        this.afterAlls.forEach((f) => f());
    57      }
    58    }
    59  }
    60  
    61  // Test contains an individual test.
    62  class Test {
    63    constructor(name, f) {
    64      this.name = name;
    65      this.func = f;
    66    }
    67  
    68    // Runs the test to completion. This could probably be simplified, but I'm
    69    // terrible at promises and async functions. :-/
    70    async run() {
    71      // First, create a promise to get a 'done' callback to pass to tests that
    72      // want one.
    73      await new Promise((done) => {
    74        try {
    75          // Save the 'done' callback to a global variable so it can be called by
    76          // the 'error' and 'unhandledrejection' handlers if an error happens in
    77          // e.g. a timeout, which runs in the window execution context.
    78          lastDone = done;
    79  
    80          Promise.resolve(this.func(done))
    81            .catch((reason) => {
    82              // This handles exceptions thrown directly from async tests.
    83              // It also handles rejected promises from async tests.
    84              if (reason instanceof Error) handleException(reason);
    85              else handleRejection(reason);
    86              done();
    87            })
    88            .then(() => {
    89              // If the test doesn't take any args, run the 'done' callback for it.
    90              if (!this.func.length) done();
    91            });
    92        } catch (err) {
    93          // This handles exceptions thrown directly from synchronous tests.
    94          handleException(err);
    95          done();
    96        }
    97      });
    98  
    99      lastDone = null;
   100    }
   101  }
   102  
   103  // Adds a test suite named |name|.
   104  // |f| is is executed immediately; it should call test() to define the
   105  // suite's tests.
   106  export function suite(name, f) {
   107    if (curSuite) throw new Error(`Already adding suite "${curSuite.name}"`);
   108    if (allSuites.some((e) => e.name === name)) {
   109      throw new Error(`Suite "${name}" already exists`);
   110    }
   111  
   112    const s = new Suite(name);
   113    try {
   114      curSuite = s;
   115      f();
   116      if (!s.tests.length) throw new Error('No tests defined');
   117      allSuites.push(s);
   118    } catch (err) {
   119      handleException(err);
   120    } finally {
   121      curSuite = null;
   122    }
   123  }
   124  
   125  // Adds |f| to run before all tests in the suite.
   126  // This must be called from within a function passed to suite().
   127  export function beforeAll(f) {
   128    if (!curSuite) throw new Error('beforeAll() called outside suite()');
   129    curSuite.beforeAlls.push(f);
   130  }
   131  
   132  // Adds |f| to run after all tests in the suite.
   133  // This must be called from within a function passed to suite().
   134  export function afterAll(f) {
   135    if (!curSuite) throw new Error('afterAll() called outside suite()');
   136    curSuite.afterAlls.push(f);
   137  }
   138  
   139  // Adds |f| to run before each tests in the suite.
   140  // This must be called from within a function passed to suite().
   141  export function beforeEach(f) {
   142    if (!curSuite) throw new Error('beforeEach() called outside suite()');
   143    curSuite.beforeEaches.push(f);
   144  }
   145  
   146  // Adds |f| to run after all tests in the suite.
   147  // This must be called from within a function passed to suite().
   148  export function afterEach(f) {
   149    if (!curSuite) throw new Error('afterEach() called outside suite()');
   150    curSuite.afterEaches.push(f);
   151  }
   152  
   153  // Adds a test named |name| with function |f|.
   154  // This must be called from within a function passed to suite().
   155  // If |f| accepts an argument, it will be passed a 'done' callback that it
   156  // must run upon completion. Additionally, |f| may be async.
   157  export function test(name, f) {
   158    if (!curSuite) throw new Error('test() called outside suite()');
   159    curSuite.tests.push(new Test(name, f));
   160  }
   161  
   162  // Extracts filename, line, and column from a stack trace line:
   163  // "    at ... (http://127.0.0.1:43559/test.js:101:15)"
   164  // "    at http://127.0.0.1:43963/example.test.js:21:7"
   165  const stackRegexp = /at .*\/([^/]+\.js):(\d+):(\d+)\)?$/;
   166  
   167  // Tries to get filename and line (e.g. 'foo.test.js:123') from |err|.
   168  // Uses the first stack frame not from this file.
   169  function getSource(err) {
   170    if (!err.stack) return '';
   171    for (const ln of err.stack.split('\n')) {
   172      var matches = stackRegexp.exec(ln);
   173      if (matches && matches[1] !== 'test.js') {
   174        return `${matches[1]}:${matches[2]}`;
   175      }
   176    }
   177    // TODO: Maybe it was triggered by test.js.
   178    return '';
   179  }
   180  
   181  // Adds an error to |curResult| if we're in a test or |initResult| otherwise.
   182  // |src| takes the form 'foo.test.js:23'.
   183  function addError(msg, src) {
   184    const err = { msg };
   185    if (src) err.src = src;
   186    (curResult || initResult).errors.push(err);
   187  }
   188  
   189  // Adds an error describing |err|.
   190  function handleException(err) {
   191    const src = getSource(err);
   192    console.error(`Exception from ${src || '[unknown]'}: ${err.toString()}`);
   193    const msg = err.toString() + ' (exception)';
   194    addError(msg, src);
   195  }
   196  
   197  // Adds an error describing |reason|.
   198  function handleRejection(reason) {
   199    const src = getSource(new Error());
   200    console.error(`Unhandled rejection from ${src || '[unknown]'}: ${reason}`);
   201    addError(`Unhandled rejection: ${reason}`, src);
   202  }
   203  
   204  // Returns a result named |name| to return from runTests().
   205  function newResult(name) {
   206    return { name, errors: [] };
   207  }
   208  
   209  // Fails the current test but continues running it.
   210  export function error(msg) {
   211    const src = getSource(new Error());
   212    console.error(`Error from ${src}: ${msg}`);
   213    addError(msg, src);
   214  }
   215  
   216  // Fails the current test and aborts it.
   217  export function fatal(msg) {
   218    throw new FatalError(msg);
   219  }
   220  
   221  // https://stackoverflow.com/a/32750746/6882947
   222  class FatalError extends Error {
   223    constructor(msg) {
   224      super(msg);
   225      this.name = 'Fatal';
   226    }
   227  }
   228  
   229  // Adds an error if |got| doesn't equal |want|, for some definition of "equal".
   230  // |desc| can contain a description of what's being compared.
   231  export function expectEq(got, want, desc) {
   232    if (
   233      typeof got === typeof want &&
   234      (typeof got === 'object'
   235        ? JSON.stringify(got) === JSON.stringify(want)
   236        : got === want)
   237    ) {
   238      return;
   239    }
   240    // TODO: Improve formatting, e.g. this prints '(object)' for an array.
   241    const gs = `${JSON.stringify(got)} (${typeof got})`;
   242    const ws = `${JSON.stringify(want)} (${typeof want})`;
   243    error(desc ? `${desc} is ${gs}; want ${ws}` : `Got ${gs}; want ${ws}`);
   244  }
   245  
   246  // expectThrows runs f and reports an error if an exception isn't thrown.
   247  export function expectThrows(f, desc) {
   248    let threw = false;
   249    try {
   250      f();
   251    } catch (e) {
   252      threw = true;
   253    }
   254    if (!threw) error(desc ? `${desc} didn't throw` : `Didn't throw`);
   255  }
   256  
   257  // Errors in the window execution context, e.g. exceptions thrown from timeouts
   258  // in either synchronous or async tests, trigger error events.
   259  window.addEventListener('error', (ev) => {
   260    handleException(ev.error);
   261    if (lastDone) lastDone();
   262  });
   263  
   264  // Unhandled promise rejections trigger unhandledrejection events.
   265  window.addEventListener('unhandledrejection', (ev) => {
   266    handleRejection(ev.reason);
   267    if (lastDone) lastDone();
   268  });
   269  
   270  // Runs tests and returns results as an array of objects:
   271  //
   272  // {
   273  //   name: 'suite.testName',
   274  //   errors: [
   275  //     {
   276  //       msg: 'foo() = 3; want 4',
   277  //       src: 'foo.test.js:23',
   278  //     },
   279  //     ...
   280  //   ],
   281  // }
   282  //
   283  // |testRegexp| is compiled to a regexp and executed against test names to determine
   284  // which ones run.
   285  export async function runTests(testRegexp = '') {
   286    results = [initResult];
   287    const re = new RegExp(testRegexp);
   288    for (const s of allSuites) await s.runTests(re);
   289    return Promise.resolve(results);
   290  }