Files
meteor/packages/tinytest/tinytest.js
2012-02-21 01:40:15 -08:00

386 lines
11 KiB
JavaScript

(function () {
var globals = (function () {return this;})();
/******************************************************************************/
/* TestCase */
/******************************************************************************/
var TestCase = function (name, func, async) {
var self = this;
self.name = name;
self.func = func;
self.async = async || false;
var nameParts = _.map(name.split(" - "), function(s) {
return s.replace(/^\s*|\s*$/g, ""); // trim
});
self.shortName = nameParts.pop();
nameParts.unshift("tinytest");
self.groupPath = nameParts;
};
_.extend(TestCase.prototype, {
// Run the test, then (asynchronously) call complete(). If the test
// throws an exception, will let that exception propagate up to the
// caller.
run: function (onComplete, onException) {
var self = this;
_.defer(function () {
if (self.async) {
try {
self.func(onComplete);
} catch (e) {
onException(e);
}
} else {
try {
self.func();
} catch (e) {
onException(e);
return;
}
onComplete();
}
});
}
});
/******************************************************************************/
/* TestManager */
/******************************************************************************/
var TestManager = function () {
var self = this;
self.tests = {};
self.ordered_tests = [];
};
_.extend(TestManager.prototype, {
addCase: function (test) {
var self = this;
if (test.name in self.tests)
throw new Error("Every test needs a unique name, but there are two tests named '" + name + "'");
self.tests[test.name] = test;
self.ordered_tests.push(test);
},
createRun: function (onReport) {
var self = this;
return new TestRun(self, onReport);
}
});
// singleton
TestManager = new TestManager;
/******************************************************************************/
/* TestRun */
/******************************************************************************/
// Previously we had functionality that would let you run up to a
// particular test, and then stop (open the debugger on the assert,
// report the exception, whatever.) It did this by counting calls to
// fail() within a particular test. It'd be nice to restore this.
var TestRun = function (manager, onReport) {
var self = this;
self.expecting_failure = false;
self.manager = manager;
self.onReport = onReport;
// XXX eliminate, so tests can run in parallel?
self.current_test = null;
self.current_fail_count = null;
self.stop_at_offset = null;
self.current_onException = null;
_.each(self.manager.ordered_tests, _.bind(self._report, self));
};
_.extend(TestRun.prototype, {
_runOne: function (test, onComplete, stopAtOffset) {
var self = this;
self._report(test);
self.current_test = test;
self.current_fail_count = 0;
self.stop_at_offset = stopAtOffset;
var original_assert = globals.assert;
globals.assert = test_assert;
var startTime = (+new Date);
var cleanup = function () {
globals.assert = original_assert;
self.current_test = null;
self.current_fail_count = null;
self.stop_at_offset = null;
self.current_onException = null;
};
self.current_onException = function (exception) {
cleanup();
// XXX you want the "name" and "message" fields on the
// exception, to start with..
self._report(test, {
events: [{
type: "exception",
details: {
message: exception.message, // XXX empty???
stack: exception.stack // XXX portability
}
}]
});
onComplete();
};
test.run(function () {
/* onComplete */
cleanup();
var totalTime = (+new Date) - startTime;
self._report(test, {events: [{type: "finish", timeMs: totalTime}]});
onComplete();
}, _.bind(self.current_onException, self));
},
run: function (onComplete) {
var self = this;
var tests = _.clone(self.manager.ordered_tests);
var runNext = function () {
if (tests.length)
self._runOne(tests.shift(), runNext);
else
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: function (cookie, onComplete) {
var self = this;
var test = self.manager.tests[cookie.name];
if (!test)
throw new Error("No such test '" + cookie.name + "'");
self._runOne(test, onComplete, cookie.offset);
},
_report: function (test, rest) {
var self = this;
self.onReport(_.extend({ groupPath: test.groupPath,
test: test.shortName },
rest));
},
ok: function (doc) {
var self = this;
var ok = {type: "ok"};
if (doc) {
ok.details = doc;
}
if (self.expecting_failure) {
ok.details["was_expecting_failure"] = true;
self.expecting_failure = false;
}
self._report(self.current_test, {events: [ok]});
},
expect_fail: function () {
var self = this;
self.expecting_failure = true;
},
fail: function (doc) {
var self = this;
if (self.stop_at_offset === 0) {
var now = (+new Date);
debugger;
if ((+new Date) - now < 100)
alert("To use this feature, first open the debugger window in your browser.");
self.stop_at_offset = null;
}
if (self.stop_at_offset)
self.stop_at_offset--;
self._report(self.current_test, {
events: [{
type: (self.expecting_failure ? "expected_fail" : "fail"),
details: doc,
cookie: {name: self.current_test.name, offset: self.current_fail_count,
groupPath: self.current_test.groupPath,
shortName: self.current_test.shortName}
}]});
self.expecting_failure = false;
self.current_fail_count++;
},
// Call this to fail the current 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: function (exception) {
var self = this;
if (!self.current_onException)
throw new Error("Not in a test");
self.current_onException(exception);
}
});
/******************************************************************************/
/* Helpers */
/******************************************************************************/
// Patterned after http://vowsjs.org/#reference
var test_assert = {
// XXX eliminate 'message' and 'not' arguments
equal: function (actual, expected, message, not) {
/* If expected is a DOM node, do a literal '===' comparison with
* actual. Otherwise compare the JSON stringifications of expected
* and actual. (It's no good to stringify a DOM node. Circular
* references, to start with..) */
// XXX WE REALLY SHOULD NOT BE USING
// STRINGIFY. stringify([undefined]) === stringify([null]). should use
// deep equality instead.
// XXX remove cruft specific to liverange
if (typeof expected === "object" && expected.nodeType) {
var matched = expected === actual;
expected = "[Node]";
actual = "[Unknown]";
} else {
expected = JSON.stringify(expected);
actual = JSON.stringify(actual);
var matched = expected === actual;
}
if (matched === !!not) {
test.fail({type: "assert_equal", message: message,
expected: expected, actual: actual, not: !!not});
} else
test.ok();
},
notEqual: function (actual, expected, message) {
test_assert.equal(actual, expected, message, true);
},
instanceOf: function (obj, klass) {
if (obj instanceof klass)
test.ok();
else
test.fail({type: "instanceOf"}); // XXX what other data?
},
// XXX should be length(), but on Chrome, functions always have a
// length property that is permanently 0 and can't be assigned to
// (it's a noop). How does vows do it??
length: function (obj, expected_length) {
if (obj.length === expected_length)
test.ok();
else
test.fail({type: "length"}); // XXX what other data?
},
// XXX nodejs assert.throws can take an expected error, as a class,
// regular expression, or predicate function..
throws: function (f) {
var actual;
try {
f();
} catch (exception) {
actual = exception;
}
if (actual)
test.ok({message: actual.message});
else
test.fail({type: "throws"});
},
isTrue: function (v) {
if (v)
test.ok();
else
test.fail({type: "true"});
},
isFalse: function (v) {
if (v)
test.fail({type: "true"});
else
test.ok();
}
};
/******************************************************************************/
/* Public API */
/******************************************************************************/
// XXX this API is confusing and irregular. revisit once we have
// package namespacing.
globals.test = function (name, func) {
TestManager.addCase(new TestCase(name, func));
};
globals.testAsync = function (name, func) {
TestManager.addCase(new TestCase(name, func, true));
};
var currentRun = null;
var reportFunc = function () {};
_.extend(globals.test, {
setReporter: function (_reportFunc) {
reportFunc = _reportFunc;
},
ok: function (doc) {
currentRun.ok(doc);
},
expect_fail: function () {
currentRun.expect_fail();
},
fail: function (doc) {
currentRun.fail(doc);
},
exception: function (exception) {
currentRun.exception(exception);
},
run: function (onComplete) {
if (currentRun)
throw new Error("Only one test run can be happening at once");
currentRun = TestManager.createRun(reportFunc);
currentRun.run(function () {
currentRun = null;
onComplete && onComplete();
});
},
debug: function (cookie, onComplete) {
if (currentRun)
throw new Error("Only one test run can be happening at once");
currentRun = TestManager.createRun(reportFunc);
currentRun.debug(cookie, function () {
currentRun = null;
onComplete && onComplete();
});
}
});
})();