From d31d33aeb35c6e62569d7b599db3b5d5cc469549 Mon Sep 17 00:00:00 2001 From: Steve Gravrock Date: Sun, 26 Oct 2025 12:50:42 -0700 Subject: [PATCH 1/3] Introduce a tab bar This will make it easier to add a third tab to HtmlReporterV2. --- lib/jasmine-core/jasmine-html.js | 181 ++++++++++++++++++--- lib/jasmine-core/jasmine.css | 8 + spec/html/HtmlReporterV2Spec.js | 261 +++++++++++++++++++++++++------ spec/html/TabBarSpec.js | 97 ++++++++++++ src/html/AlertsView.js | 23 ++- src/html/HtmlReporter.js | 46 +++++- src/html/HtmlReporterV2.js | 33 +++- src/html/TabBar.js | 77 +++++++++ src/html/_HTMLReporter.scss | 8 + src/html/requireHtml.js | 1 + 10 files changed, 646 insertions(+), 89 deletions(-) create mode 100644 spec/html/TabBarSpec.js create mode 100644 src/html/TabBar.js diff --git a/lib/jasmine-core/jasmine-html.js b/lib/jasmine-core/jasmine-html.js index c1637e7b..a8963132 100644 --- a/lib/jasmine-core/jasmine-html.js +++ b/lib/jasmine-core/jasmine-html.js @@ -35,6 +35,7 @@ jasmineRequire.html = function(j$) { j$.private.SymbolsView = jasmineRequire.SymbolsView(j$); j$.private.SummaryTreeView = jasmineRequire.SummaryTreeView(j$); j$.private.FailuresView = jasmineRequire.FailuresView(j$); + j$.private.TabBar = jasmineRequire.TabBar(j$); j$.HtmlReporter = jasmineRequire.HtmlReporter(j$); j$.HtmlReporterV2Urls = jasmineRequire.HtmlReporterV2Urls(j$); j$.HtmlReporterV2 = jasmineRequire.HtmlReporterV2(j$); @@ -187,16 +188,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 +471,7 @@ jasmineRequire.AlertsView = function(j$) { ); } + // TODO: remove this once HtmlReporterV2 doesn't use it addFailureToggle(onClickFailures, onClickSpecList) { const failuresLink = createDom( 'a', @@ -456,14 +494,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 +990,9 @@ jasmineRequire.HtmlReporterV2 = function(j$) { const { createDom, noExpectations } = j$.private.htmlReporterUtils; + const specListTabId = 'jasmine-specListTab'; + const failuresTabId = 'jasmine-failuresTab'; + /** * @class HtmlReporterV2 * @classdesc Displays results and allows re-running individual specs and suites. @@ -974,6 +1021,7 @@ jasmineRequire.HtmlReporterV2 = function(j$) { // Sub-views #alerts; #statusBar; + #tabBar; #progress; #banner; #failures; @@ -1011,6 +1059,22 @@ 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' } + ], + tabId => { + if (tabId === specListTabId) { + this.#setMenuModeTo('jasmine-spec-list'); + } else { + this.#setMenuModeTo('jasmine-failure-list'); + } + } + ); + this.#alerts.addBar(this.#tabBar.rootEl); + this.#progress = new ProgressView(); this.#banner = new j$.private.Banner( this.#queryString.navigateWithNewParam.bind(this.#queryString), @@ -1103,13 +1167,11 @@ jasmineRequire.HtmlReporterV2 = function(j$) { results.appendChild(summary.rootEl); 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(specListTabId); + this.#tabBar.showTab(failuresTabId); + this.#tabBar.selectTab(failuresTabId); + } else { + this.#tabBar.selectTab(specListTabId); } } @@ -1148,7 +1210,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'); } } @@ -1665,3 +1726,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..e0596114 100644 --- a/lib/jasmine-core/jasmine.css +++ b/lib/jasmine-core/jasmine.css @@ -323,4 +323,12 @@ body { } .jasmine_html-reporter .jasmine-debug-log .jasmine-debug-log-msg { white-space: pre; +} + +.jasmine-hidden { + display: none; +} + +.jasmine-tab + .jasmine-tab:before { + content: " | "; } \ No newline at end of file diff --git a/spec/html/HtmlReporterV2Spec.js b/spec/html/HtmlReporterV2Spec.js index e1a1c6f3..cafebe4a 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,193 @@ 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(2); + expect(tabs[0].textContent).toEqual('Spec List'); + expect(tabs[1].textContent).toEqual('Failures'); + checkHidden(tabs, [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]); + }); + }); + + describe('when Jasmine is done', function() { + function hasSpecOrSuiteFailureBehavior(reportEvents) { + let reporter; + + beforeEach(function() { + reporter = setup(); + reporter.initialize(); + reportEvents(reporter); + }); + + it('shows the Spec List and Failures tabs', function() { + const tabs = container.querySelectorAll('.jasmine-tab'); + checkHidden(tabs, [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('does not show any tabs', function() { + const tabs = container.querySelectorAll('.jasmine-tab'); + checkHidden(tabs, [true, true]); + }); + + 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: [{}] + }); + }); + }); + }); + }); + 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 +636,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 +666,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 +696,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 +942,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 +1256,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/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..e1d1205d 100644 --- a/src/html/HtmlReporterV2.js +++ b/src/html/HtmlReporterV2.js @@ -3,6 +3,9 @@ jasmineRequire.HtmlReporterV2 = function(j$) { const { createDom, noExpectations } = j$.private.htmlReporterUtils; + const specListTabId = 'jasmine-specListTab'; + const failuresTabId = 'jasmine-failuresTab'; + /** * @class HtmlReporterV2 * @classdesc Displays results and allows re-running individual specs and suites. @@ -31,6 +34,7 @@ jasmineRequire.HtmlReporterV2 = function(j$) { // Sub-views #alerts; #statusBar; + #tabBar; #progress; #banner; #failures; @@ -68,6 +72,22 @@ 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' } + ], + tabId => { + if (tabId === specListTabId) { + this.#setMenuModeTo('jasmine-spec-list'); + } else { + this.#setMenuModeTo('jasmine-failure-list'); + } + } + ); + this.#alerts.addBar(this.#tabBar.rootEl); + this.#progress = new ProgressView(); this.#banner = new j$.private.Banner( this.#queryString.navigateWithNewParam.bind(this.#queryString), @@ -160,13 +180,11 @@ jasmineRequire.HtmlReporterV2 = function(j$) { results.appendChild(summary.rootEl); 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(specListTabId); + this.#tabBar.showTab(failuresTabId); + this.#tabBar.selectTab(failuresTabId); + } else { + this.#tabBar.selectTab(specListTabId); } } @@ -205,7 +223,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/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..9dc752d9 100644 --- a/src/html/_HTMLReporter.scss +++ b/src/html/_HTMLReporter.scss @@ -464,3 +464,11 @@ body { } } } + +.jasmine-hidden { + display: none; +} + +.jasmine-tab + .jasmine-tab:before { + content: ' | '; +} diff --git a/src/html/requireHtml.js b/src/html/requireHtml.js index df9d85bc..05b1800f 100644 --- a/src/html/requireHtml.js +++ b/src/html/requireHtml.js @@ -11,6 +11,7 @@ jasmineRequire.html = function(j$) { j$.private.SymbolsView = jasmineRequire.SymbolsView(j$); j$.private.SummaryTreeView = jasmineRequire.SummaryTreeView(j$); j$.private.FailuresView = jasmineRequire.FailuresView(j$); + j$.private.TabBar = jasmineRequire.TabBar(j$); j$.HtmlReporter = jasmineRequire.HtmlReporter(j$); j$.HtmlReporterV2Urls = jasmineRequire.HtmlReporterV2Urls(j$); j$.HtmlReporterV2 = jasmineRequire.HtmlReporterV2(j$); From 8f13684a019ae20ca587153cd96d4b94fc23d512 Mon Sep 17 00:00:00 2001 From: Steve Gravrock Date: Fri, 14 Nov 2025 18:31:20 -0800 Subject: [PATCH 2/3] Add a slowest specs list to HTMLReporterV2 --- lib/jasmine-core/jasmine-html.js | 88 ++++++++++++++++++++++++++++++-- lib/jasmine-core/jasmine.css | 10 +++- spec/html/HtmlReporterV2Spec.js | 35 ++++++++++--- spec/html/PerformanceViewSpec.js | 69 +++++++++++++++++++++++++ src/html/HtmlReporterV2.js | 14 +++-- src/html/PerformanceView.js | 72 ++++++++++++++++++++++++++ src/html/_HTMLReporter.scss | 12 ++++- src/html/requireHtml.js | 1 + 8 files changed, 285 insertions(+), 16 deletions(-) create mode 100644 spec/html/PerformanceViewSpec.js create mode 100644 src/html/PerformanceView.js diff --git a/lib/jasmine-core/jasmine-html.js b/lib/jasmine-core/jasmine-html.js index a8963132..104e3d0f 100644 --- a/lib/jasmine-core/jasmine-html.js +++ b/lib/jasmine-core/jasmine-html.js @@ -35,6 +35,7 @@ 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$); @@ -992,6 +993,7 @@ jasmineRequire.HtmlReporterV2 = function(j$) { const specListTabId = 'jasmine-specListTab'; const failuresTabId = 'jasmine-failuresTab'; + const perfTabId = 'jasmine-perfTab'; /** * @class HtmlReporterV2 @@ -1063,13 +1065,16 @@ jasmineRequire.HtmlReporterV2 = function(j$) { this.#tabBar = new j$.private.TabBar( [ { id: specListTabId, label: 'Spec List' }, - { id: failuresTabId, label: 'Failures' } + { id: failuresTabId, label: 'Failures' }, + { id: perfTabId, label: 'Performance' } ], tabId => { if (tabId === specListTabId) { this.#setMenuModeTo('jasmine-spec-list'); - } else { + } else if (tabId === failuresTabId) { this.#setMenuModeTo('jasmine-failure-list'); + } else { + this.#setMenuModeTo('jasmine-performance'); } } ); @@ -1165,9 +1170,13 @@ 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.#tabBar.showTab(specListTabId); this.#tabBar.showTab(failuresTabId); this.#tabBar.selectTab(failuresTabId); } else { @@ -1495,6 +1504,79 @@ jasmineRequire.OverallStatusBar = function(j$) { return OverallStatusBar; }; +jasmineRequire.PerformanceView = function(j$) { + const createDom = j$.private.htmlReporterUtils.createDom; + const MAX_SLOW_SPECS = 20; + + class PerformanceView { + #tbody; + + constructor() { + this.#tbody = document.createElement('tbody'); + this.rootEl = createDom( + 'div', + { className: 'jasmine-performance-view' }, + createDom('h2', {}, 'Performance'), + createDom('h3', {}, 'Slowest Specs'), + createDom( + 'table', + {}, + createDom( + 'thead', + {}, + createDom( + 'tr', + {}, + createDom('th', {}, 'Duration'), + createDom('th', {}, 'Spec Name') + ) + ), + this.#tbody + ) + ); + } + + addResults(resultsTree) { + let specResults = []; + getSpecResults(resultsTree, specResults); + + specResults.sort(function(a, b) { + if (a.duration < b.duration) { + return 1; + } else if (a.duration > b.duration) { + return -1; + } else { + return 0; + } + }); + 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'; diff --git a/lib/jasmine-core/jasmine.css b/lib/jasmine-core/jasmine.css index e0596114..167639d0 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 { diff --git a/spec/html/HtmlReporterV2Spec.js b/spec/html/HtmlReporterV2Spec.js index cafebe4a..d422e817 100644 --- a/spec/html/HtmlReporterV2Spec.js +++ b/spec/html/HtmlReporterV2Spec.js @@ -275,10 +275,11 @@ describe('HtmlReporterV2', function() { reporter.initialize(); reporter.jasmineStarted({ totalSpecsDefined: 0 }); const tabs = container.querySelectorAll('.jasmine-tab'); - expect(tabs.length).toEqual(2); + expect(tabs.length).toEqual(3); expect(tabs[0].textContent).toEqual('Spec List'); expect(tabs[1].textContent).toEqual('Failures'); - checkHidden(tabs, [true, true]); + expect(tabs[2].textContent).toEqual('Performance'); + checkHidden(tabs, [true, true, true]); // Results, even failures, should not show any tabs reporter.specDone({ @@ -289,7 +290,7 @@ describe('HtmlReporterV2', function() { failedExpectations: [{}], passedExpectations: [] }); - checkHidden(tabs, [true, true]); + checkHidden(tabs, [true, true, true]); }); }); @@ -303,9 +304,9 @@ describe('HtmlReporterV2', function() { reportEvents(reporter); }); - it('shows the Spec List and Failures tabs', function() { + it('shows all three tabs', function() { const tabs = container.querySelectorAll('.jasmine-tab'); - checkHidden(tabs, [false, false]); + checkHidden(tabs, [false, false, false]); }); it('selects the Failures tab', function() { @@ -357,9 +358,9 @@ describe('HtmlReporterV2', function() { reportEvents(reporter); }); - it('does not show any tabs', function() { + it('shows the Spec List and Performance tabs', function() { const tabs = container.querySelectorAll('.jasmine-tab'); - checkHidden(tabs, [true, true]); + checkHidden(tabs, [false, true, false]); }); it('shows the spec list view', function() { @@ -443,6 +444,26 @@ describe('HtmlReporterV2', function() { }); }); }); + + 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'); + }); }); }); diff --git a/spec/html/PerformanceViewSpec.js b/spec/html/PerformanceViewSpec.js new file mode 100644 index 00000000..3da3e359 --- /dev/null +++ b/spec/html/PerformanceViewSpec.js @@ -0,0 +1,69 @@ +'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('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']); + }); +}); diff --git a/src/html/HtmlReporterV2.js b/src/html/HtmlReporterV2.js index e1d1205d..cb703beb 100644 --- a/src/html/HtmlReporterV2.js +++ b/src/html/HtmlReporterV2.js @@ -5,6 +5,7 @@ jasmineRequire.HtmlReporterV2 = function(j$) { const specListTabId = 'jasmine-specListTab'; const failuresTabId = 'jasmine-failuresTab'; + const perfTabId = 'jasmine-perfTab'; /** * @class HtmlReporterV2 @@ -76,13 +77,16 @@ jasmineRequire.HtmlReporterV2 = function(j$) { this.#tabBar = new j$.private.TabBar( [ { id: specListTabId, label: 'Spec List' }, - { id: failuresTabId, label: 'Failures' } + { id: failuresTabId, label: 'Failures' }, + { id: perfTabId, label: 'Performance' } ], tabId => { if (tabId === specListTabId) { this.#setMenuModeTo('jasmine-spec-list'); - } else { + } else if (tabId === failuresTabId) { this.#setMenuModeTo('jasmine-failure-list'); + } else { + this.#setMenuModeTo('jasmine-performance'); } } ); @@ -178,9 +182,13 @@ 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.#tabBar.showTab(specListTabId); this.#tabBar.showTab(failuresTabId); this.#tabBar.selectTab(failuresTabId); } else { diff --git a/src/html/PerformanceView.js b/src/html/PerformanceView.js new file mode 100644 index 00000000..fddc5eff --- /dev/null +++ b/src/html/PerformanceView.js @@ -0,0 +1,72 @@ +jasmineRequire.PerformanceView = function(j$) { + const createDom = j$.private.htmlReporterUtils.createDom; + const MAX_SLOW_SPECS = 20; + + class PerformanceView { + #tbody; + + constructor() { + this.#tbody = document.createElement('tbody'); + this.rootEl = createDom( + 'div', + { className: 'jasmine-performance-view' }, + createDom('h2', {}, 'Performance'), + createDom('h3', {}, 'Slowest Specs'), + createDom( + 'table', + {}, + createDom( + 'thead', + {}, + createDom( + 'tr', + {}, + createDom('th', {}, 'Duration'), + createDom('th', {}, 'Spec Name') + ) + ), + this.#tbody + ) + ); + } + + addResults(resultsTree) { + let specResults = []; + getSpecResults(resultsTree, specResults); + + specResults.sort(function(a, b) { + if (a.duration < b.duration) { + return 1; + } else if (a.duration > b.duration) { + return -1; + } else { + return 0; + } + }); + 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/_HTMLReporter.scss b/src/html/_HTMLReporter.scss index 9dc752d9..d6a285fb 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; } diff --git a/src/html/requireHtml.js b/src/html/requireHtml.js index 05b1800f..17fd9420 100644 --- a/src/html/requireHtml.js +++ b/src/html/requireHtml.js @@ -11,6 +11,7 @@ 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$); From 3899d83fb69123a37185ec1165fbf0015a9abf90 Mon Sep 17 00:00:00 2001 From: Steve Gravrock Date: Sat, 15 Nov 2025 09:01:06 -0800 Subject: [PATCH 3/3] HtmlReporterV2: show median and mean spec run time --- lib/jasmine-core/jasmine-html.js | 28 ++++++++++++++++++++++- lib/jasmine-core/jasmine.css | 11 +++++++++ spec/html/PerformanceViewSpec.js | 38 ++++++++++++++++++++++++++++++++ src/html/PerformanceView.js | 28 ++++++++++++++++++++++- src/html/_HTMLReporter.scss | 15 +++++++++++++ 5 files changed, 118 insertions(+), 2 deletions(-) diff --git a/lib/jasmine-core/jasmine-html.js b/lib/jasmine-core/jasmine-html.js index 104e3d0f..b7029468 100644 --- a/lib/jasmine-core/jasmine-html.js +++ b/lib/jasmine-core/jasmine-html.js @@ -1509,14 +1509,17 @@ jasmineRequire.PerformanceView = function(j$) { 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', @@ -1537,9 +1540,13 @@ jasmineRequire.PerformanceView = function(j$) { } addResults(resultsTree) { - let specResults = []; + const specResults = []; getSpecResults(resultsTree, specResults); + if (specResults.length === 0) { + return; + } + specResults.sort(function(a, b) { if (a.duration < b.duration) { return 1; @@ -1549,6 +1556,25 @@ jasmineRequire.PerformanceView = function(j$) { 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) { diff --git a/lib/jasmine-core/jasmine.css b/lib/jasmine-core/jasmine.css index 167639d0..24cf47dd 100644 --- a/lib/jasmine-core/jasmine.css +++ b/lib/jasmine-core/jasmine.css @@ -337,4 +337,15 @@ body { .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/PerformanceViewSpec.js b/spec/html/PerformanceViewSpec.js index 3da3e359..63e5f873 100644 --- a/spec/html/PerformanceViewSpec.js +++ b/spec/html/PerformanceViewSpec.js @@ -44,6 +44,42 @@ describe('PerformanceView', function() { 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({ @@ -65,5 +101,7 @@ describe('PerformanceView', function() { 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/src/html/PerformanceView.js b/src/html/PerformanceView.js index fddc5eff..5f1f2948 100644 --- a/src/html/PerformanceView.js +++ b/src/html/PerformanceView.js @@ -3,14 +3,17 @@ jasmineRequire.PerformanceView = function(j$) { 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', @@ -31,9 +34,13 @@ jasmineRequire.PerformanceView = function(j$) { } addResults(resultsTree) { - let specResults = []; + const specResults = []; getSpecResults(resultsTree, specResults); + if (specResults.length === 0) { + return; + } + specResults.sort(function(a, b) { if (a.duration < b.duration) { return 1; @@ -43,6 +50,25 @@ jasmineRequire.PerformanceView = function(j$) { 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) { diff --git a/src/html/_HTMLReporter.scss b/src/html/_HTMLReporter.scss index d6a285fb..e804397f 100644 --- a/src/html/_HTMLReporter.scss +++ b/src/html/_HTMLReporter.scss @@ -482,3 +482,18 @@ body { .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; + } +}