Skip to content

Instantly share code, notes, and snippets.

@SaladFork
Last active April 14, 2016 16:52
Show Gist options
  • Save SaladFork/15683b00388bfe1d1458 to your computer and use it in GitHub Desktop.
Save SaladFork/15683b00388bfe1d1458 to your computer and use it in GitHub Desktop.
Mocha Reporter (+blanket.js)
  1. Put reporter.js in tests/helpers/reporter.js
  2. Change tests/test-helper.js to look like the attached
  3. Put test-container-styles.css in vendor/ember-cli-mocha to override the default
  4. Stop your server and restart (it doesn't watch vendor/ by default)
/*
* 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
#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;
}
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
});
@lolmaus
Copy link

lolmaus commented Dec 17, 2015

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment