- Put
reporter.js
intests/helpers/reporter.js
- Change
tests/test-helper.js
to look like the attached - Put
test-container-styles.css
invendor/ember-cli-mocha
to override the default - Stop your server and restart (it doesn't watch
vendor/
by default)
Last active
April 14, 2016 16:52
-
-
Save SaladFork/15683b00388bfe1d1458 to your computer and use it in GitHub Desktop.
Mocha Reporter (+blanket.js)
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* | |
* A Mocha reporter meant to be used with ember-cli-mocha and ember-cli-blanket | |
* | |
* Based on Edward Faulnker's better-mocha-html-reporter: | |
* <https://github.com/ef4/better-mocha-html-reporter> | |
* | |
* With modifications from Elad Shahar: | |
* <https://gist.github.com/SaladFork/15683b00388bfe1d1458> | |
*/ | |
export default class Reporter { | |
constructor(runner) { | |
this.passes = 0; | |
this.failures = 0; | |
this.runner = runner; | |
this.setupDOM(); | |
this.setupEvents(runner); | |
this.setupBlanket(); | |
} | |
setupDOM() { | |
const $rootNode = $('#mocha'); | |
if (!$rootNode) { | |
throw new Error('#mocha missing, ensure it is in your document'); | |
} | |
$rootNode.append(template); | |
$('#test-title').text(document.title); | |
this.setupCanvas(); | |
this.$stats = $('#mocha-stats'); | |
this.stack = [$('#mocha-report')]; | |
this.$stats.find('#hide-passed') | |
.attr('checked', /hide_passed/.test(window.location.hash)) | |
.on('change', () => this.updateHidePassed()); | |
this.updateHidePassed(); | |
this.$stats.find('#enable-coverage') | |
.attr('checked', /coverage/.test(window.location.search)) | |
.on('change', () => this.updateCoverageEnabled()); | |
this.updateCoverageEnabled(); | |
} | |
setupEvents(runner) { | |
function handlerForEvent(event) { | |
// e.g., "suite end" => "onSuiteEnd" | |
return `on ${event}`.replace(/ [\w]/g, (m) => m[1].toUpperCase()); | |
} | |
const events = [ | |
'start', // execution of testing started | |
'end', // execution of testing ended | |
'suite', // execution of a test suite started | |
'suite end', // execution of a test suite ended | |
'test', // execution of a test started | |
'test end', // execution of a test ended | |
'hook', // execution of a hook started | |
'hook end', // execution of a hook ended | |
'pass', // execution of a test ended in pass | |
'fail', // execution of a test ended in fail | |
'pending' | |
]; | |
events.forEach((event) => { | |
const reporter = this; | |
runner.on(event, function(/* arguments */) { | |
let handler = reporter[handlerForEvent(event)]; | |
if (handler) { | |
handler.apply(reporter, arguments); | |
} | |
}); | |
}); | |
} | |
setupBlanket() { | |
if (!window.blanket) { | |
return; | |
} | |
const { blanket } = window; | |
const { onTestsDone: origOnTestsDone } = blanket; | |
blanket.onTestsDone = () => { | |
origOnTestsDone.apply(blanket); | |
this.onBlanketDone(); | |
}; | |
} | |
setupCanvas() { | |
const ratio = window.devicePixelRatio || 1; | |
this.canvas = $('.mocha-progress canvas')[0]; | |
this.ctx = this.canvas.getContext('2d'); | |
this.ctx.scale(ratio, ratio); | |
} | |
updateDuration() { | |
const seconds = (new Date() - this.startedAt) / 1000; | |
this.$stats.find('.duration .value').text(seconds.toFixed(2)); | |
} | |
updateProgress() { | |
try { | |
const { canvas: { clientWidth: width } } = this; | |
this.renderProgressRing(width); | |
} catch (err) { | |
// don't fail if we can't render progress | |
} | |
} | |
renderProgressRing(diameter) { | |
const totalTests = this.passes + this.failures; | |
const progress = totalTests / this.runner.total * 100 | 0; | |
const percent = Math.min(progress, 100); | |
const angle = Math.PI * 2 * (percent / 100); | |
const halfSize = diameter / 2; | |
const rad = halfSize - 1; | |
const fontSize = 11; | |
const { ctx } = this; | |
const quarterCircle = 0.5 * Math.PI; | |
ctx.font = `${fontSize}px helvetica, arial, sans-serif`; | |
ctx.clearRect(0, 0, diameter, diameter); | |
// outer circle | |
ctx.strokeStyle = '#9f9f9f'; | |
ctx.beginPath(); | |
ctx.arc(halfSize, halfSize, rad, -quarterCircle, angle - quarterCircle, false); | |
ctx.stroke(); | |
// inner circle | |
ctx.strokeStyle = '#eee'; | |
ctx.beginPath(); | |
ctx.arc(halfSize, halfSize, rad - 1, -quarterCircle, angle - quarterCircle, true); | |
ctx.stroke(); | |
// text | |
const text = `${(percent | 0)}%`; | |
const textWidth = ctx.measureText(text).width; | |
ctx.fillText(text, halfSize - textWidth / 2 + 1, halfSize + fontSize / 2 - 1); | |
} | |
updateHidePassed() { | |
if (this.$stats.find('#hide-passed').is(':checked')) { | |
$('#mocha-report').addClass('hide-passed'); | |
$('#blanket-main').addClass('hide-passed'); | |
window.location.hash = '#hide_passed'; | |
} else { | |
$('#mocha-report').removeClass('hide-passed'); | |
$('#blanket-main').removeClass('hide-passed'); | |
window.location.hash = '#'; | |
} | |
} | |
updateCoverageEnabled() { | |
if (this.$stats.find('#enable-coverage').is(':checked')) { | |
if (!/[?&]coverage/.test(window.location.search)) { | |
window.location.search = '?coverage'; | |
} | |
} else { | |
if (/[?&]coverage/.test(window.location.search)) { | |
window.location.search = ''; | |
} | |
} | |
} | |
setMood(mood) { | |
this.$stats.removeClass(this.mood); | |
this.mood = mood; | |
this.$stats.addClass(mood); | |
setFavicon(mood); | |
} | |
onStart() { | |
this.startedAt = new Date(); | |
} | |
onEnd() { | |
if (this.mood !== 'sad') { | |
this.setMood('happy'); | |
} | |
groupDescribes('JSHint'); | |
groupDescribes('JSCS'); | |
} | |
onSuite(suite) { | |
if (suite.root) { return; } | |
const title = suite.fullTitle(); | |
const $fragment = $('<li class="suite"><h1><a></a></h1><ul></ul></li>'); | |
$fragment.find('a').text(suite.title).attr('href', grepUrl(title)); | |
this.stack[0].append($fragment); | |
this.stack.unshift($fragment.find('ul')); | |
} | |
onSuiteEnd(suite) { | |
if (suite.root) { return; } | |
const $ul = this.stack.shift(); | |
if ($ul.find('.fail').length > 0) { | |
$ul.parent().addClass('fail'); | |
} else { | |
$ul.parent().addClass('pass'); | |
} | |
} | |
onTestEnd(test) { | |
this.updateDuration(); | |
const $fragment = fragmentForTest(test); | |
if (!this.stack[0]) { | |
const $report = $('#mocha-report'); | |
$report.append('<li class="suite"><h1></h1><ul></ul></li>'); | |
$report.find('h1').text('ORPHAN TESTS'); | |
this.stack.unshift($report.find('ul')); | |
} | |
this.stack[0].append($fragment); | |
this.updateProgress(); | |
} | |
onPass() { | |
this.passes++; | |
this.$stats.find('.passes .value').text(this.passes); | |
} | |
onFail(test, err) { | |
this.failures++; | |
this.$stats.find('.failures .value').text(this.failures); | |
this.setMood('sad'); | |
test.err = err; | |
if (test.type === 'hook') { | |
// This is a bizarre misfeature in mocha, but apparently without | |
// the reporter feeding this back, you will never hear these | |
// hook failures. Things like the testem mocha adapter assume | |
// this behavior. | |
this.runner.emit('test end', test); | |
} | |
} | |
onBlanketDone() { | |
const $blanket = $('#blanket-main'); | |
const $title = $blanket.find('.bl-title > .bl-file'); | |
$title.text('Code Coverage'); | |
this.updateHidePassed(); | |
} | |
} | |
function grepUrl(pattern) { | |
let { location } = window; | |
let { search } = location; | |
if (search) { | |
search = search.replace(/[?&]grep=[^&\s]*/g, '').replace(/^&/, '?'); | |
} | |
let prefix = search ? `${search}&` : '?'; | |
let { pathname: locationPath } = location; | |
let encodedPattern = encodeURIComponent(pattern); | |
return `${locationPath}${prefix}grep=${encodedPattern}`; | |
} | |
function fragmentForTest(test) { | |
const $fragment = $('<li class="test"><h2><span class="title"></h2></li>'); | |
$fragment.find('h2 .title').text(test.title); | |
$fragment.addClass(speedOfTest(test)); | |
if (test.state === 'passed') { | |
$fragment.addClass('pass'); | |
$fragment.find('h2').append('<span class="duration"></span>'); | |
$fragment.find('.duration').text(`${test.duration}ms`); | |
} else if (test.pending) { | |
$fragment.addClass('pass') | |
.addClass('pending'); | |
} else { | |
$fragment.addClass('fail'); | |
$fragment.append('<pre class="error"></pre>'); | |
$fragment.find('.error').text(errorSummaryForTest(test)) | |
.append('<div class="dump">Dump stack to console</div>'); | |
$fragment.find('.dump').on('click', () => console.log(test.err.stack)); | |
} | |
if (!test.pending) { | |
const h2 = $fragment.find('h2'); | |
h2.append('<a class="replay" title="Replay">‣</a>'); | |
h2.find('.replay').attr('href', grepUrl(test.fullTitle())); | |
const code = $('<pre><code></code></pre>'); | |
if (test.state === 'passed') { | |
code.css('display', 'none'); | |
} | |
code.find('code').text(cleanCode(test.fn.toString())); | |
$fragment.append(code); | |
h2.on('click', () => code.toggle()); | |
} | |
return $fragment; | |
} | |
function speedOfTest(test) { | |
const slow = test.slow(); | |
const medium = slow / 2; | |
if (test.duration > slow) { | |
return 'slow'; | |
} else if (test.duration > medium) { | |
return 'medium'; | |
} | |
return 'fast'; | |
} | |
function errorSummaryForTest(test) { | |
let summary = test.err.stack || test.err.toString(); | |
if (summary.indexOf(test.err.message) === -1) { | |
summary = `${test.err.message}\n${summary}`; | |
} | |
if (summary === '[object Error]') { | |
summary = test.err.message; | |
} | |
if (!test.err.stack && test.err.sourceURL && test.err.line !== undefined) { | |
summary += `\n(${test.err.sourceURL}:${test.err.line})`; | |
} | |
return summary; | |
} | |
function cleanCode(code) { | |
code = code.replace(/\r\n?|[\n\u2028\u2029]/g, '\n').replace(/^\uFEFF/, '') | |
.replace(/^function *\(.*\) *{|\(.*\) *=> *{?/, '') | |
.replace(/\s+\}$/, ''); | |
const spaces = code.match(/^\n?( *)/)[1].length; | |
const tabs = code.match(/^\n?(\t*)/)[1].length; | |
const count = tabs ? tabs : spaces; | |
const ws = tabs ? '\t' : ' '; | |
const re = new RegExp(`^\n?${ws}{${count}}`, 'gm'); | |
code = code.replace(re, ''); | |
return code.trim(); | |
} | |
// Original from <https://gist.github.com/timrwood/7754098> | |
function setFavicon(mood) { | |
const pngPrefix = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/'; | |
const redGraphic = `${pngPrefix}9hAAAAH0lEQVQ4T2P8z8AAROQDxlEDGEbDgGE0DIBZaBikAwCl1B/x0/RuTAAAAABJRU5ErkJggg==`; | |
const greenGraphic = `${pngPrefix}9hAAAAHklEQVQ4T2Nk+A+EFADGUQMYRsOAYTQMgHloGKQDAJXkH/HZpKBrAAAAAElFTkSuQmCC`; | |
let uri = (mood === 'happy') ? greenGraphic : redGraphic; | |
const links = $('link'); | |
// Remove existing favicons | |
links.each((idx, link) => { | |
if (/\bicon\b/i.test(link.getAttribute('rel'))) { | |
link.parentNode.removeChild(link); | |
} | |
}); | |
// Add new favicon | |
const $link = $('<link type="image/x-icon" rel="icon">'); | |
$link.attr('href', uri); | |
$('head').append($link); | |
} | |
function groupDescribes(linter) { | |
const $linter = $('<li class="suite"><h1><a></a></h1><ul></ul></li>'); | |
$linter.find('a').text(linter).attr('href', grepUrl(`{linter}`)); | |
let $suites = $(`.suite:contains("${linter}")`); | |
$suites.each((idx, suite) => { | |
let $suite = $(suite); | |
let suiteTitle = $suite.find('h1').text(); | |
let [ , fileName] = suiteTitle.match(`^${linter} - (.*)$`); | |
let $test = $suite.find('.test'); | |
$test.find('.title').text(fileName); | |
$linter.find('ul').append($test); | |
$suite.remove(); | |
}); | |
if ($linter.find('.test.fail').length > 0) { | |
$linter.addClass('fail'); | |
} else { | |
$linter.addClass('pass'); | |
} | |
$('#mocha-report').append($linter); | |
} | |
// jscs:disable disallowVar | |
var template = `<h1 id='test-title'></h1> | |
<ul id="mocha-stats"> | |
<li class="mocha-progress"><canvas width="40" height="40"></canvas></li> | |
<li class="test-option"> | |
<label> | |
<input type="checkbox" id="enable-coverage"> Enable coverage | |
</label> | |
</li> | |
<li class="test-option"> | |
<label> | |
<input type="checkbox" id="hide-passed"> Hide passed | |
</label> | |
</li> | |
<li class="passes">passes: <em class="value">0</em></li> | |
<li class="failures">failures: <em class="value">0</em></li> | |
<li class="duration">duration: <em class="value">0</em>s</li> | |
</ul> | |
<ul id="mocha-report"></ul>`; | |
// jscs:enable disallowVar |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#ember-testing-container { | |
position: fixed; | |
background: white; | |
bottom: 0; | |
right: -620px; | |
width: 640px; | |
height: 384px; | |
overflow: auto; | |
z-index: 101; | |
border: 1px solid #ccc; | |
transition: right 500ms; | |
} | |
#ember-testing-container:hover { | |
right: 0; | |
} | |
#ember-testing { | |
zoom: 50%; | |
height: 100%; | |
} | |
#mocha { | |
margin: 60px 50px 15px 50px; | |
} | |
#mocha #test-title { | |
font-size: 1em; | |
} | |
#mocha h1 { | |
padding: 0; | |
font-size: 0.75em; | |
} | |
#mocha .suite .suite h1 { | |
margin-top: 0; | |
font-size: 0.75em; | |
} | |
#mocha h2 { | |
margin: 0; | |
padding: 0; | |
} | |
#mocha-stats li { | |
padding-top: 15px; | |
} | |
#mocha-stats .mocha-progress { | |
padding-top: 3px; | |
vertical-align: middle; | |
} | |
#mocha-stats input { | |
display: inline-block; | |
} | |
#mocha-report.hide-passed .suite.pass { | |
display: none; | |
} | |
#mocha-report.hide-passed .test.pass { | |
display: none; | |
} | |
#mocha .test a.replay { | |
display: inline-block; | |
position: static; | |
margin-left: 0.5em; | |
} | |
#mocha-stats { | |
border-radius: 5px; | |
transition: background-color 1s; | |
} | |
#mocha-stats.sad { | |
background-color: #FFC8DF; | |
} | |
#mocha-stats.happy { | |
background-color: #C0FFC1; | |
} | |
#mocha .suite .dump { | |
color: black; | |
text-decoration: underline; | |
cursor: pointer; | |
} | |
div #blanket-main { | |
background: transparent; | |
color: black; | |
font-size: 12px; | |
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; | |
margin-left: 50px; | |
line-height: 1.5; | |
} | |
div #blanket-main a { | |
color: black; | |
} | |
div #blanket-main > div { | |
margin-left: 15px; | |
border: 0; | |
padding: 0; | |
} | |
div #blanket-main .bl-title { | |
font-size: 15px; | |
font-weight: 200; | |
} | |
div #blanket-main > div:not(.bl-title) { | |
margin-left: 30px; | |
} | |
div #blanket-main .bl-success { | |
color: #00d6b2; | |
} | |
div #blanket-main .bl-nb { | |
color: black; | |
} | |
div #blanket-main .grand-total > div:first-of-type { | |
color: black; | |
} | |
div #blanket-main .bl-success::before { | |
content: '✓'; | |
font-size: 12px; | |
display: block; | |
float: left; | |
margin-right: 5px; | |
color: #00d6b2; | |
} | |
div #blanket-main .bl-error::before { | |
content: '✖'; | |
font-size: 12px; | |
display: block; | |
float: left; | |
margin-right: 5px; | |
color: #c00; | |
} | |
#blanket-main.hide-passed .bl-success { | |
display: none; | |
} | |
div #blanket-main .bl-error a { | |
color: #c00; | |
} | |
#blanket-main .bl-source { | |
font-size: 12px; | |
} | |
#blanket-main .bl-source .miss { | |
background-color: #FFC8DF; | |
} | |
#blanket-main .bl-source span { | |
text-align: right; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { setResolver } from 'ember-mocha'; | |
import resolver from './helpers/resolver'; | |
import Reporter from './helpers/reporter'; | |
setResolver(resolver); | |
window.mocha.reporter((runner) => new Reporter(runner)); | |
window.mocha.setup({ | |
timeout: 10000, | |
slow: 2000 | |
}); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Awesome stuff, SaladFork! I've udpated this reporter with the invaluable "No try/catch" feature as seen in QUnit: https://gist.github.com/lolmaus/8b5e84762c85142e43c2