/* Javascript doctest runner Copyright 2006-2010 Ian Bicking This program is free software; you can redistribute it and/or modify it under the terms of the MIT License. */ function doctest(verbosity/*default=0*/, elements/*optional*/, outputId/*optional*/) { var output = document.getElementById(outputId || 'doctestOutput'); var reporter = new doctest.Reporter(output, verbosity || 0); if (elements) { if (typeof elements == 'string') { // Treat it as an id elements = [document.getElementById(elementId)]; } if (! elements.length) { throw('No elements'); } var suite = new doctest.TestSuite(elements, reporter); } else { var els = doctest.getElementsByTagAndClassName('pre', 'doctest'); var suite = new doctest.TestSuite(els, reporter); } suite.run(); } doctest.runDoctest = function (el, reporter) { logDebug('Testing element', el); reporter.startElement(el); if (el === null) { throw('runDoctest() with a null element'); } var parsed = new doctest.Parser(el); var runner = new doctest.JSRunner(reporter); runner.runParsed(parsed); }; doctest.TestSuite = function (els, reporter) { if (this === window) { throw('you forgot new!'); } this.els = els; this.parsers = []; for (var i=0; i= this.testSuite.parsers.length) { logInfo('All examples from all sections tested'); this.runner.reporter.finish(); return; } logInfo('Testing example ' + (parserIndex+1) + ' of ' + this.testSuite.parsers.length); var runNext = function () { self.run(parserIndex+1); }; this.runner.runParsed(this.testSuite.parsers[parserIndex], 0, runNext); }; doctest.Parser = function (el) { if (this === window) { throw('you forgot new!'); } if (! el) { throw('Bad call to doctest.Parser'); } if (el.getAttribute('parsed-id')) { var examplesID = el.getAttribute('parsed-id'); if (doctest._allExamples[examplesID]) { this.examples = doctest._allExamples[examplesID]; return; } } var newHTML = document.createElement('span'); newHTML.className = 'doctest-example-set'; var examplesID = doctest.genID('example-set'); newHTML.setAttribute('id', examplesID); el.setAttribute('parsed-id', examplesID); var text = doctest.getText(el); var lines = text.split(/(?:\r\n|\r|\n)/); this.examples = []; var example_lines = []; var output_lines = []; for (var i=0; i/.test(line)) { if (! example_lines.length) { throw('Bad example: '+doctest.repr(line)+'\n' +'> line not preceded by $'); } line = line.substr(1).replace(/ *$/, '').replace(/^ /, ''); example_lines.push(line); } else { output_lines.push(line); } } if (example_lines.length) { var ex = new doctest.Example(example_lines, output_lines); this.examples.push(ex); newHTML.appendChild(ex.createSpan()); } el.innerHTML = ''; el.appendChild(newHTML); doctest._allExamples[examplesID] = this.examples; }; doctest._allExamples = {}; doctest.Example = function (example, output) { if (this === window) { throw('you forgot new!'); } this.example = example.join('\n'); this.output = output.join('\n'); this.htmlID = null; this.detailID = null; }; doctest.Example.prototype.createSpan = function () { var id = doctest.genID('example'); var span = document.createElement('span'); span.className = 'doctest-example'; span.setAttribute('id', id); this.htmlID = id; var exampleSpan = document.createElement('span'); exampleSpan.className = 'doctest-example-code'; var exampleLines = this.example.split(/\n/); for (var i=0; i 0) { if (this.verbosity > 1) { this.write('Trying:\n'); this.write(this.formatOutput(example.example)); this.write('Expecting:\n'); this.write(this.formatOutput(example.output)); this.write('ok\n'); } else { this.writeln(example.example + ' ... passed!'); } } this.success += 1; if ((example.output.indexOf('...') >= 0 || example.output.indexOf('?') >= 0) && output) { example.markExample('doctest-success', 'Output:\n' + output); } else { example.markExample('doctest-success'); } }; doctest.Reporter.prototype.reportFailure = function (example, output) { this.write('Failed example:\n'); this.write('' + this.formatOutput(example.example) +''); this.write('Expected:\n'); this.write(this.formatOutput(example.output)); this.write('Got:\n'); this.write(this.formatOutput(output)); this.failure += 1; example.markExample('doctest-failure', 'Actual output:\n' + output); }; doctest.Reporter.prototype.finish = function () { this.writeln((this.success+this.failure) + ' tests in ' + this.elements + ' items.'); if (this.failure) { var color = '#f00'; } else { var color = '#0f0'; } this.writeln('' + this.success + ' tests of ' + '' + (this.success+this.failure) + ' passed, ' + '' + this.failure + ' failed.'); }; doctest.Reporter.prototype.writeln = function (text) { this.write(text + '\n'); }; doctest.Reporter.prototype.write = function (text) { var leading = /^[ ]*/.exec(text)[0]; text = text.substr(leading.length); for (var i=0; i'); this.container.innerHTML += text; }; doctest.Reporter.prototype.formatOutput = function (text) { if (! text) { return ' (nothing)\n'; } var lines = text.split(/\n/); var output = ''; for (var i=0; i= parsed.examples.length) { if (finishedCallback) { finishedCallback(); } return; } var example = parsed.examples[index]; if (typeof example == 'undefined') { throw('Undefined example (' + (index+1) + ' of ' + parsed.examples.length + ')'); } doctest._waitCond = null; this.run(example); var finishThisRun = function () { self.finishRun(example); if (doctest._AbortCalled) { // FIXME: I need to find a way to make this more visible: logWarn('Abort() called'); return; } self.runParsed(parsed, index+1, finishedCallback); }; if (doctest._waitCond !== null) { if (typeof doctest._waitCond == 'number') { var condition = null; var time = doctest._waitCond; var maxTime = null; } else { var condition = doctest._waitCond; // FIXME: shouldn't be hard-coded var time = 100; var maxTime = doctest._waitTimeout || doctest.defaultTimeout; } var start = (new Date()).getTime(); var timeoutFunc = function () { if (condition === null || condition()) { finishThisRun(); } else { // Condition not met, try again soon... if ((new Date()).getTime() - start > maxTime) { // Time has run out var msg = 'Error: wait(' + repr(condition) + ') has timed out'; writeln(msg); logDebug(msg); logDebug('Timeout after ' + ((new Date()).getTime() - start) + ' milliseconds'); finishThisRun(); return; } setTimeout(timeoutFunc, time); } }; setTimeout(timeoutFunc, time); } else { finishThisRun(); } }; doctest.formatTraceback = function (e, skipFrames) { skipFrames = skipFrames || 0; var lines = []; if (typeof e == 'undefined' || !e) { var caughtErr = null; try { (null).foo; } catch (caughtErr) { e = caughtErr; } skipFrames++; } if (e.stack) { var stack = e.stack.split('\n'); for (var i=skipFrames; i ' + filename + ':' + lineno); } } } if (lines.length) { return lines; } else { return null; } }; doctest.logTraceback = function (e, skipFrames) { var tracebackLines = doctest.formatTraceback(e, skipFrames); if (! tracebackLines) { return; } for (var i=0; i 1) { // If it's only one line it's not worth showing this var check = this.showCheckDifference(got, expected); logWarn('Mismatch of output (line-by-line comparison follows)'); for (var i=0; i gotLines.length ? expectedLines.length : gotLines.length; function displayExpectedLine(line) { return line; line = line.replace(/\[a-zA-Z0-9_.\]\+/g, '?'); line = line.replace(/ \+/g, ' '); line = line.replace(/\(\?:\.\|\[\\r\\n\]\)\*/g, '...'); // FIXME: also unescape values? e.g., * became \* return line; } for (var i=0; i= expectedLines.length) { result.push('got extra line: ' + repr(gotLines[i])); continue; } else if (i >= gotLines.length) { result.push('expected extra line: ' + displayExpectedLine(expectedLines[i])); continue; } var gotLine = gotLines[i]; try { var expectRE = new RegExp('^' + expectedLines[i] + '$'); } catch (e) { result.push('regex match failed: ' + repr(gotLine) + ' (' + expectedLines[i] + ')'); continue; } if (gotLine.search(expectRE) != -1) { result.push('match: ' + repr(gotLine)); } else { result.push('no match: ' + repr(gotLine) + ' (' + displayExpectedLine(expectedLines[i]) + ')'); } } return result; }; // Should I really be setting this on RegExp? RegExp.escape = function (text) { if (!arguments.callee.sRE) { var specials = [ '/', '.', '*', '+', '?', '|', '(', ')', '[', ']', '{', '}', '\\' ]; arguments.callee.sRE = new RegExp( '(\\' + specials.join('|\\') + ')', 'g' ); } return text.replace(arguments.callee.sRE, '\\$1'); }; doctest.OutputCapturer = function () { if (this === window) { throw('you forgot new!'); } this.output = ''; }; doctest._output = null; doctest.OutputCapturer.prototype.capture = function () { doctest._output = this; }; doctest.OutputCapturer.prototype.stopCapture = function () { doctest._output = null; }; doctest.OutputCapturer.prototype.write = function (text) { if (typeof text == 'string') { this.output += text; } else { this.output += repr(text); } }; // Used to create unique IDs: doctest._idGen = 0; doctest.genID = function (prefix) { prefix = prefix || 'generic-doctest'; var id = doctest._idGen++; return prefix + '-' + doctest._idGen; }; doctest.writeln = function () { for (var i=0; i (maxLen - indentString.length)) { doctest._reprTrackRestore(restorer); return doctest.multilineObjRepr(obj, indentString, maxLen); } return ostring; }; doctest.multilineObjRepr = function (obj, indentString, maxLen) { var keys = doctest._sortedKeys(obj); var ostring = '{\n'; for (var i=0; i (maxLen + indentString.length)) { doctest._reprTrackRestore(restorer); return doctest.multilineArrayRepr(obj, indentString, maxLen); } return s; }; doctest.multilineArrayRepr = function (obj, indentString, maxLen) { var s = "[\n"; for (var i=0; i'; return s; }; doctest.repr.registry = [ [function (o) { return typeof o == 'string';}, function (o) { o = '"' + o.replace(/([\"\\])/g, '\\$1') + '"'; o = o.replace(/[\f]/g, "\\f") .replace(/[\b]/g, "\\b") .replace(/[\n]/g, "\\n") .replace(/[\t]/g, "\\t") .replace(/[\r]/g, "\\r"); return o; }], [function (o) { return typeof o == 'number';}, function (o) { return o + ""; }], [function (o) { return (typeof o == 'object' && o.xmlVersion); }, doctest.xmlRepr], [function (o) { var typ = typeof o; if ((typ != 'object' && ! (type == 'function' && typeof o.item == 'function')) || o === null || typeof o.length != 'number' || o.nodeType === 3) { return false; } return true; }, doctest.arrayRepr ]]; doctest.objDiff = function (orig, current) { var result = { added: {}, removed: {}, changed: {}, same: {} }; for (var i in orig) { if (! (i in current)) { result.removed[i] = orig[i]; } else if (orig[i] !== current[i]) { result.changed[i] = [orig[i], current[i]]; } else { result.same[i] = orig[i]; } } for (i in current) { if (! (i in orig)) { result.added[i] = current[i]; } } return result; }; doctest.writeDiff = function (orig, current, indentString) { if (typeof orig != 'object' || typeof current != 'object') { writeln(indentString + repr(orig, indentString) + ' -> ' + repr(current, indentString)); return; } indentString = indentString || ''; var diff = doctest.objDiff(orig, current); var i, keys; var any = false; keys = doctest._sortedKeys(diff.added); for (i=0; i ' + repr(diff.changed[keys[i]][1], indentString)); } if (! any) { writeln(indentString + '(no changes)'); } }; doctest.objectsEqual = function (ob1, ob2) { var i; if (typeof ob1 != 'object' || typeof ob2 != 'object') { return ob1 === ob2; } for (i in ob1) { if (ob1[i] !== ob2[i]) { return false; } } for (i in ob2) { if (! (i in ob1)) { return false; } } return true; }; doctest.getElementsByTagAndClassName = function (tagName, className, parent/*optional*/) { parent = parent || document; var els = parent.getElementsByTagName(tagName); var result = []; var regexes = []; if (typeof className == 'string') { className = [className]; } for (var i=0; i/g, '>'); }; doctest.escapeSpaces = function (s) { return s.replace(/ /g, '  '); }; doctest.extend = function (obj, extendWith) { for (i in extendWith) { obj[i] = extendWith[i]; } return obj; }; doctest.extendDefault = function (obj, extendWith) { for (i in extendWith) { if (typeof obj[i] == 'undefined') { obj[i] = extendWith[i]; } } return obj; }; if (typeof repr == 'undefined') { repr = doctest.repr; } doctest._consoleFunc = function (attr) { if (typeof window.console != 'undefined' && typeof window.console[attr] != 'undefined') { if (typeof console[attr].apply === 'function') { result = function() { console[attr].apply(console, arguments); }; } else { result = console[attr]; } } else { result = function () { // FIXME: do something }; } return result; }; if (typeof log == 'undefined') { log = doctest._consoleFunc('log'); } if (typeof logDebug == 'undefined') { logDebug = doctest._consoleFunc('log'); } if (typeof logInfo == 'undefined') { logInfo = doctest._consoleFunc('info'); } if (typeof logWarn == 'undefined') { logWarn = doctest._consoleFunc('warn'); } doctest.eval = function () { return window.eval.apply(window, arguments); }; doctest.useCoffeeScript = function (options) { options = options || {}; options.bare = true; options.globals = true; if (! options.fileName) { options.fileName = 'repl'; } if (typeof CoffeeScript == 'undefined') { doctest.logWarn('coffee-script.js is not included'); throw 'coffee-script.js is not included'; } doctest.eval = function (code) { var src = CoffeeScript.compile(code, options); logDebug('Compiled code to:', src); return window.eval(src); }; }; doctest.autoSetup = function (parent) { var tags = doctest.getElementsByTagAndClassName('div', 'test', parent); // First we'll make sure everything has an ID var tagsById = {}; for (var i=0; i 1) { // This makes the :target CSS work, since if the hash points to an // element whose id has just been added, it won't be noticed location.hash = location.hash; } var output = document.getElementById('doctestOutput'); if (! tags.length) { tags = document.getElementsByTagName('body'); } if (! output) { output = document.createElement('pre'); output.setAttribute('id', 'doctestOutput'); output.className = 'output'; tags[0].parentNode.insertBefore(output, tags[0]); } var reloader = document.getElementById('doctestReload'); if (! reloader) { reloader = document.createElement('button'); reloader.setAttribute('type', 'button'); reloader.setAttribute('id', 'doctest-testall'); reloader.innerHTML = 'test all'; reloader.onclick = function () { location.hash = '#doctest-testall'; location.reload(); }; output.parentNode.insertBefore(reloader, output); } }; doctest.autoSetup._idCount = 0; doctest.Spy = function (name, options, extraOptions) { var self; if (doctest.spies[name]) { self = doctest.spies[name]; if (! options && ! extraOptions) { return self; } } else { self = function () { return self.func.apply(this, arguments); }; } name = name || 'spy'; options = options || {}; if (typeof options == 'function') { options = {applies: options}; } if (extraOptions) { doctest.extendDefault(options, extraOptions); } doctest.extendDefault(options, doctest.defaultSpyOptions); self._name = name; self.options = options; self.called = false; self.calledWait = false; self.args = null; self.self = null; self.argList = []; self.selfList = []; self.writes = options.writes || false; self.returns = options.returns || null; self.applies = options.applies || null; self.binds = options.binds || null; self.throwError = options.throwError || null; self.ignoreThis = options.ignoreThis || false; self.wrapArgs = options.wrapArgs || false; self.func = function () { self.called = true; self.calledWait = true; self.args = doctest._argsToArray(arguments); self.self = this; self.argList.push(self.args); self.selfList.push(this); // It might be possible to get the caller? if (self.writes) { writeln(self.formatCall()); } if (self.throwError) { throw self.throwError; } if (self.applies) { return self.applies.apply(this, arguments); } return self.returns; }; self.func.toString = function () { return "Spy('" + self._name + "').func"; }; // Method definitions: self.formatCall = function () { var s = ''; if ((! self.ignoreThis) && self.self !== window && self.self !== self) { s += doctest.repr(self.self) + '.'; } s += self._name; if (self.args === null) { return s + ':never called'; } s += '('; for (var i=0; i'); } var loc = window.location.search.substring(1); if (auto || (/doctestRun/).exec(loc)) { var elements = null; // FIXME: we need to put the output near the specific test being tested: if (location.hash) { var el = document.getElementById(location.hash.substr(1)); if (el) { if (/\btest\b/.exec(el.className)) { var testEls = doctest.getElementsByTagAndClassName('pre', 'doctest', el); elements = doctest.getElementsByTagAndClassName('pre', ['doctest', 'setup']); for (var i=0; i