packages/tinytest/tinytest.js
import isEqual from "lodash.isequal";
/******************************************************************************/
/* 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<void>}
*/
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);
Tinytest._currentRunningTestName = test.name;
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 = {};
globalThis.__Tinytest = 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);
};
Tinytest._currentRunningTestName = ""
Meteor.methods({
'tinytest/getCurrentRunningTestName'() {
return Tinytest._currentRunningTestName;
}
})
Tinytest._getCurrentRunningTestOnServer = function () {
return Meteor.callAsync('tinytest/getCurrentRunningTestName');
}
Tinytest._getCurrentRunningTestOnClient = function () {
return Tinytest._currentRunningTestName;
}
// 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;