/******************************************************************************/ /* TestCaseResults */ /******************************************************************************/ export class TestCaseResults { constructor(test_case, onEvent, onException, stop_at_offset) { this.test_case = test_case; this.onEvent = onEvent; this.expecting_failure = false; this.current_fail_count = 0; this.stop_at_offset = stop_at_offset; this.onException = onException; this.id = Random.id(); this.extraDetails = {}; } ok(doc) { var ok = {type: "ok"}; if (doc) ok.details = doc; if (this.expecting_failure) { ok.details = ok.details || {}; ok.details["was_expecting_failure"] = true; this.expecting_failure = false; } this.onEvent(ok); } expect_fail() { this.expecting_failure = true; } fail(doc) { if (typeof doc === "string") { // Some very old code still tries to call fail() with a // string. Don't do this! doc = { type: "fail", message: doc }; } doc = { ...doc, ...this.extraDetails, }; if (this.stop_at_offset === 0) { if (Meteor.isClient) { // Only supported on the browser for now.. var now = (+new Date); debugger; if ((+new Date) - now < 100) alert("To use this feature, first enable your browser's debugger."); } this.stop_at_offset = null; } if (this.stop_at_offset) this.stop_at_offset--; // Get filename and line number of failure if we're using v8 (Chrome or // Node). if (Error.captureStackTrace) { var savedPrepareStackTrace = Error.prepareStackTrace; Error.prepareStackTrace = function(_, stack){ return stack; }; var err = new Error; Error.captureStackTrace(err); var stack = err.stack; Error.prepareStackTrace = savedPrepareStackTrace; for (var i = stack.length - 1; i >= 0; --i) { var frame = stack[i]; // Heuristic: use the OUTERMOST line which is in a :tests.js // file (this is less likely to be a test helper function). const fileName = frame.getFileName(); if (fileName && fileName.match(/:tests\.js/)) { doc.filename = fileName; doc.line = frame.getLineNumber(); break; } } } this.onEvent({ type: (this.expecting_failure ? "expected_fail" : "fail"), details: doc, cookie: {name: this.test_case.name, offset: this.current_fail_count, groupPath: this.test_case.groupPath, shortName: this.test_case.shortName} }); this.expecting_failure = false; this.current_fail_count++; } // Call this to fail the test with an exception. Use this to record // exceptions that occur inside asynchronous callbacks in tests. // // It should only be used with asynchronous tests, and if you call // this function, you should make sure that (1) the test doesn't // call its callback (onComplete function); (2) the test function // doesn't directly raise an exception. exception(exception) { this.onException(exception); } // returns a unique ID for this test run, for convenience use by // your tests runId() { return this.id; } // === Following patterned after http://vowsjs.org/#reference === // XXX eliminate 'message' and 'not' arguments equal(actual, expected, message, not) { if ((! not) && (typeof actual === 'string') && (typeof expected === 'string')) { this._stringEqual(actual, expected, message); return; } /* If expected is a DOM node, do a literal '===' comparison with * actual. Otherwise do a deep comparison, as implemented by _.isEqual. */ var matched; // XXX remove cruft specific to liverange if (typeof expected === "object" && expected && expected.nodeType) { matched = expected === actual; expected = "[Node]"; actual = "[Unknown]"; } else if (typeof Uint8Array !== 'undefined' && expected instanceof Uint8Array) { // I have no idea why but _.isEqual on Chrome horks completely on Uint8Arrays. // and the symptom is the chrome renderer taking up an entire CPU and freezing // your web page, but not pausing anywhere in _.isEqual. I don't understand it // but we fall back to a manual comparison if (!(actual instanceof Uint8Array)) this.fail({type: "assert_equal", message: "found object is not a typed array", expected: "A typed array", actual: actual.constructor.toString()}); if (expected.length !== actual.length) this.fail({type: "assert_equal", message: "lengths of typed arrays do not match", expected: expected.length, actual: actual.length}); for (var i = 0; i < expected.length; i++) { this.equal(actual[i], expected[i]); } } else { matched = EJSON.equals(expected, actual); } if (matched === !!not) { this.fail({type: "assert_equal", message: message, expected: JSON.stringify(expected), actual: JSON.stringify(actual), not: !!not}); } else this.ok(); } notEqual(actual, expected, message) { this.equal(actual, expected, message, true); } instanceOf(obj, klass, message) { if (obj instanceof klass) this.ok(); else this.fail({type: "instanceOf", message: message, not: false}); // XXX what other data? } notInstanceOf(obj, klass, message) { if (obj instanceof klass) this.fail({type: "instanceOf", message: message, not: true}); // XXX what other data? else this.ok(); } matches(actual, regexp, message) { if (regexp.test(actual)) this.ok(); else this.fail({type: "matches", message: message, actual: actual, regexp: regexp.toString(), not: false}); } notMatches(actual, regexp, message) { if (regexp.test(actual)) this.fail({type: "matches", message: message, actual: actual, regexp: regexp.toString(), not: true}); else this.ok(); } _assertActual(actual, predicate, message) { if (actual && predicate(actual)) this.ok(); else this.fail({ type: "throws", message: (actual ? "wrong error thrown: " + actual.message : "did not throw an error as expected") + (message ? ": " + message : ""), }); } _guessPredicate(expected) { let predicate; if (expected === undefined) { predicate = function () { return true; }; } else if (typeof expected === "string") { predicate = function (actual) { return typeof actual.message === "string" && actual.message.indexOf(expected) !== -1; }; } else if (expected instanceof RegExp) { predicate = function (actual) { return expected.test(actual.message); }; } else if (typeof expected === 'function') { predicate = expected; } else { throw new Error('expected should be a string, regexp, or predicate function'); } return predicate; } // expected can be: // undefined: accept any exception. // string: pass if the string is a substring of the exception message. // regexp: pass if the exception message passes the regexp. // function: call the function as a predicate with the exception. // // Note: Node's assert.throws also accepts a constructor to test // whether the error is of the expected class. But since // JavaScript can't distinguish between constructors and plain // functions and Node's assert.throws also accepts a predicate // function, if the error fails the instanceof test with the // constructor then the constructor is then treated as a predicate // and called (!) // // The upshot is, if you want to test whether an error is of a // particular class, use a predicate function. // throws(f, expected, message) { let actual; const predicate = this._guessPredicate(expected); try { f(); } catch (exception) { actual = exception; } this._assertActual(actual, predicate, message); } /** * Same as throw, but accepts an async function as a parameter. * @param f * @param expected * @param message * @returns {Promise} */ async throwsAsync(f, expected, message) { let actual; const predicate = this._guessPredicate(expected); try { await f(); } catch (exception) { actual = exception; } this._assertActual(actual, predicate, message); } isTrue(v, msg) { if (v) this.ok(); else this.fail({type: "true", message: msg, not: false}); } isFalse(v, msg) { if (v) this.fail({type: "true", message: msg, not: true}); else this.ok(); } isNull(v, msg) { if (v === null) this.ok(); else this.fail({type: "null", message: msg, not: false}); } isNotNull(v, msg) { if (v === null) this.fail({type: "null", message: msg, not: true}); else this.ok(); } isUndefined(v, msg) { if (v === undefined) this.ok(); else this.fail({type: "undefined", message: msg, not: false}); } isNotUndefined(v, msg) { if (v === undefined) this.fail({type: "undefined", message: msg, not: true}); else this.ok(); } isNaN(v, msg) { if (isNaN(v)) this.ok(); else this.fail({type: "NaN", message: msg, not: false}); } isNotNaN(v, msg) { if (isNaN(v)) this.fail({type: "NaN", message: msg, not: true}); else this.ok(); } include(s, v, message, not) { var pass = false; if (s instanceof Array) { pass = s.some(it => _.isEqual(v, it)); } else if (s && typeof s === "object") { pass = v in s; } else if (typeof s === "string") { if (s.indexOf(v) > -1) { pass = true; } } else { /* fail -- not something that contains other things */ } if (pass === ! not) { this.ok(); } else { this.fail({ type: "include", message, sequence: s, should_contain_value: v, not: !!not, }); } } notInclude(s, v, message) { this.include(s, v, message, true); } // XXX should change to lengthOf to match vowsjs length(obj, expected_length, msg) { if (obj.length === expected_length) { this.ok(); } else { this.fail({ type: "length", expected: expected_length, actual: obj.length, message: msg, }); } } // EXPERIMENTAL way to compare two strings that results in // a nicer display in the test runner, e.g. for multiline // strings _stringEqual(actual, expected, message) { if (actual !== expected) { this.fail({ type: "string_equal", message, expected, actual, }); } else { this.ok(); } } } /******************************************************************************/ /* TestCase */ /******************************************************************************/ export class TestCase { constructor(name, func) { this.name = name; this.func = func; var nameParts = name.split(" - ").map(s => { return s.replace(/^\s*|\s*$/g, ""); // trim }); this.shortName = nameParts.pop(); nameParts.unshift("tinytest"); this.groupPath = nameParts; } // Run the test asynchronously, delivering results via onEvent; // then call onComplete() on success, or else onException(e) if the // test raised (or voluntarily reported) an exception. run(onEvent, onComplete, onException, stop_at_offset) { let completed = false; return new Promise((resolve, reject) => { const results = new TestCaseResults( this, event => { // If this trace prints, it means you ran some test.* function // after the test finished! Another symptom will be that the // test will display as "waiting" even when it counts as passed // or failed. if (completed) { console.trace("event after complete!"); } return onEvent(event); }, reject, stop_at_offset ); const result = this.func(results, resolve); if (result && typeof result.then === "function") { result.then(resolve, reject); } }).then( () => { completed = true; onComplete(); }, error => { completed = true; onException(error); } ); } } /******************************************************************************/ /* TestManager */ /******************************************************************************/ export const TestManager = new (class TestManager { constructor() { this.tests = {}; this.ordered_tests = []; this.testQueue = Meteor.isServer && new Meteor._SynchronousQueue(); this.onlyTestsNames = []; } addCase(test, options = {}) { if (test.name in this.tests) throw new Error( "Every test needs a unique name, but there are two tests named '" + test.name + "'"); if (__meteor_runtime_config__.tinytestFilter && test.name.indexOf(__meteor_runtime_config__.tinytestFilter) === -1) { return; } if (options.isOnly) { this.onlyTestsNames.push(test.name); } this.tests[test.name] = test; this.ordered_tests.push(test); if (this.onlyTestsNames.length){ this.tests = Object.entries(this.tests).reduce((acc, [key, value]) => { if(this.onlyTestsNames.includes(key)){ return {...acc, [key]: value}; } return acc; }, {}); this.ordered_tests = this.ordered_tests.map(test => { if (this.onlyTestsNames.includes(test.name)) { return test; } return null; }).filter(Boolean); } } createRun(onReport, pathPrefix) { return new TestRun(this, onReport, pathPrefix); } }); if (Meteor.isServer && process.env.TINYTEST_FILTER) { __meteor_runtime_config__.tinytestFilter = process.env.TINYTEST_FILTER; } /******************************************************************************/ /* TestRun */ /******************************************************************************/ export class TestRun { constructor(manager, onReport, pathPrefix) { this.manager = manager; this.onReport = onReport; this.next_sequence_number = 0; this._pathPrefix = pathPrefix || []; this.manager.ordered_tests.forEach(test => { if (this._prefixMatch(test.groupPath)) this._report(test); }); } _prefixMatch(testPath) { for (var i = 0; i < this._pathPrefix.length; i++) { if (!testPath[i] || this._pathPrefix[i] !== testPath[i]) { return false; } } return true; } _runTest(test, onComplete, stop_at_offset) { var startTime = (+new Date); test.run(event => { /* onEvent */ // Ignore result callbacks if the test has already been reported // as timed out. if (test.timedOut) return; this._report(test, event); }, () => { /* onComplete */ if (test.timedOut) return; var totalTime = (+new Date) - startTime; this._report(test, {type: "finish", timeMs: totalTime}); onComplete(); }, exception => { /* onException */ if (test.timedOut) return; // XXX you want the "name" and "message" fields on the // exception, to start with.. this._report(test, { type: "exception", details: { message: exception.message, // XXX empty??? stack: exception.stack // XXX portability } }); onComplete(); }, stop_at_offset); } // Run a single test. On the server, ensure that only one test runs // at a time, even with multiple clients submitting tests. However, // time out the test after three minutes to avoid locking up the // server if a test fails to complete. // _runOne(test, onComplete, stop_at_offset) { if (! this._prefixMatch(test.groupPath)) { onComplete && onComplete(); return; } if (Meteor.isServer) { this.manager.testQueue.queueTask(() => { // On the server, ensure that only one test runs at a time, even // with multiple clients. let hasRan = false; const timeoutPromise = new Promise((resolve) => { Meteor.setTimeout(() => { if (!hasRan) { test.timedOut = true; this._report(test, { type: "exception", details: { message: "test timed out" } }); } resolve(); }, 3 * 60 * 1000); }); const runnerPromise = new Promise((resolve) => { this._runTest(test, () => { if (!hasRan) { hasRan = true; } resolve(); }, stop_at_offset); }); Promise.race([runnerPromise, timeoutPromise]).finally(() => { onComplete && onComplete(); }); }); } else { // client this._runTest(test, () => { onComplete && onComplete(); }, stop_at_offset); } } run(onComplete) { var tests = this.manager.ordered_tests.slice(0); var reportCurrent = function (name) { if (Meteor.isClient) Tinytest._onCurrentClientTest(name); }; const runNext = () => { if (tests.length) { var t = tests.shift(); reportCurrent(t.name); this._runOne(t, runNext); } else { reportCurrent(null); onComplete && onComplete(); } }; runNext(); } // An alternative to run(). Given the 'cookie' attribute of a // failure record, try to rerun that particular test up to that // failure, and then open the debugger. debug(cookie, onComplete) { var test = this.manager.tests[cookie.name]; if (!test) throw new Error("No such test '" + cookie.name + "'"); this._runOne(test, onComplete, cookie.offset); } _report(test, event) { let events; if (event) { events = [{ sequence: this.next_sequence_number++, ...event }]; } else { events = []; } this.onReport({ groupPath: test.groupPath, test: test.shortName, events, }); } } /******************************************************************************/ /* Public API */ /******************************************************************************/ export const Tinytest = {}; Tinytest.addAsync = function (name, func, options) { TestManager.addCase(new TestCase(name, func), options); }; Tinytest.onlyAsync = function (name, func) { Tinytest.addAsync(name, func, { isOnly: true }); }; Tinytest.add = function (name, func, options) { Tinytest.addAsync(name, function (test, onComplete) { func(test); onComplete(); }, options); }; Tinytest.only = function (name, func) { Tinytest.add(name, func, { isOnly: true }); }; // Run every test, asynchronously. Runs the test in the current // process only (if called on the server, runs the tests on the // server, and likewise for the client.) Report results via // onReport. Call onComplete when it's done. // Tinytest._runTests = function (onReport, onComplete, pathPrefix) { var testRun = TestManager.createRun(onReport, pathPrefix); testRun.run(onComplete); }; // Run just one test case, and stop the debugger at a particular // error, all as indicated by 'cookie', which will have come from a // failure event output by _runTests. // Tinytest._debugTest = function (cookie, onReport, onComplete) { var testRun = TestManager.createRun(onReport); testRun.debug(cookie, onComplete); }; // Replace this callback to get called when we run a client test, // and then called with `null` when the client tests are // done. This is used to provide a live display of the current // running client test on the test results page. Tinytest._onCurrentClientTest = function (name) {}; Tinytest._TestCaseResults = TestCaseResults; Tinytest._TestCase = TestCase; Tinytest._TestManager = TestManager; Tinytest._TestRun = TestRun;