diff --git a/spec/html/HTMLReporterSpec.js b/spec/html/HTMLReporterSpec.js
new file mode 100644
index 00000000..84ee3a96
--- /dev/null
+++ b/spec/html/HTMLReporterSpec.js
@@ -0,0 +1,194 @@
+describe("HtmlReporter", function() {
+ var env;
+ var htmlReporter;
+ var body;
+ var fakeDocument;
+
+ beforeEach(function() {
+ env = new jasmine.Env();
+ env.updateInterval = 0;
+
+ body = document.createElement("body");
+ fakeDocument = { body: body, location: { search: "" } };
+ htmlReporter = new jasmine.HtmlReporter(fakeDocument);
+ });
+
+ function fakeSpec(name) {
+ return {
+ getFullName: function() {
+ return name;
+ }
+ };
+ }
+
+ function findElements(divs, withClass) {
+ var els = [];
+ for (var i = 0; i < divs.length; i++) {
+ if (divs[i].className == withClass) els.push(divs[i]);
+ }
+ return els;
+ }
+
+ function findElement(divs, withClass) {
+ var els = findElements(divs, withClass);
+ if (els.length > 0) {
+ return els[0];
+ }
+ throw new Error("couldn't find div with class " + withClass);
+ }
+
+ it("should run only specs beginning with spec parameter", function() {
+ fakeDocument.location.search = "?spec=run%20this";
+ expect(htmlReporter.specFilter(fakeSpec("run this"))).toBeTruthy();
+ expect(htmlReporter.specFilter(fakeSpec("not the right spec"))).toBeFalsy();
+ expect(htmlReporter.specFilter(fakeSpec("not run this"))).toBeFalsy();
+ });
+
+ describe('Matcher reporting', function () {
+ var getResultMessageDiv = function (body) {
+ var divs = body.getElementsByTagName("div");
+ for (var i = 0; i < divs.length; i++) {
+ if (divs[i].className.match(/resultMessage/)) {
+ return divs[i];
+ }
+ }
+ };
+
+ var runner, spec, fakeTimer;
+ beforeEach(function () {
+ fakeTimer = new jasmine.FakeTimer();
+ env.setTimeout = fakeTimer.setTimeout;
+ env.clearTimeout = fakeTimer.clearTimeout;
+ env.setInterval = fakeTimer.setInterval;
+ env.clearInterval = fakeTimer.clearInterval;
+ runner = env.currentRunner();
+ var suite = new jasmine.Suite(env, 'some suite');
+ runner.add(suite);
+ spec = new jasmine.Spec(env, suite, 'some spec');
+ suite.add(spec);
+ fakeDocument.location.search = "?";
+ env.addReporter(htmlReporter);
+ });
+
+ describe('toContain', function () {
+ it('should show actual and expected', function () {
+ spec.runs(function () {
+ this.expect('foo').toContain('bar');
+ });
+ runner.execute();
+ fakeTimer.tick(0);
+
+ var resultEl = getResultMessageDiv(body);
+ expect(resultEl.innerHTML).toMatch(/foo/);
+ expect(resultEl.innerHTML).toMatch(/bar/);
+ });
+ });
+ });
+
+ describe("failure messages (integration)", function () {
+ var spec, results, expectationResult;
+
+ it("should add the failure message to the DOM (non-toEquals matchers)", function() {
+ env.describe("suite", function() {
+ env.it("will have log messages", function() {
+ this.expect('a').toBeNull();
+ });
+ });
+
+ env.addReporter(htmlReporter);
+ env.execute();
+
+ var divs = body.getElementsByTagName("div");
+ var errorDiv = findElement(divs, 'resultMessage fail');
+ expect(errorDiv.innerHTML).toMatch(/Expected 'a' to be null/);
+ });
+
+ it("should add the failure message to the DOM (non-toEquals matchers) html escaping", function() {
+ env.describe("suite", function() {
+ env.it("will have log messages", function() {
+ this.expect('1 < 2').toBeNull();
+ });
+ });
+
+ env.addReporter(htmlReporter);
+ env.execute();
+
+ var divs = body.getElementsByTagName("div");
+ var errorDiv = findElement(divs, 'resultMessage fail');
+ expect(errorDiv.innerHTML).toMatch(/Expected '1 < 2' to be null/);
+ });
+ });
+
+ describe("log messages", function() {
+ it("should appear in the report of a failed spec", function() {
+ env.describe("suite", function() {
+ env.it("will have log messages", function() {
+ this.log("this is a", "multipart log message");
+ this.expect(true).toBeFalsy();
+ });
+ });
+
+ env.addReporter(htmlReporter);
+ env.execute();
+
+ var divs = body.getElementsByTagName("div");
+ var errorDiv = findElement(divs, 'specDetail failed');
+ expect(errorDiv.innerHTML).toMatch("this is a multipart log message");
+ });
+
+ xit("should work on IE without console.log.apply", function() {
+ });
+ });
+
+ describe("duplicate example names", function() {
+ it("should report failures correctly", function() {
+ var suite1 = env.describe("suite", function() {
+ env.it("will have log messages", function() {
+ this.log("this one fails!");
+ this.expect(true).toBeFalsy();
+ });
+ });
+
+ var suite2 = env.describe("suite", function() {
+ env.it("will have log messages", function() {
+ this.log("this one passes!");
+ this.expect(true).toBeTruthy();
+ });
+ });
+
+ env.addReporter(htmlReporter);
+ env.execute();
+
+ var divs = body.getElementsByTagName("div");
+ var failedSpecDiv = findElement(divs, 'specDetail failed');
+ expect(failedSpecDiv.className).toEqual('specDetail failed');
+ expect(failedSpecDiv.innerHTML).toContain("this one fails!");
+ expect(failedSpecDiv.innerHTML).not.toContain("this one passes!");
+ });
+ });
+
+ describe('#reportSpecStarting', function() {
+ beforeEach(function () {
+ env.describe("suite 1", function() {
+ env.it("spec 1", function() {
+ });
+ });
+ spyOn(htmlReporter, 'log').andCallThrough();
+ });
+
+ it('DOES NOT log running specs by default', function() {
+ env.addReporter(htmlReporter);
+ env.execute();
+
+ expect(htmlReporter.log).not.toHaveBeenCalled();
+ });
+
+ it('logs running specs when log_running_specs is true', function() {
+ htmlReporter.logRunningSpecs = true;
+ env.addReporter(htmlReporter);
+ env.execute();
+
+ expect(htmlReporter.log).toHaveBeenCalledWith('>> Jasmine Running suite 1 spec 1...');
+ });
+ });
+});
diff --git a/src/html/HtmlReporter.js b/src/html/HtmlReporter.js
new file mode 100644
index 00000000..ce92ca11
--- /dev/null
+++ b/src/html/HtmlReporter.js
@@ -0,0 +1,101 @@
+jasmine.HtmlReporter = function(_doc) {
+ var self = this;
+ var doc = _doc || window.document;
+
+ var reporterView;
+
+ var dom = {};
+
+ // Jasmine Reporter Public Interface
+ self.logRunningSpecs = false;
+
+ self.reportRunnerStarting = function(runner) {
+ var specs = runner.specs() || [];
+
+ if (specs.length == 0) {
+ return;
+ }
+
+ createReporterDom(runner.env.versionString());
+ doc.body.appendChild(dom.reporter);
+
+ reporterView = new jasmine.HtmlReporter.ReporterView(dom);
+ reporterView.addSpecs(specs, self.specFilter);
+ };
+
+ self.reportRunnerResults = function(runner) {
+ reporterView.complete();
+ };
+
+ self.reportSuiteResults = function(suite) {
+ reporterView.suiteComplete(suite);
+ };
+
+ self.reportSpecStarting = function(spec) {
+ if (self.logRunningSpecs) {
+ self.log('>> Jasmine Running ' + spec.suite.description + ' ' + spec.description + '...');
+ }
+ };
+
+ self.reportSpecResults = function(spec) {
+ reporterView.specComplete(spec);
+ };
+
+ self.log = function() {
+ var console = jasmine.getGlobal().console;
+ if (console && console.log) {
+ if (console.log.apply) {
+ console.log.apply(console, arguments);
+ } else {
+ console.log(arguments); // ie fix: console.log.apply doesn't exist on ie
+ }
+ }
+ };
+
+ self.specFilter = function(spec) {
+ if (!focusedSpecName()) {
+ return true;
+ }
+
+ return spec.getFullName().indexOf(focusedSpecName()) === 0;
+ };
+
+ return self;
+
+ function focusedSpecName() {
+ var specName;
+
+ (function memoizeFocusedSpec() {
+ if (specName) {
+ return;
+ }
+
+ var paramMap = [];
+ var params = doc.location.search.substring(1).split('&');
+
+ for (var i = 0; i < params.length; i++) {
+ var p = params[i].split('=');
+ paramMap[decodeURIComponent(p[0])] = decodeURIComponent(p[1]);
+ }
+
+ specName = paramMap.spec;
+ })();
+
+ return specName;
+ }
+
+ function createReporterDom(version) {
+ dom.reporter = self.createDom('div', { className: 'jasmine_reporter' },
+ dom.banner = self.createDom('div', { className: 'banner' },
+ self.createDom('span', { className: 'title' }, "Jasmine "),
+ self.createDom('span', { className: 'version' }, version)),
+
+ dom.symbolSummary = self.createDom('ul', {className: 'symbolSummary'}),
+ dom.alert = self.createDom('div', {className: 'alert'}),
+ dom.results = self.createDom('div', {className: 'results'},
+ dom.summary = self.createDom('div', { className: 'summary' }),
+ dom.details = self.createDom('div', { id: 'details' }))
+ );
+ }
+};
+jasmine.HtmlReporterHelpers.addHelpers(jasmine.HtmlReporter);
\ No newline at end of file
diff --git a/src/html/HtmlReporterHelpers.js b/src/html/HtmlReporterHelpers.js
new file mode 100644
index 00000000..745e1e09
--- /dev/null
+++ b/src/html/HtmlReporterHelpers.js
@@ -0,0 +1,60 @@
+jasmine.HtmlReporterHelpers = {};
+
+jasmine.HtmlReporterHelpers.createDom = function(type, attrs, childrenVarArgs) {
+ var el = document.createElement(type);
+
+ for (var i = 2; i < arguments.length; i++) {
+ var child = arguments[i];
+
+ if (typeof child === 'string') {
+ el.appendChild(document.createTextNode(child));
+ } else {
+ if (child) {
+ el.appendChild(child);
+ }
+ }
+ }
+
+ for (var attr in attrs) {
+ if (attr == "className") {
+ el[attr] = attrs[attr];
+ } else {
+ el.setAttribute(attr, attrs[attr]);
+ }
+ }
+
+ return el;
+};
+
+jasmine.HtmlReporterHelpers.getSpecStatus = function(child) {
+ var results = child.results();
+ var status = results.passed() ? 'passed' : 'failed';
+ if (results.skipped) {
+ status = 'skipped';
+ }
+
+ return status;
+};
+
+jasmine.HtmlReporterHelpers.appendToSummary = function(child, childElement) {
+ var parentDiv = this.dom.summary;
+ var parentSuite = (typeof child.parentSuite == 'undefined') ? 'suite' : 'parentSuite';
+ var parent = child[parentSuite];
+
+ if (parent) {
+ if (typeof this.views.suites[parent.id] == 'undefined') {
+ this.views.suites[parent.id] = new jasmine.HtmlReporter.SuiteView(parent, this.dom, this.views);
+ }
+ parentDiv = this.views.suites[parent.id].element;
+ }
+
+ parentDiv.appendChild(childElement);
+};
+
+
+jasmine.HtmlReporterHelpers.addHelpers = function(ctor) {
+ for(var fn in jasmine.HtmlReporterHelpers) {
+ ctor.prototype[fn] = jasmine.HtmlReporterHelpers[fn];
+ }
+};
+
diff --git a/src/html/ReporterView.js b/src/html/ReporterView.js
new file mode 100644
index 00000000..80178eec
--- /dev/null
+++ b/src/html/ReporterView.js
@@ -0,0 +1,150 @@
+jasmine.HtmlReporter.ReporterView = function(dom) {
+ this.startedAt = new Date();
+ this.runningSpecCount = 0;
+ this.completeSpecCount = 0;
+ this.passedCount = 0;
+ this.failedCount = 0;
+ this.skippedCount = 0;
+
+ this.createResultsMenu = function() {
+ this.resultsMenu = this.createDom('span', {className: 'resultsMenu bar'},
+ this.summaryMenuItem = this.createDom('a', {className: 'summaryMenuItem', href: "#"}, '0 specs'),
+ ' | ',
+ this.detailsMenuItem = this.createDom('a', {className: 'detailsMenuItem', href: "#"}, '0 failing'));
+
+ this.summaryMenuItem.onclick = function() {
+ dom.reporter.className = dom.reporter.className.replace(/ showDetails/g, '');
+ };
+
+ this.detailsMenuItem.onclick = function() {
+ showDetails();
+ };
+ };
+
+ this.specComplete = function(spec) {
+ this.completeSpecCount++;
+ var specView = this.views.specs[spec.id];
+
+ switch (specView.status()) {
+ case 'passed':
+ this.passedCount++;
+ break;
+
+ case 'failed':
+ this.failedCount++;
+ break;
+
+ case 'skipped':
+ this.skippedCount++;
+ break;
+ }
+
+ specView.refresh();
+ this.refresh();
+ };
+
+ this.suiteComplete = function(suite) {
+ var suiteView = this.views.suites[suite.id];
+ if (isUndefined(suiteView)) {
+ return;
+ }
+ suiteView.refresh();
+ };
+
+ this.refresh = function() {
+
+ if (isUndefined(this.resultsMenu)) {
+ this.createResultsMenu();
+ }
+
+ // currently running UI
+ if (isUndefined(this.runningAlert)) {
+ this.runningAlert = this.createDom('a', {href: "?", className: "runningAlert bar"});
+ dom.alert.appendChild(this.runningAlert);
+ }
+ this.runningAlert.innerHTML = "Running " + this.completeSpecCount + " of " + this.totalSpecCount + " spec" + (this.totalSpecCount == 1 ? "" : "s" );
+
+ // skipped specs UI
+ if (isUndefined(this.skippedAlert)) {
+ this.skippedAlert = this.createDom('a', {href: "?", className: "skippedAlert bar"});
+ }
+
+ this.skippedAlert.innerHTML = "Skipping " + this.skippedCount + " of " + this.totalSpecCount + " spec" + (this.totalSpecCount == 1 ? "" : "s" ) + " - run all";
+
+ if (this.skippedCount === 1 && isDefined(dom.alert)) {
+ dom.alert.appendChild(this.skippedAlert);
+ }
+
+ // passing specs UI
+ if (isUndefined(this.passedAlert)) {
+ this.passedAlert = this.createDom('span', {href: "?", className: "passingAlert bar"});
+ }
+ this.passedAlert.innerHTML = "Passing " + this.passedCount + " spec" + (this.passedCount == 1 ? "" : "s" );
+
+ // failing specs UI
+ if (isUndefined(this.failedAlert)) {
+ this.failedAlert = this.createDom('span', {href: "?", className: "failingAlert bar"});
+ }
+ this.failedAlert.innerHTML = "Failing " + this.failedCount + " spec" + (this.totalSpecCount == 1 ? "" : "s" );
+
+ if (this.failedCount === 1 && isDefined(dom.alert)) {
+ dom.alert.appendChild(this.failedAlert);
+ dom.alert.appendChild(this.resultsMenu);
+ }
+
+ // summary info
+ this.summaryMenuItem.innerHTML = "" + this.runningSpecCount + " spec" + (this.runningSpecCount == 1 ? "" : "s" );
+ this.detailsMenuItem.innerHTML = "" + this.failedCount + " failing";
+ };
+
+ this.complete = function() {
+ dom.alert.removeChild(this.runningAlert);
+
+ this.skippedAlert.innerHTML = "Ran " + this.runningSpecCount + " of " + this.totalSpecCount + " spec" + (this.totalSpecCount == 1 ? "" : "s" ) + " - run all";
+
+ if (this.failedCount === 0) {
+ dom.alert.appendChild(this.createDom('span', {className: 'passingAlert bar'}, "Passing " + this.passedCount + " spec" + (this.passedCount == 1 ? "" : "s" )));
+ } else {
+ showDetails();
+ }
+
+ dom.banner.appendChild(this.createDom('span', {className: 'duration'}, "finished in " + ((new Date().getTime() - this.startedAt.getTime()) / 1000) + "s"));
+ };
+
+ this.addSpecs = function(specs, specFilter) {
+ this.totalSpecCount = specs.length;
+
+ this.views = {
+ specs: {},
+ suites: {}
+ };
+
+ for (var i = 0; i < specs.length; i++) {
+ var spec = specs[i];
+ this.views.specs[spec.id] = new jasmine.HtmlReporter.SpecView(spec, dom, this.views);
+ if (specFilter(spec)) {
+ this.runningSpecCount++;
+ }
+ }
+ };
+
+ return this;
+
+ function showDetails() {
+ if (dom.reporter.className.search(/showDetails/) === -1) {
+ dom.reporter.className += " showDetails";
+ }
+ }
+
+ function isUndefined(obj) {
+ return typeof obj === 'undefined';
+ }
+
+ function isDefined(obj) {
+ return !isUndefined(obj);
+ }
+};
+
+jasmine.HtmlReporterHelpers.addHelpers(jasmine.HtmlReporter.ReporterView);
+
+
diff --git a/src/html/SpecView.js b/src/html/SpecView.js
new file mode 100644
index 00000000..8769bb84
--- /dev/null
+++ b/src/html/SpecView.js
@@ -0,0 +1,79 @@
+jasmine.HtmlReporter.SpecView = function(spec, dom, views) {
+ this.spec = spec;
+ this.dom = dom;
+ this.views = views;
+
+ this.symbol = this.createDom('li', { className: 'pending' });
+ this.dom.symbolSummary.appendChild(this.symbol);
+
+ this.summary = this.createDom('div', { className: 'specSummary' },
+ this.createDom('a', {
+ className: 'description',
+ href: '?spec=' + encodeURIComponent(this.spec.getFullName()),
+ title: this.spec.getFullName()
+ }, this.spec.description)
+ );
+
+ this.detail = this.createDom('div', { className: 'specDetail' },
+ this.createDom('a', {
+ className: 'description',
+ href: '?spec=' + encodeURIComponent(this.spec.getFullName()),
+ title: this.spec.getFullName()
+ }, this.spec.getFullName())
+ );
+};
+
+jasmine.HtmlReporter.SpecView.prototype.status = function() {
+ return this.getSpecStatus(this.spec);
+};
+
+jasmine.HtmlReporter.SpecView.prototype.refresh = function() {
+ this.symbol.className = this.status();
+
+ switch (this.status()) {
+ case 'skipped':
+ break;
+
+ case 'passed':
+ this.appendSummaryToSuiteDiv();
+ break;
+
+ case 'failed':
+ this.appendSummaryToSuiteDiv();
+ this.appendFailureDetail();
+ break;
+ }
+};
+
+jasmine.HtmlReporter.SpecView.prototype.appendSummaryToSuiteDiv = function() {
+ this.summary.className += ' ' + this.status();
+ this.appendToSummary(this.spec, this.summary);
+};
+
+jasmine.HtmlReporter.SpecView.prototype.appendFailureDetail = function() {
+ this.detail.className += ' ' + this.status();
+
+ var resultItems = this.spec.results().getItems();
+ var messagesDiv = this.createDom('div', { className: 'messages' });
+
+ for (var i = 0; i < resultItems.length; i++) {
+ var result = resultItems[i];
+
+ if (result.type == 'log') {
+ messagesDiv.appendChild(this.createDom('div', {className: 'resultMessage log'}, result.toString()));
+ } else if (result.type == 'expect' && result.passed && !result.passed()) {
+ messagesDiv.appendChild(this.createDom('div', {className: 'resultMessage fail'}, result.message));
+
+ if (result.trace.stack) {
+ messagesDiv.appendChild(this.createDom('div', {className: 'stackTrace'}, result.trace.stack));
+ }
+ }
+ }
+
+ if (messagesDiv.childNodes.length > 0) {
+ this.detail.appendChild(messagesDiv);
+ this.dom.details.appendChild(this.detail);
+ }
+};
+
+jasmine.HtmlReporterHelpers.addHelpers(jasmine.HtmlReporter.SpecView);
\ No newline at end of file
diff --git a/src/html/SuiteView.js b/src/html/SuiteView.js
new file mode 100644
index 00000000..19a1efaf
--- /dev/null
+++ b/src/html/SuiteView.js
@@ -0,0 +1,22 @@
+jasmine.HtmlReporter.SuiteView = function(suite, dom, views) {
+ this.suite = suite;
+ this.dom = dom;
+ this.views = views;
+
+ this.element = this.createDom('div', { className: 'suite' },
+ this.createDom('a', { className: 'description', href: '?spec=' + encodeURIComponent(this.suite.getFullName()) }, this.suite.description)
+ );
+
+ this.appendToSummary(this.suite, this.element);
+};
+
+jasmine.HtmlReporter.SuiteView.prototype.status = function() {
+ return this.getSpecStatus(this.suite);
+};
+
+jasmine.HtmlReporter.SuiteView.prototype.refresh = function() {
+ this.element.className += " " + this.status();
+};
+
+jasmine.HtmlReporterHelpers.addHelpers(jasmine.HtmlReporter.SuiteView);
+
diff --git a/tasks/helpers.rb b/tasks/helpers.rb
index 27dec3b5..66c9f683 100644
--- a/tasks/helpers.rb
+++ b/tasks/helpers.rb
@@ -9,7 +9,7 @@ def core_sources
end
def html_sources
- Dir.glob('./src/html/*.js')
+ ['./src/html/HtmlReporterHelpers.js'] + Dir.glob('./src/html/*.js')
end
def console_sources