diff --git a/lib/jasmine-core/jasmine-html.js b/lib/jasmine-core/jasmine-html.js index c1637e7b..b7029468 100644 --- a/lib/jasmine-core/jasmine-html.js +++ b/lib/jasmine-core/jasmine-html.js @@ -35,6 +35,8 @@ jasmineRequire.html = function(j$) { j$.private.SymbolsView = jasmineRequire.SymbolsView(j$); j$.private.SummaryTreeView = jasmineRequire.SummaryTreeView(j$); j$.private.FailuresView = jasmineRequire.FailuresView(j$); + j$.private.PerformanceView = jasmineRequire.PerformanceView(j$); + j$.private.TabBar = jasmineRequire.TabBar(j$); j$.HtmlReporter = jasmineRequire.HtmlReporter(j$); j$.HtmlReporterV2Urls = jasmineRequire.HtmlReporterV2Urls(j$); j$.HtmlReporterV2 = jasmineRequire.HtmlReporterV2(j$); @@ -187,16 +189,52 @@ jasmineRequire.HtmlReporter = function(j$) { results.appendChild(summary.rootEl); if (this.#stateBuilder.anyNonTopSuiteFailures) { - this.#alerts.addFailureToggle( - () => this.#setMenuModeTo('jasmine-failure-list'), - () => this.#setMenuModeTo('jasmine-spec-list') - ); - + this.#addFailureToggle(); this.#setMenuModeTo('jasmine-failure-list'); this.#failures.show(); } } + #addFailureToggle() { + const onClickFailures = () => this.#setMenuModeTo('jasmine-failure-list'); + const onClickSpecList = () => this.#setMenuModeTo('jasmine-spec-list'); + const failuresLink = createDom( + 'a', + { className: 'jasmine-failures-menu', href: '#' }, + 'Failures' + ); + let specListLink = createDom( + 'a', + { className: 'jasmine-spec-list-menu', href: '#' }, + 'Spec List' + ); + + failuresLink.onclick = function() { + onClickFailures(); + return false; + }; + + specListLink.onclick = function() { + onClickSpecList(); + return false; + }; + + this.#alerts.addBar( + createDom( + 'span', + { className: 'jasmine-menu jasmine-bar jasmine-spec-list' }, + [createDom('span', {}, 'Spec List | '), failuresLink] + ) + ); + this.#alerts.addBar( + createDom( + 'span', + { className: 'jasmine-menu jasmine-bar jasmine-failure-list' }, + [specListLink, createDom('span', {}, ' | Failures ')] + ) + ); + } + #find(selector) { return this.#getContainer().querySelector( '.jasmine_html-reporter ' + selector @@ -434,6 +472,7 @@ jasmineRequire.AlertsView = function(j$) { ); } + // TODO: remove this once HtmlReporterV2 doesn't use it addFailureToggle(onClickFailures, onClickSpecList) { const failuresLink = createDom( 'a', @@ -456,14 +495,20 @@ jasmineRequire.AlertsView = function(j$) { return false; }; - this.#createAndAdd('jasmine-menu jasmine-bar jasmine-spec-list', [ - createDom('span', {}, 'Spec List | '), - failuresLink - ]); - this.#createAndAdd('jasmine-menu jasmine-bar jasmine-failure-list', [ - specListLink, - createDom('span', {}, ' | Failures ') - ]); + this.rootEl.appendChild( + createDom( + 'span', + { className: 'jasmine-menu jasmine-bar jasmine-spec-list' }, + [createDom('span', {}, 'Spec List | '), failuresLink] + ) + ); + this.rootEl.appendChild( + createDom( + 'span', + { className: 'jasmine-menu jasmine-bar jasmine-failure-list' }, + [specListLink, createDom('span', {}, ' | Failures ')] + ) + ); } addGlobalFailure(failure) { @@ -946,6 +991,10 @@ jasmineRequire.HtmlReporterV2 = function(j$) { const { createDom, noExpectations } = j$.private.htmlReporterUtils; + const specListTabId = 'jasmine-specListTab'; + const failuresTabId = 'jasmine-failuresTab'; + const perfTabId = 'jasmine-perfTab'; + /** * @class HtmlReporterV2 * @classdesc Displays results and allows re-running individual specs and suites. @@ -974,6 +1023,7 @@ jasmineRequire.HtmlReporterV2 = function(j$) { // Sub-views #alerts; #statusBar; + #tabBar; #progress; #banner; #failures; @@ -1011,6 +1061,25 @@ jasmineRequire.HtmlReporterV2 = function(j$) { this.#statusBar = new j$.private.OverallStatusBar(this.#urlBuilder); this.#statusBar.showRunning(); this.#alerts.addBar(this.#statusBar.rootEl); + + this.#tabBar = new j$.private.TabBar( + [ + { id: specListTabId, label: 'Spec List' }, + { id: failuresTabId, label: 'Failures' }, + { id: perfTabId, label: 'Performance' } + ], + tabId => { + if (tabId === specListTabId) { + this.#setMenuModeTo('jasmine-spec-list'); + } else if (tabId === failuresTabId) { + this.#setMenuModeTo('jasmine-failure-list'); + } else { + this.#setMenuModeTo('jasmine-performance'); + } + } + ); + this.#alerts.addBar(this.#tabBar.rootEl); + this.#progress = new ProgressView(); this.#banner = new j$.private.Banner( this.#queryString.navigateWithNewParam.bind(this.#queryString), @@ -1101,15 +1170,17 @@ jasmineRequire.HtmlReporterV2 = function(j$) { ); summary.addResults(this.#stateBuilder.topResults); results.appendChild(summary.rootEl); + const perf = new j$.private.PerformanceView(); + perf.addResults(this.#stateBuilder.topResults); + results.appendChild(perf.rootEl); + this.#tabBar.showTab(specListTabId); + this.#tabBar.showTab(perfTabId); if (this.#stateBuilder.anyNonTopSuiteFailures) { - this.#alerts.addFailureToggle( - () => this.#setMenuModeTo('jasmine-failure-list'), - () => this.#setMenuModeTo('jasmine-spec-list') - ); - - this.#setMenuModeTo('jasmine-failure-list'); - this.#failures.show(); + this.#tabBar.showTab(failuresTabId); + this.#tabBar.selectTab(failuresTabId); + } else { + this.#tabBar.selectTab(specListTabId); } } @@ -1148,7 +1219,6 @@ jasmineRequire.HtmlReporterV2 = function(j$) { this.rootEl.value = this.rootEl.value + 1; if (result.status === 'failed') { - // TODO: also a non-color indicator this.rootEl.classList.add('failed'); } } @@ -1434,6 +1504,105 @@ jasmineRequire.OverallStatusBar = function(j$) { return OverallStatusBar; }; +jasmineRequire.PerformanceView = function(j$) { + const createDom = j$.private.htmlReporterUtils.createDom; + const MAX_SLOW_SPECS = 20; + + class PerformanceView { + #summary; + #tbody; + + constructor() { + this.#tbody = document.createElement('tbody'); + this.#summary = document.createElement('div'); + this.rootEl = createDom( + 'div', + { className: 'jasmine-performance-view' }, + createDom('h2', {}, 'Performance'), + this.#summary, + createDom('h3', {}, 'Slowest Specs'), + createDom( + 'table', + {}, + createDom( + 'thead', + {}, + createDom( + 'tr', + {}, + createDom('th', {}, 'Duration'), + createDom('th', {}, 'Spec Name') + ) + ), + this.#tbody + ) + ); + } + + addResults(resultsTree) { + const specResults = []; + getSpecResults(resultsTree, specResults); + + if (specResults.length === 0) { + return; + } + + specResults.sort(function(a, b) { + if (a.duration < b.duration) { + return 1; + } else if (a.duration > b.duration) { + return -1; + } else { + return 0; + } + }); + + this.#populateSumary(specResults); + this.#populateTable(specResults); + } + + #populateSumary(specResults) { + const total = specResults.map(r => r.duration).reduce((a, b) => a + b, 0); + const mean = total / specResults.length; + const median = specResults[Math.floor(specResults.length / 2)].duration; + this.#summary.appendChild( + document.createTextNode(`Mean spec run time: ${mean.toFixed(0)}ms`) + ); + this.#summary.appendChild(document.createElement('br')); + this.#summary.appendChild( + document.createTextNode(`Median spec run time: ${median}ms`) + ); + } + + #populateTable(specResults) { + specResults = specResults.slice(0, MAX_SLOW_SPECS); + + for (const r of specResults) { + this.#tbody.appendChild( + createDom( + 'tr', + {}, + createDom('td', {}, `${r.duration}ms`), + createDom('td', {}, r.fullName) + ) + ); + } + } + } + + function getSpecResults(resultsTree, dest) { + for (const node of resultsTree.children) { + if (node.type === 'suite') { + getSpecResults(node, dest); + } else if (node.result.status !== 'excluded') { + dest.push(node.result); + } + } + } + + return PerformanceView; +}; + jasmineRequire.ResultsStateBuilder = function(j$) { 'use strict'; @@ -1665,3 +1834,81 @@ jasmineRequire.SymbolsView = function(j$) { return SymbolsView; }; + +jasmineRequire.TabBar = function(j$) { + const createDom = j$.private.htmlReporterUtils.createDom; + + class TabBar { + #tabs; + #onSelectTab; + + // tabSpecs should be an array of {id, label}. + // All tabs are initially not visible and not selected. + constructor(tabSpecs, onSelectTab) { + this.#onSelectTab = onSelectTab; + this.#tabs = []; + this.#tabs = tabSpecs.map(ts => new Tab(ts, () => this.selectTab(ts.id))); + + this.rootEl = createDom( + 'span', + { className: 'jasmine-menu jasmine-bar' }, + this.#tabs.map(t => t.rootEl) + ); + } + + showTab(id) { + for (const tab of this.#tabs) { + if (tab.rootEl.id === id) { + tab.setVisibility(true); + } + } + } + + selectTab(id) { + for (const tab of this.#tabs) { + tab.setSelected(tab.rootEl.id === id); + } + + this.#onSelectTab(id); + } + } + + class Tab { + #spec; + #onClick; + + constructor(spec, onClick) { + this.#spec = spec; + this.#onClick = onClick; + this.rootEl = createDom( + 'span', + { id: spec.id, className: 'jasmine-tab jasmine-hidden' }, + this.#createLink() + ); + } + + setVisibility(visible) { + this.rootEl.classList.toggle('jasmine-hidden', !visible); + } + + setSelected(selected) { + if (selected) { + this.rootEl.textContent = this.#spec.label; + } else { + this.rootEl.textContent = ''; + this.rootEl.appendChild(this.#createLink()); + } + } + + #createLink() { + const link = createDom('a', { href: '#' }, this.#spec.label); + link.addEventListener('click', e => { + e.preventDefault(); + this.#onClick(); + }); + return link; + } + } + + return TabBar; +}; diff --git a/lib/jasmine-core/jasmine.css b/lib/jasmine-core/jasmine.css index 71f6fc17..24cf47dd 100644 --- a/lib/jasmine-core/jasmine.css +++ b/lib/jasmine-core/jasmine.css @@ -198,11 +198,17 @@ body { color: white; } .jasmine_html-reporter.jasmine-spec-list .jasmine-bar.jasmine-menu.jasmine-failure-list, -.jasmine_html-reporter.jasmine-spec-list .jasmine-results .jasmine-failures { +.jasmine_html-reporter.jasmine-spec-list .jasmine-results .jasmine-failures, +.jasmine_html-reporter.jasmine-spec-list .jasmine-performance-view { display: none; } .jasmine_html-reporter.jasmine-failure-list .jasmine-bar.jasmine-menu.jasmine-spec-list, -.jasmine_html-reporter.jasmine-failure-list .jasmine-summary { +.jasmine_html-reporter.jasmine-failure-list .jasmine-summary, +.jasmine_html-reporter.jasmine-failure-list .jasmine-performance-view { + display: none; +} +.jasmine_html-reporter.jasmine-performance .jasmine-results .jasmine-failures, +.jasmine_html-reporter.jasmine-performance .jasmine-summary { display: none; } .jasmine_html-reporter .jasmine-results { @@ -323,4 +329,23 @@ body { } .jasmine_html-reporter .jasmine-debug-log .jasmine-debug-log-msg { white-space: pre; +} + +.jasmine-hidden { + display: none; +} + +.jasmine-tab + .jasmine-tab:before { + content: " | "; +} + +.jasmine-performance-view h2, .jasmine-performance-view h3 { + margin-top: 1em; + margin-bottom: 1em; +} +.jasmine-performance-view table { + border-spacing: 5px; +} +.jasmine-performance-view th, .jasmine-performance-view td { + text-align: left; } \ No newline at end of file diff --git a/spec/html/HtmlReporterV2Spec.js b/spec/html/HtmlReporterV2Spec.js index e1a1c6f3..d422e817 100644 --- a/spec/html/HtmlReporterV2Spec.js +++ b/spec/html/HtmlReporterV2Spec.js @@ -167,18 +167,18 @@ describe('HtmlReporterV2', function() { '.jasmine-alert .jasmine-bar' ); - expect(alertBars.length).toEqual(4); - expect(alertBars[1].innerHTML).toMatch( + expect(alertBars.length).toEqual(5); + expect(alertBars[2].innerHTML).toMatch( /spec deprecation.*\(in spec: a spec with a deprecation\)/ ); - expect(alertBars[1].getAttribute('class')).toEqual( + expect(alertBars[2].getAttribute('class')).toEqual( 'jasmine-bar jasmine-warning' ); - expect(alertBars[2].innerHTML).toMatch( + expect(alertBars[3].innerHTML).toMatch( /suite deprecation.*\(in suite: a suite with a deprecation\)/ ); - expect(alertBars[3].innerHTML).toMatch(/global deprecation/); - expect(alertBars[3].innerHTML).not.toMatch(/in /); + expect(alertBars[4].innerHTML).toMatch(/global deprecation/); + expect(alertBars[4].innerHTML).not.toMatch(/in /); }); it('displays expandable stack traces', function() { @@ -259,6 +259,214 @@ describe('HtmlReporterV2', function() { }); }); + describe('The tab bar', function() { + function checkHidden(tabs, expected) { + const actual = Array.from(tabs).map(t => + t.classList.contains('jasmine-hidden') + ); + expect(actual) + .withContext('tab hiddenness') + .toEqual(expected); + } + + describe('while Jasmine is running', function() { + it('hides all tabs', function() { + const reporter = setup(); + reporter.initialize(); + reporter.jasmineStarted({ totalSpecsDefined: 0 }); + const tabs = container.querySelectorAll('.jasmine-tab'); + expect(tabs.length).toEqual(3); + expect(tabs[0].textContent).toEqual('Spec List'); + expect(tabs[1].textContent).toEqual('Failures'); + expect(tabs[2].textContent).toEqual('Performance'); + checkHidden(tabs, [true, true, true]); + + // Results, even failures, should not show any tabs + reporter.specDone({ + id: 1, + description: 'a failing spec', + fullName: 'a failing spec', + status: 'failed', + failedExpectations: [{}], + passedExpectations: [] + }); + checkHidden(tabs, [true, true, true]); + }); + }); + + describe('when Jasmine is done', function() { + function hasSpecOrSuiteFailureBehavior(reportEvents) { + let reporter; + + beforeEach(function() { + reporter = setup(); + reporter.initialize(); + reportEvents(reporter); + }); + + it('shows all three tabs', function() { + const tabs = container.querySelectorAll('.jasmine-tab'); + checkHidden(tabs, [false, false, false]); + }); + + it('selects the Failures tab', function() { + const reporterNode = container.querySelector( + '.jasmine_html-reporter' + ); + expect(reporterNode).toHaveClass('jasmine-failure-list'); + }); + + it('switches between failure details and the spec summary', function() { + const tabs = container.querySelectorAll('.jasmine-tab'); + let specListLink = () => tabs[0].querySelector('a'); + let failuresLink = () => tabs[1].querySelector('a'); + const reporterNode = container.querySelector( + '.jasmine_html-reporter' + ); + expect(specListLink().textContent).toEqual('Spec List'); + expect(failuresLink()) + .withContext('failures link') + .toBeFalsy(); + + specListLink().click(); + expect(reporterNode).toHaveClass('jasmine-spec-list'); + expect(reporterNode).not.toHaveClass('jasmine-failure-list'); + expect(specListLink()) + .withContext('spec list link') + .toBeFalsy(); + expect(failuresLink().textContent).toEqual('Failures'); + + failuresLink().click(); + expect(reporterNode.getAttribute('class')).toMatch( + 'jasmine-failure-list' + ); + expect(failuresLink()) + .withContext('failures link') + .toBeFalsy(); + expect(specListLink().textContent).toEqual('Spec List'); + expect(reporterNode).toHaveClass('jasmine-failure-list'); + expect(reporterNode).not.toHaveClass('jasmine-spec-list'); + }); + } + + function hasSpecAndSuiteSuccessBehavior(reportEvents) { + let reporter; + + beforeEach(function() { + reporter = setup(); + reporter.initialize(); + reportEvents(reporter); + }); + + it('shows the Spec List and Performance tabs', function() { + const tabs = container.querySelectorAll('.jasmine-tab'); + checkHidden(tabs, [false, true, false]); + }); + + it('shows the spec list view', function() { + const reporterNode = container.querySelector( + '.jasmine_html-reporter' + ); + expect(reporterNode).toHaveClass('jasmine-spec-list'); + expect(reporterNode).not.toHaveClass('jasmine-failure-list'); + }); + } + + describe('with spec failures', function() { + hasSpecOrSuiteFailureBehavior(function(reporter) { + reporter.jasmineStarted({ totalSpecsDefined: 0 }); + reporter.specDone({ + id: 1, + description: 'a failing spec', + fullName: 'a failing spec', + status: 'failed', + failedExpectations: [{}], + passedExpectations: [] + }); + reporter.specDone({ + id: 2, + description: 'a passing spec', + fullName: 'a passing spec', + status: 'passed', + failedExpectations: [], + passedExpectations: [] + }); + reporter.jasmineDone({}); + }); + }); + + describe('with suite failures', function() { + hasSpecOrSuiteFailureBehavior(function(reporter) { + reporter.jasmineStarted({ totalSpecsDefined: 0 }); + reporter.specDone({ + id: 1, + description: 'a failing spec', + fullName: 'a failing spec', + status: 'failed', + failedExpectations: [{}], + passedExpectations: [] + }); + reporter.specDone({ + id: 2, + description: 'a passing spec', + fullName: 'a passing spec', + status: 'passed', + failedExpectations: [], + passedExpectations: [] + }); + reporter.jasmineDone({}); + }); + }); + + describe('without any failures', function() { + hasSpecAndSuiteSuccessBehavior(function(reporter) { + reporter.jasmineStarted({ totalSpecsDefined: 0 }); + reporter.specDone({ + id: 1, + description: 'a passing spec', + fullName: 'a passing spec', + status: 'passed', + failedExpectations: [], + passedExpectations: [] + }); + reporter.suiteDone({ id: 1 }); + reporter.jasmineDone({}); + }); + }); + + describe('with only top suite failures', function() { + // Top suite failures are displayed in their own alert bars, so they + // don't cause the failures tab to be shown. + hasSpecAndSuiteSuccessBehavior(function(reporter) { + reporter.jasmineStarted({ totalSpecsDefined: 0 }); + reporter.jasmineDone({ + failedExpectations: [{}] + }); + }); + }); + + it('shows the slow spec view when the Performance tab is clicked', function() { + const reporter = setup(); + reporter.initialize(); + reporter.jasmineStarted({ totalSpecsDefined: 0 }); + reporter.specDone({ + duration: 1.2, + failedExpectations: [], + passedExpectations: [] + }); + reporter.jasmineDone({}); + const tabs = container.querySelectorAll('.jasmine-tab'); + let perfLink = tabs[2].querySelector('a'); + const reporterNode = container.querySelector('.jasmine_html-reporter'); + expect(perfLink.textContent).toEqual('Performance'); + perfLink.click(); + expect(reporterNode).toHaveClass('jasmine-performance'); + expect(reporterNode.innerHTML).toContain('

Performance

'); + expect(reporterNode.innerHTML).toContain('1.2ms'); + }); + }); + }); + describe('when Jasmine is done', function() { it('adds a warning to the link title of specs that have no expectations', function() { const reporter = setup(); @@ -449,21 +657,18 @@ describe('HtmlReporterV2', function() { ] }); - const alertBars = container.querySelectorAll( - '.jasmine-alert .jasmine-bar' + const errorBars = container.querySelectorAll( + '.jasmine-alert .jasmine-bar.jasmine-errored' ); - expect(alertBars.length).toEqual(3); - expect(alertBars[1].getAttribute('class')).toEqual( - 'jasmine-bar jasmine-errored' - ); - expect(alertBars[1].innerHTML).toMatch( + expect(errorBars.length).toEqual(2); + expect(errorBars[0].innerHTML).toMatch( /AfterAll Global After All Failure/ ); - expect(alertBars[2].innerHTML).toMatch( + expect(errorBars[1].innerHTML).toMatch( /Error during loading: Your JS is borken/ ); - expect(alertBars[2].innerHTML).not.toMatch(/line/); + expect(errorBars[1].innerHTML).not.toMatch(/line/); }); it('does not display the "AfterAll" prefix for other error types', function() { @@ -482,16 +687,16 @@ describe('HtmlReporterV2', function() { ] }); - const alertBars = container.querySelectorAll( - '.jasmine-alert .jasmine-bar' + const errorBars = container.querySelectorAll( + '.jasmine-alert .jasmine-bar.jasmine-errored' ); - expect(alertBars.length).toEqual(4); - expect(alertBars[1].textContent).toContain('load error'); - expect(alertBars[2].textContent).toContain('lateExpectation error'); - expect(alertBars[3].textContent).toContain('lateError error'); + expect(errorBars.length).toEqual(3); + expect(errorBars[0].textContent).toContain('load error'); + expect(errorBars[1].textContent).toContain('lateExpectation error'); + expect(errorBars[2].textContent).toContain('lateError error'); - for (let bar of alertBars) { + for (let bar of errorBars) { expect(bar.textContent).not.toContain('AfterAll'); } }); @@ -512,12 +717,12 @@ describe('HtmlReporterV2', function() { ] }); - const alertBars = container.querySelectorAll( - '.jasmine-alert .jasmine-bar' + const alertBar = container.querySelector( + '.jasmine-alert .jasmine-bar.jasmine-errored' ); - expect(alertBars.length).toEqual(2); - expect(alertBars[1].innerHTML).toMatch( + expect(alertBar).toBeTruthy(); + expect(alertBar.innerHTML).toMatch( /Error during loading: Your JS is borken in some\/file.js line 42/ ); }); @@ -758,12 +963,12 @@ describe('HtmlReporterV2', function() { }); it('reports the specs counts', function() { - const alertBars = container.querySelectorAll( - '.jasmine-alert .jasmine-bar' + const resultBar = container.querySelector( + '.jasmine-alert .jasmine-bar.jasmine-overall-result' ); - expect(alertBars.length).toEqual(1); - expect(alertBars[0].innerHTML).toMatch(/2 specs, 0 failures/); + expect(resultBar).toBeTruthy(); + expect(resultBar.innerHTML).toMatch(/2 specs, 0 failures/); }); it('reports no failure details', function() { @@ -1072,23 +1277,6 @@ describe('HtmlReporterV2', function() { )}` ); }); - - it('allows switching between failure details and the spec summary', function() { - const menuBar = container.querySelectorAll('.jasmine-bar')[1]; - - expect(menuBar.getAttribute('class')).not.toMatch(/hidden/); - - const link = menuBar.querySelector('a'); - expect(link.innerHTML).toEqual('Failures'); - expect(link.getAttribute('href')).toEqual('#'); - }); - - it("sets the reporter to 'Failures List' mode", function() { - const reporterNode = container.querySelector('.jasmine_html-reporter'); - expect(reporterNode.getAttribute('class')).toMatch( - 'jasmine-failure-list' - ); - }); }); it('counts failures that are reported in the jasmineDone event', function() { diff --git a/spec/html/PerformanceViewSpec.js b/spec/html/PerformanceViewSpec.js new file mode 100644 index 00000000..63e5f873 --- /dev/null +++ b/spec/html/PerformanceViewSpec.js @@ -0,0 +1,107 @@ +'use strict'; + +describe('PerformanceView', function() { + it('shows specs ordered by execution time', function() { + const stateBuilder = new privateUnderTest.ResultsStateBuilder(); + stateBuilder.suiteStarted({}); + stateBuilder.specDone({ + fullName: 'spec A', + duration: 2 + }); + stateBuilder.suiteDone({}); + stateBuilder.specDone({ + fullName: 'spec B', + duration: 1 + }); + stateBuilder.specDone({ + fullName: 'spec C', + duration: 3 + }); + const subject = new privateUnderTest.PerformanceView(); + subject.addResults(stateBuilder.topResults); + + const rows = Array.from(subject.rootEl.querySelectorAll('tbody tr')); + const durations = rows.map(r => r.querySelectorAll('td')[0].textContent); + const names = rows.map(r => r.querySelectorAll('td')[1].textContent); + expect(names).toEqual(['spec C', 'spec A', 'spec B']); + expect(durations).toEqual(['3ms', '2ms', '1ms']); + }); + + it('shows at most 20 specs', function() { + const stateBuilder = new privateUnderTest.ResultsStateBuilder(); + const subject = new privateUnderTest.PerformanceView(); + + for (let i = 0; i < 21; i++) { + stateBuilder.specDone({ + fullName: `spec ${i}`, + duration: i + }); + } + + subject.addResults(stateBuilder.topResults); + + expect(subject.rootEl.querySelectorAll('tbody tr').length).toEqual(20); + expect(subject.textContent).not.toContain('spec 0'); + }); + + it('shows mean and median run times for an odd number of specs', function() { + const stateBuilder = new privateUnderTest.ResultsStateBuilder(); + const subject = new privateUnderTest.PerformanceView(); + + stateBuilder.specDone({ duration: 1 }); + stateBuilder.specDone({ duration: 2 }); + stateBuilder.specDone({ duration: 5 }); + subject.addResults(stateBuilder.topResults); + + expect(subject.rootEl.textContent).toContain('Mean spec run time: 3ms'); + expect(subject.rootEl.textContent).toContain('Median spec run time: 2ms'); + }); + + it('shows mean and median run times for an even number of specs', function() { + const stateBuilder = new privateUnderTest.ResultsStateBuilder(); + const subject = new privateUnderTest.PerformanceView(); + + stateBuilder.specDone({ duration: 1 }); + stateBuilder.specDone({ duration: 3 }); + stateBuilder.specDone({ duration: 10 }); + stateBuilder.specDone({ duration: 2 }); + subject.addResults(stateBuilder.topResults); + + expect(subject.rootEl.textContent).toContain('Mean spec run time: 4ms'); + expect(subject.rootEl.textContent).toContain('Median spec run time: 2ms'); + }); + + it('copes with 0 specs', function() { + const stateBuilder = new privateUnderTest.ResultsStateBuilder(); + const subject = new privateUnderTest.PerformanceView(); + + expect(function() { + subject.addResults(stateBuilder.topResults); + }).not.toThrow(); + }); + + it('filters out excluded specs', function() { + const stateBuilder = new privateUnderTest.ResultsStateBuilder(); + stateBuilder.specDone({ + fullName: 'spec A', + duration: 2 + }); + stateBuilder.specDone({ + fullName: 'spec B', + duration: 1, + status: 'excluded' + }); + stateBuilder.specDone({ + fullName: 'spec C', + duration: 3 + }); + const subject = new privateUnderTest.PerformanceView(); + subject.addResults(stateBuilder.topResults); + + const rows = Array.from(subject.rootEl.querySelectorAll('tbody tr')); + const names = rows.map(r => r.querySelectorAll('td')[1].textContent); + expect(names).toEqual(['spec C', 'spec A']); + expect(subject.rootEl.textContent).toContain('Mean spec run time: 3ms'); + expect(subject.rootEl.textContent).toContain('Median spec run time: 2ms'); + }); +}); diff --git a/spec/html/TabBarSpec.js b/spec/html/TabBarSpec.js new file mode 100644 index 00000000..b7849d5d --- /dev/null +++ b/spec/html/TabBarSpec.js @@ -0,0 +1,97 @@ +describe('TabBar', function() { + it('initially renders but hides the tabs', function() { + const subject = new privateUnderTest.TabBar([ + { id: 'tab1', label: 'tab 1' } + ]); + const tabs = subject.rootEl.querySelectorAll('.jasmine-tab'); + expect(tabs.length).toEqual(1); + expect(tabs[0].id).toEqual('tab1'); + expect(tabs[0]).toHaveClass('jasmine-hidden'); + const link = tabs[0].querySelector('a'); + expect(link).toBeTruthy(); + expect(link.textContent).toEqual('tab 1'); + }); + + it('does not initially call the onSelect callback', function() { + const onSelect = jasmine.createSpy('onSelect'); + new privateUnderTest.TabBar([{ id: 'tab1', label: '' }], onSelect); + expect(onSelect).not.toHaveBeenCalled(); + }); + + describe('#showTab', function() { + it('shows the specified tab', function() { + const subject = new privateUnderTest.TabBar([ + { id: 'tab1' }, + { id: 'tab2' } + ]); + + subject.showTab('tab2'); + + const tabs = subject.rootEl.querySelectorAll('.jasmine-tab'); + expect(tabs[0]).toHaveClass('jasmine-hidden'); + expect(tabs[1]).not.toHaveClass('jasmine-hidden'); + }); + + it('does not hide previously shown tabs', function() { + const subject = new privateUnderTest.TabBar([ + { id: 'tab1' }, + { id: 'tab2' } + ]); + + subject.showTab('tab1'); + subject.showTab('tab2'); + + const tabs = subject.rootEl.querySelectorAll('.jasmine-tab'); + expect(tabs[0]).not.toHaveClass('jasmine-hidden'); + }); + }); + + describe("When a tab's link is clicked", function() { + it("calls the onSelect callback with the tab's id", function() { + const onSelect = jasmine.createSpy('onSelect'); + const subject = new privateUnderTest.TabBar( + [{ id: 'tab1', label: '' }], + onSelect + ); + + subject.rootEl.querySelector('.jasmine-tab a').click(); + + expect(onSelect).toHaveBeenCalledWith('tab1'); + }); + + it('shows links on all non-selected tabs only', function() { + const subject = new privateUnderTest.TabBar( + [ + { id: 'tab1', label: 'tab 1' }, + { id: 'tab2', label: 'tab 2' }, + { id: 'tab3', label: 'tab 3' } + ], + () => {} + ); + + subject.rootEl.querySelectorAll('.jasmine-tab a')[1].click(); + let tabs = subject.rootEl.querySelectorAll('.jasmine-tab'); + expect(tabs[0].querySelector('a')) + .withContext('tab 1') + .toBeTruthy(); + expect(tabs[1].querySelector('a')) + .withContext('tab 1') + .toBeFalsy(); + expect(tabs[2].querySelector('a')) + .withContext('tab 1') + .toBeTruthy(); + + subject.rootEl.querySelectorAll('.jasmine-tab a')[0].click(); + tabs = subject.rootEl.querySelectorAll('.jasmine-tab'); + expect(tabs[0].querySelector('a')) + .withContext('tab 1') + .toBeFalsy(); + expect(tabs[1].querySelector('a')) + .withContext('tab 1') + .toBeTruthy(); + expect(tabs[2].querySelector('a')) + .withContext('tab 1') + .toBeTruthy(); + }); + }); +}); diff --git a/src/html/AlertsView.js b/src/html/AlertsView.js index 321da750..1f41ce52 100644 --- a/src/html/AlertsView.js +++ b/src/html/AlertsView.js @@ -24,6 +24,7 @@ jasmineRequire.AlertsView = function(j$) { ); } + // TODO: remove this once HtmlReporterV2 doesn't use it addFailureToggle(onClickFailures, onClickSpecList) { const failuresLink = createDom( 'a', @@ -46,14 +47,20 @@ jasmineRequire.AlertsView = function(j$) { return false; }; - this.#createAndAdd('jasmine-menu jasmine-bar jasmine-spec-list', [ - createDom('span', {}, 'Spec List | '), - failuresLink - ]); - this.#createAndAdd('jasmine-menu jasmine-bar jasmine-failure-list', [ - specListLink, - createDom('span', {}, ' | Failures ') - ]); + this.rootEl.appendChild( + createDom( + 'span', + { className: 'jasmine-menu jasmine-bar jasmine-spec-list' }, + [createDom('span', {}, 'Spec List | '), failuresLink] + ) + ); + this.rootEl.appendChild( + createDom( + 'span', + { className: 'jasmine-menu jasmine-bar jasmine-failure-list' }, + [specListLink, createDom('span', {}, ' | Failures ')] + ) + ); } addGlobalFailure(failure) { diff --git a/src/html/HtmlReporter.js b/src/html/HtmlReporter.js index a410986e..4bfc8c91 100644 --- a/src/html/HtmlReporter.js +++ b/src/html/HtmlReporter.js @@ -142,16 +142,52 @@ jasmineRequire.HtmlReporter = function(j$) { results.appendChild(summary.rootEl); if (this.#stateBuilder.anyNonTopSuiteFailures) { - this.#alerts.addFailureToggle( - () => this.#setMenuModeTo('jasmine-failure-list'), - () => this.#setMenuModeTo('jasmine-spec-list') - ); - + this.#addFailureToggle(); this.#setMenuModeTo('jasmine-failure-list'); this.#failures.show(); } } + #addFailureToggle() { + const onClickFailures = () => this.#setMenuModeTo('jasmine-failure-list'); + const onClickSpecList = () => this.#setMenuModeTo('jasmine-spec-list'); + const failuresLink = createDom( + 'a', + { className: 'jasmine-failures-menu', href: '#' }, + 'Failures' + ); + let specListLink = createDom( + 'a', + { className: 'jasmine-spec-list-menu', href: '#' }, + 'Spec List' + ); + + failuresLink.onclick = function() { + onClickFailures(); + return false; + }; + + specListLink.onclick = function() { + onClickSpecList(); + return false; + }; + + this.#alerts.addBar( + createDom( + 'span', + { className: 'jasmine-menu jasmine-bar jasmine-spec-list' }, + [createDom('span', {}, 'Spec List | '), failuresLink] + ) + ); + this.#alerts.addBar( + createDom( + 'span', + { className: 'jasmine-menu jasmine-bar jasmine-failure-list' }, + [specListLink, createDom('span', {}, ' | Failures ')] + ) + ); + } + #find(selector) { return this.#getContainer().querySelector( '.jasmine_html-reporter ' + selector diff --git a/src/html/HtmlReporterV2.js b/src/html/HtmlReporterV2.js index 6a114105..cb703beb 100644 --- a/src/html/HtmlReporterV2.js +++ b/src/html/HtmlReporterV2.js @@ -3,6 +3,10 @@ jasmineRequire.HtmlReporterV2 = function(j$) { const { createDom, noExpectations } = j$.private.htmlReporterUtils; + const specListTabId = 'jasmine-specListTab'; + const failuresTabId = 'jasmine-failuresTab'; + const perfTabId = 'jasmine-perfTab'; + /** * @class HtmlReporterV2 * @classdesc Displays results and allows re-running individual specs and suites. @@ -31,6 +35,7 @@ jasmineRequire.HtmlReporterV2 = function(j$) { // Sub-views #alerts; #statusBar; + #tabBar; #progress; #banner; #failures; @@ -68,6 +73,25 @@ jasmineRequire.HtmlReporterV2 = function(j$) { this.#statusBar = new j$.private.OverallStatusBar(this.#urlBuilder); this.#statusBar.showRunning(); this.#alerts.addBar(this.#statusBar.rootEl); + + this.#tabBar = new j$.private.TabBar( + [ + { id: specListTabId, label: 'Spec List' }, + { id: failuresTabId, label: 'Failures' }, + { id: perfTabId, label: 'Performance' } + ], + tabId => { + if (tabId === specListTabId) { + this.#setMenuModeTo('jasmine-spec-list'); + } else if (tabId === failuresTabId) { + this.#setMenuModeTo('jasmine-failure-list'); + } else { + this.#setMenuModeTo('jasmine-performance'); + } + } + ); + this.#alerts.addBar(this.#tabBar.rootEl); + this.#progress = new ProgressView(); this.#banner = new j$.private.Banner( this.#queryString.navigateWithNewParam.bind(this.#queryString), @@ -158,15 +182,17 @@ jasmineRequire.HtmlReporterV2 = function(j$) { ); summary.addResults(this.#stateBuilder.topResults); results.appendChild(summary.rootEl); + const perf = new j$.private.PerformanceView(); + perf.addResults(this.#stateBuilder.topResults); + results.appendChild(perf.rootEl); + this.#tabBar.showTab(specListTabId); + this.#tabBar.showTab(perfTabId); if (this.#stateBuilder.anyNonTopSuiteFailures) { - this.#alerts.addFailureToggle( - () => this.#setMenuModeTo('jasmine-failure-list'), - () => this.#setMenuModeTo('jasmine-spec-list') - ); - - this.#setMenuModeTo('jasmine-failure-list'); - this.#failures.show(); + this.#tabBar.showTab(failuresTabId); + this.#tabBar.selectTab(failuresTabId); + } else { + this.#tabBar.selectTab(specListTabId); } } @@ -205,7 +231,6 @@ jasmineRequire.HtmlReporterV2 = function(j$) { this.rootEl.value = this.rootEl.value + 1; if (result.status === 'failed') { - // TODO: also a non-color indicator this.rootEl.classList.add('failed'); } } diff --git a/src/html/PerformanceView.js b/src/html/PerformanceView.js new file mode 100644 index 00000000..5f1f2948 --- /dev/null +++ b/src/html/PerformanceView.js @@ -0,0 +1,98 @@ +jasmineRequire.PerformanceView = function(j$) { + const createDom = j$.private.htmlReporterUtils.createDom; + const MAX_SLOW_SPECS = 20; + + class PerformanceView { + #summary; + #tbody; + + constructor() { + this.#tbody = document.createElement('tbody'); + this.#summary = document.createElement('div'); + this.rootEl = createDom( + 'div', + { className: 'jasmine-performance-view' }, + createDom('h2', {}, 'Performance'), + this.#summary, + createDom('h3', {}, 'Slowest Specs'), + createDom( + 'table', + {}, + createDom( + 'thead', + {}, + createDom( + 'tr', + {}, + createDom('th', {}, 'Duration'), + createDom('th', {}, 'Spec Name') + ) + ), + this.#tbody + ) + ); + } + + addResults(resultsTree) { + const specResults = []; + getSpecResults(resultsTree, specResults); + + if (specResults.length === 0) { + return; + } + + specResults.sort(function(a, b) { + if (a.duration < b.duration) { + return 1; + } else if (a.duration > b.duration) { + return -1; + } else { + return 0; + } + }); + + this.#populateSumary(specResults); + this.#populateTable(specResults); + } + + #populateSumary(specResults) { + const total = specResults.map(r => r.duration).reduce((a, b) => a + b, 0); + const mean = total / specResults.length; + const median = specResults[Math.floor(specResults.length / 2)].duration; + this.#summary.appendChild( + document.createTextNode(`Mean spec run time: ${mean.toFixed(0)}ms`) + ); + this.#summary.appendChild(document.createElement('br')); + this.#summary.appendChild( + document.createTextNode(`Median spec run time: ${median}ms`) + ); + } + + #populateTable(specResults) { + specResults = specResults.slice(0, MAX_SLOW_SPECS); + + for (const r of specResults) { + this.#tbody.appendChild( + createDom( + 'tr', + {}, + createDom('td', {}, `${r.duration}ms`), + createDom('td', {}, r.fullName) + ) + ); + } + } + } + + function getSpecResults(resultsTree, dest) { + for (const node of resultsTree.children) { + if (node.type === 'suite') { + getSpecResults(node, dest); + } else if (node.result.status !== 'excluded') { + dest.push(node.result); + } + } + } + + return PerformanceView; +}; diff --git a/src/html/TabBar.js b/src/html/TabBar.js new file mode 100644 index 00000000..0f4742ff --- /dev/null +++ b/src/html/TabBar.js @@ -0,0 +1,77 @@ +jasmineRequire.TabBar = function(j$) { + const createDom = j$.private.htmlReporterUtils.createDom; + + class TabBar { + #tabs; + #onSelectTab; + + // tabSpecs should be an array of {id, label}. + // All tabs are initially not visible and not selected. + constructor(tabSpecs, onSelectTab) { + this.#onSelectTab = onSelectTab; + this.#tabs = []; + this.#tabs = tabSpecs.map(ts => new Tab(ts, () => this.selectTab(ts.id))); + + this.rootEl = createDom( + 'span', + { className: 'jasmine-menu jasmine-bar' }, + this.#tabs.map(t => t.rootEl) + ); + } + + showTab(id) { + for (const tab of this.#tabs) { + if (tab.rootEl.id === id) { + tab.setVisibility(true); + } + } + } + + selectTab(id) { + for (const tab of this.#tabs) { + tab.setSelected(tab.rootEl.id === id); + } + + this.#onSelectTab(id); + } + } + + class Tab { + #spec; + #onClick; + + constructor(spec, onClick) { + this.#spec = spec; + this.#onClick = onClick; + this.rootEl = createDom( + 'span', + { id: spec.id, className: 'jasmine-tab jasmine-hidden' }, + this.#createLink() + ); + } + + setVisibility(visible) { + this.rootEl.classList.toggle('jasmine-hidden', !visible); + } + + setSelected(selected) { + if (selected) { + this.rootEl.textContent = this.#spec.label; + } else { + this.rootEl.textContent = ''; + this.rootEl.appendChild(this.#createLink()); + } + } + + #createLink() { + const link = createDom('a', { href: '#' }, this.#spec.label); + link.addEventListener('click', e => { + e.preventDefault(); + this.#onClick(); + }); + return link; + } + } + + return TabBar; +}; diff --git a/src/html/_HTMLReporter.scss b/src/html/_HTMLReporter.scss index 18535761..e804397f 100644 --- a/src/html/_HTMLReporter.scss +++ b/src/html/_HTMLReporter.scss @@ -280,15 +280,25 @@ body { } // simplify toggle control between the two menu bars + // TODO: clean this up once HtmlReporter is removed &.jasmine-spec-list { .jasmine-bar.jasmine-menu.jasmine-failure-list, - .jasmine-results .jasmine-failures { + .jasmine-results .jasmine-failures, + .jasmine-performance-view { display: none; } } &.jasmine-failure-list { .jasmine-bar.jasmine-menu.jasmine-spec-list, + .jasmine-summary, + .jasmine-performance-view { + display: none; + } + } + + &.jasmine-performance { + .jasmine-results .jasmine-failures, .jasmine-summary { display: none; } @@ -464,3 +474,26 @@ body { } } } + +.jasmine-hidden { + display: none; +} + +.jasmine-tab + .jasmine-tab:before { + content: ' | '; +} + +.jasmine-performance-view { + h2, h3 { + margin-top: 1em; + margin-bottom: 1em; + } + + table { + border-spacing: 5px; + } + + th, td { + text-align: left; + } +} diff --git a/src/html/requireHtml.js b/src/html/requireHtml.js index df9d85bc..17fd9420 100644 --- a/src/html/requireHtml.js +++ b/src/html/requireHtml.js @@ -11,6 +11,8 @@ jasmineRequire.html = function(j$) { j$.private.SymbolsView = jasmineRequire.SymbolsView(j$); j$.private.SummaryTreeView = jasmineRequire.SummaryTreeView(j$); j$.private.FailuresView = jasmineRequire.FailuresView(j$); + j$.private.PerformanceView = jasmineRequire.PerformanceView(j$); + j$.private.TabBar = jasmineRequire.TabBar(j$); j$.HtmlReporter = jasmineRequire.HtmlReporter(j$); j$.HtmlReporterV2Urls = jasmineRequire.HtmlReporterV2Urls(j$); j$.HtmlReporterV2 = jasmineRequire.HtmlReporterV2(j$);