(function () { /******************************************************************************/ /* TestCaseResults */ /******************************************************************************/ TestCaseResults = function (test_case, onEvent, onException, stop_at_offset) { var self = this; self.test_case = test_case; self.onEvent = onEvent; self.expecting_failure = false; self.current_fail_count = 0; self.stop_at_offset = stop_at_offset; self.onException = onException; self.id = Meteor.uuid(); }; _.extend(TestCaseResults.prototype, { ok: function (doc) { var self = this; var ok = {type: "ok"}; if (doc) ok.details = doc; if (self.expecting_failure) { ok.details = ok.details || {}; ok.details["was_expecting_failure"] = true; self.expecting_failure = false; } self.onEvent(ok); }, expect_fail: function () { var self = this; self.expecting_failure = true; }, fail: function (doc) { var self = this; if (self.stop_at_offset === 0) { if (Meteor.is_client) { // 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."); } self.stop_at_offset = null; } if (self.stop_at_offset) self.stop_at_offset--; self.onEvent({ type: (self.expecting_failure ? "expected_fail" : "fail"), details: doc, cookie: {name: self.test_case.name, offset: self.current_fail_count, groupPath: self.test_case.groupPath, shortName: self.test_case.shortName} }); self.expecting_failure = false; self.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: function (exception) { this.onException(exception); }, // returns a unique ID for this test run, for convenience use by // your tests runId: function () { return this.id; }, // === Following patterned after http://vowsjs.org/#reference === // 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 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 { matched = _.isEqual(expected, actual); expected = JSON.stringify(expected); actual = JSON.stringify(actual); } if (matched === !!not) { this.fail({type: "assert_equal", message: message, expected: expected, actual: actual, not: !!not}); } else this.ok(); }, notEqual: function (actual, expected, message) { this.equal(actual, expected, message, true); }, instanceOf: function (obj, klass) { if (obj instanceof klass) this.ok(); else this.fail({type: "instanceOf"}); // 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) this.ok({message: actual.message}); else this.fail({type: "throws"}); }, isTrue: function (v, msg) { if (v) this.ok(); else this.fail({type: "true", message: msg}); }, isFalse: function (v, msg) { if (v) this.fail({type: "true", message: msg}); else this.ok(); }, isNull: function (v, msg) { if (v === null) this.ok(); else this.fail({type: "null", message: msg}); }, isNotNull: function (v, msg) { if (v === null) this.fail({type: "true", message: msg}); else this.ok(); }, isUndefined: function (v, msg) { if (v === undefined) this.ok(); else this.fail({type: "undefined", message: msg}); }, isNaN: function (v, msg) { if (isNaN(v)) this.ok(); else this.fail({type: "NaN", message: msg}); }, include: function (s, v) { var pass = false; if (s instanceof Array) pass = _.indexOf(s, v) !== -1; else if (typeof s === "object") pass = v in s; else if (typeof s === "string") for (var i = 0; i < s.length; i++) if (s.charAt(i) === v) { pass = true; break; } else /* fail -- not something that contains other things */; if (pass) this.ok(); else this.fail({type: "include", sequence: s, should_contain_value: v}); }, // XXX should change to lengthOf to match vowsjs length: function (obj, expected_length) { if (obj.length === expected_length) this.ok(); else this.fail({type: "length", expected: expected_length, actual: obj.length}); } }); /******************************************************************************/ /* TestCase */ /******************************************************************************/ 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 asynchronously, delivering results via onEvent; // then call onComplete() on success, or else onException(e) if the // test raised (or voluntarily reported) an exception. run: function (onEvent, onComplete, onException, stop_at_offset) { var self = this; var completed = false; var markComplete = function () { if (completed) { Meteor._debug("*** Test error -- test '" + self.name + "' returned multiple times."); return false; } completed = true; return true; }; var results = new TestCaseResults(self, onEvent, function (e) { if (markComplete()) onException(e); }, stop_at_offset); Tinytest.defer(function () { try { if (self.async) { self.func(results, function () { if (markComplete()) onComplete(); }); } else { self.func(results); if (markComplete()) onComplete(); } } catch (e) { if (markComplete()) onException(e); } }); } }); /******************************************************************************/ /* TestManager */ /******************************************************************************/ 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 */ /******************************************************************************/ TestRun = function (manager, onReport) { var self = this; self.manager = manager; self.onReport = onReport; self.next_sequence_number = 0; _.each(self.manager.ordered_tests, function (test) { self._report(test); }); }; _.extend(TestRun.prototype, { _runOne: function (test, onComplete, stop_at_offset) { var self = this; var startTime = (+new Date); test.run(function (event) { /* onEvent */ self._report(test, event); }, function () { /* onComplete */ var totalTime = (+new Date) - startTime; self._report(test, {type: "finish", timeMs: totalTime}); onComplete && onComplete(); }, function (exception) { /* onException */ // XXX you want the "name" and "message" fields on the // exception, to start with.. self._report(test, { type: "exception", details: { message: exception.message, // XXX empty??? stack: exception.stack // XXX portability } }); onComplete && onComplete(); }, stop_at_offset); }, run: function (onComplete) { var self = this; // create array of arrays of tests; synchronous tests in // different groups are run in parallel on client, async tests or // tests in different groups are run in sequence, as are all // tests on server var testGroups = _.values( _.groupBy(self.manager.ordered_tests, function(t) { if (Meteor.is_server) return "SERVER"; if (t.async) return "ASYNC"; return t.name.split(" - ")[0]; })); if (! testGroups.length) { onComplete(); } else { var groupsDone = 0; _.each(testGroups, function(tests) { var runNext = function () { if (tests.length) { self._runOne(tests.shift(), runNext); } else { groupsDone++; if (groupsDone >= testGroups.length) 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, event) { var self = this; if (event) var events = [_.extend({sequence: self.next_sequence_number++}, event)]; else var events = []; self.onReport({ groupPath: test.groupPath, test: test.shortName, events: events }); } }); /******************************************************************************/ /* Public API */ /******************************************************************************/ var globals = this; globals.Tinytest = { add: function (name, func) { TestManager.addCase(new TestCase(name, func)); }, addAsync: function (name, func) { TestManager.addCase(new TestCase(name, func, true)); }, // Tinytest has versions of setTimeout et al. that preserve the // environment with write fence on the server. Unlike how // Meteor.setTimeout etc. are currently implemented, we *DO* // want to block method satisfaction. setTimeout: function (f, duration) { return setTimeout(Meteor.bindEnvironment(f, function (e) { Meteor._debug("Exception from setTimeout callback:", e.stack); }), duration); }, setInterval: function (f, duration) { return setInterval(Meteor.bindEnvironment(f, function (e) { Meteor._debug("Exception from setInterval callback:", e); }), duration); }, clearInterval: function(x) { return clearInterval(x); }, clearTimeout: function(x) { return clearTimeout(x); }, defer: function (f) { // Older Firefox will pass an argument to the setTimeout callback // function, indicating the "actual lateness." It's non-standard, // so for defer, standardize on not having it. globals.Tinytest.setTimeout(function () {f();}, 0); } }; // 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. Meteor._runTests = function (onReport, onComplete) { var testRun = TestManager.createRun(onReport); 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. Meteor._debugTest = function (cookie, onReport, onComplete) { var testRun = TestManager.createRun(onReport); testRun.debug(cookie, onComplete); }; })();