UNPKG

21.1 kBJavaScriptView Raw
1"use strict";
2
3const fs = require("fs");
4const path = require("path");
5const diff = require("diff");
6const print = require("print");
7const ipc = require("./ipc.js");
8const utils = require("./utils.js");
9const painter = require("./patch-painter.js");
10const {clean} = require("mocha/lib/utils.js");
11const {addTo, nearest, link, New, escapeRegExp, escapeHTML, parseKeywords} = utils;
12
13const cssPath = require.resolve("./reporter.css");
14const el = Symbol("Atom-Mocha-Elements");
15
16
17/**
18 * TTY emulator to show spec results in interactive mode.
19 *
20 * @class
21 */
22class Reporter{
23
24 constructor(runner, options){
25 this.runner = runner;
26 this.options = options;
27
28 // Register Mocha handlers
29 const events = {
30 start: this.onStart,
31 suite: this.onSuite,
32 pass: this.onPass,
33 fail: this.onFail,
34 end: this.onEnd,
35 pending: this.onPending,
36 "test end": this.onTestEnd
37 };
38 for(const event in events)
39 runner.on(event, events[event].bind(this));
40
41 // Construct something to actually show stuff with
42 this.element = New("div", {id: "mocha"});
43 this.report = New("div", {id: "mocha-report"});
44 this.element.appendChild(this.report);
45 this.element.addEventListener("mousedown", this.onClick);
46
47 // Generate stat-bar
48 this.statBar = New("footer", {id: "mocha-stats"});
49 this.element.appendChild(addTo(this.statBar)(
50 this.statBar.passes = New("div", {textContent: "0", id: "mocha-passes"}),
51 this.statBar.duration = this.createDuration(...[,,{tagName: "div", id:"mocha-duration"}]),
52 this.statBar.pending = New("div", {textContent: "0", id: "mocha-pending"}),
53 this.statBar.failures = New("div", {textContent: "0", id: "mocha-failures"})
54 )[0]);
55
56 // Check if this operating system uses opaque scrollbars (e.g., Windows)
57 if(this.scrollbarWidth = utils.getScrollbarWidth()){
58 const offset = this.scrollbarWidth + "px";
59 this.report.style.marginRight = "-" + offset;
60 this.report.style.paddingRight = offset;
61 }
62
63 document.title = options.title || "Mocha";
64 document.body.classList.add("hide", "native-key-bindings");
65 document.body.appendChild(this.element);
66 document.head.appendChild(New("link", {
67 rel: "stylesheet",
68 type: "text/css",
69 href: "file://" + cssPath
70 }));
71
72 // Manage element to store dynamic styling */
73 const ed = Reporter.editorSettings || {};
74 const rules = [
75 `background-color: rgba(0,0,0,${options.opacity || .8})`,
76 ed.fontSize ? `font-size: ${ed.fontSize}px` : null,
77 ed.lineHeight ? `line-height: ${ed.lineHeight}` : null,
78 ed.fontFamily ? `font-family: "${ed.fontFamily}"` : null,
79 ed.tabLength ? `tab-size: ${ed.tabLength}` : null
80 ].filter(Boolean).map(s => `\t${s};`);
81 document.head.appendChild(New("style", {
82 textContent: `#mocha{\n${ rules.join("\n") }\n}`
83 }));
84
85 // Preserve the spec-runner's title when opening projects
86 let title = "";
87 Object.defineProperty(document, "title", {
88 get(){ return title },
89 set(i){ title = i; }
90 });
91
92 // Configure slideable behaviour
93 if(options.slide){
94 const dir = parseKeywords(true === options.slide
95 ? "up down left right"
96 : options.slide);
97
98 this.element.classList.add("sliding");
99 for(const key of Object.keys(dir)){
100
101 // Spawn a new clickable arrowhead
102 const arrow = New("div", {
103 direction: key,
104 className: "slide-indicator " + key
105 });
106
107 arrow.addEventListener("click", e => {
108 this.element.classList.toggle("offset-" + key);
109 e.preventDefault();
110 e.stopImmediatePropagation();
111 return false;
112 });
113
114 addTo(this.element)(arrow);
115 this.element.classList.add("slide-" + key);
116 }
117 }
118
119 // Hide the statbar if hideStatBar is enabled
120 this.element.classList.toggle("hide-stats", !!options.hideStatBar);
121
122 // Use a more compact look
123 if(options.minimal){
124 this.minimal = true;
125 this.dots = New("div", {id: "mocha-dots"});
126 this.element.classList.add("minimal");
127 this.element.insertBefore(this.dots, this.report);
128 this.element.insertBefore(this.statBar, this.report);
129 }
130
131 // Enable linked source-paths in stack traces
132 if(options.linkPaths)
133 ipc.init(options.packagePath);
134 }
135
136
137 get passes(){ return +this.statBar.passes.dataset.value || 0 }
138 set passes(input){
139 input = Math.max(0, +input || 0);
140 const el = this.statBar.passes;
141 el.textContent = `${input} passing`;
142 el.dataset.value = input;
143 el.classList.toggle("zero", input === 0);
144 }
145
146 get pending(){ return +this.statBar.pending.dataset.value || 0 }
147 set pending(input){
148 input = Math.max(0, +input || 0);
149 const el = this.statBar.pending;
150 el.textContent = `${input} pending`;
151 el.dataset.value = input;
152 el.classList.toggle("zero", input === 0);
153 }
154
155 get failures(){ return +this.statBar.failures.dataset.value || 0 }
156 set failures(input){
157 input = Math.max(0, +input || 0);
158 const el = this.statBar.failures;
159 el.textContent = `${input} failing`;
160 el.dataset.value = input;
161 el.classList.toggle("zero", input === 0);
162 }
163
164 get duration(){ return this.statBar.duration || 0 }
165 set duration(input){
166 const el = this.statBar.duration;
167 el.value = input;
168 }
169
170
171 onStart(){
172 this.passes = 0;
173 this.failures = 0;
174 this.total = 0;
175 this.pending = 0;
176 this.started = performance.now();
177 setTimeout(() => {
178 document.body.classList.remove("hide");
179 }, 10);
180 }
181
182
183 onSuite(suite){
184
185 // We haven't created elements for this Mocha suite yet
186 if(!suite[el] && !suite.root)
187 this.addSuite(suite);
188 }
189
190
191 onTestEnd(){
192 ++this.total;
193 this.duration = performance.now() - this.started;
194 }
195
196
197 onPass(test){
198 ++this.passes;
199 this.addResult(test);
200 }
201
202
203 onFail(test, reason){
204 ++this.failures;
205 this.addResult(test, reason);
206 }
207
208
209 onPending(test){
210 if(this.options.hidePending) return;
211 ++this.pending;
212 this.addResult(test);
213 }
214
215
216 onEnd(){
217 this.finished = performance.now();
218 this.duration = this.finished - this.started;
219
220 if(!this.summary){
221 const total = this.runner.total - this.total;
222 const textContent = total
223 ? this.options.bail
224 ? `Bailed with ${total} tests remaining`.replace(/\b(1 test)s/, "$1")
225 : `Finished with ${total} aborted tests`.replace(/\b(1 aborted test)s/, "$1")
226 : "Finished";
227 this.summary = addTo(this.report)(New("div", {textContent}))[1];
228 if(this.options.autoScroll)
229 this.report.scrollTop = this.report.scrollHeight;
230 }
231 }
232
233
234
235 /**
236 * Handler fired when a user clicks the Reporter's HTML container.
237 *
238 * @param {MouseEvent} event
239 */
240 onClick(event){
241 const hideClass = "collapsed";
242 const {target, altKey} = event;
243 let title, node, parent, isClosed, siblings;
244
245 // Source path in stack-trace
246 if(node = nearest(target, ".stack-source.link[data-path]")){
247 const {path, line, column} = node.dataset;
248 ipc.jumpToFile(path, line - 1, column - 1);
249 }
250
251 // Individual test
252 if(title = nearest(target, ".test-title")){
253 parent = title[el].container;
254 isClosed = parent.classList.toggle(hideClass);
255
256 if(altKey){
257 for(const e of Array.from(parent.parentElement.children)){
258 if(!e[el] || !e[el].details) continue;
259 e.classList.toggle(hideClass, isClosed);
260 }
261 }
262 }
263
264 // Suite
265 else if(title = nearest(target, ".suite-title")){
266 parent = title.parentNode;
267 isClosed = !parent.classList.toggle(hideClass);
268
269 if(altKey){
270 siblings = Array.from(parent[el].parent.children);
271 siblings = siblings.filter(e => e.classList.contains("suite"));
272
273 for(const e of siblings){
274 if(e !== parent)
275 e.classList.toggle(hideClass, !isClosed);
276
277 /** Toggle all entries in this one */
278 for(const s of Array.from(e.querySelectorAll(".suite, .test")))
279 s.classList.toggle(hideClass, !isClosed);
280 }
281 }
282 }
283
284 // Stop the event bubbling up the DOM tree if it landed on something
285 if(node){
286 event.preventDefault();
287 event.stopPropagation();
288 return false;
289 }
290 }
291
292
293 /**
294 * Add an HTML container for a suite of tests.
295 *
296 * @param {Suite} suite
297 */
298 addSuite(suite){
299 const container = New("article", {className: "suite"});
300 const title = New("h1", {className: "suite-title", textContent: suite.title});
301 const results = New("div", {className: "results"});
302
303 container.appendChild(title);
304 container.appendChild(results);
305 const nodeRefs = {suite, container, title, results};
306
307 const parent = suite.parent[el];
308 const parentEl = parent ? parent.results : this.report;
309 nodeRefs.parent = addTo(parentEl)(container)[0];
310 link(el, nodeRefs);
311 }
312
313
314 /**
315 * Display the result of a finished test in the spec-runner.
316 *
317 * @param {Runnable} test
318 * @param {Error} error
319 */
320 addResult(test, error){
321 const speed = this.rankSpeed(test);
322 const state = error ? "fail" : (test.pending ? "pending" : "pass");
323 const show = error ? "" : " collapsed";
324
325 const prefix = this.options.autoIt ? (test.type === "hook" ? "In " : "It ") : "";
326 const container = New("div", {className: `test ${state} ${speed}` + show});
327 const title = New("div", {className: "test-title"});
328 const h2 = New("h2", {textContent: prefix + test.title});
329 addTo(title)(h2);
330
331 if(!test.pending){
332 const duration = this.createDuration(test.duration, true);
333 duration.title += ` (${speed === "okay" ? "medium" : speed})`;
334 addTo(title)(duration);
335 }
336
337 container.appendChild(title);
338 const details = (error
339 ? addTo(container)(this.createErrorBlock(error))
340 : test.pending || addTo(container)(this.createCodeBlock(test.body)))[1];
341 test.parent[el].results.appendChild(container);
342 link(el, {container, title, details});
343 error && test.parent[el].container.classList.add("has-failures");
344
345 if(this.dots){
346 const dot = New("div", {className: container.className.replace(/test/, "dot")});
347 addTo(this.dots)(dot);
348 }
349
350 if(this.options.autoScroll)
351 this.report.scrollTop = this.report.scrollHeight;
352 }
353
354
355 /**
356 * Generate a block to display the details of a failed test.
357 *
358 * @param {AssertionError} error
359 * @return {HTMLDivElement}
360 */
361 createErrorBlock(error){
362 const div = New("div", {error, className: "error-block"});
363 const title = New("div", {error, className: "error-title"});
364 const stack = New("div", {error, className: "stack-trace"});
365 let diff;
366
367 // No stack? Improvise
368 if(!error.rawStack){
369 const uncaught = error.uncaught? "Uncaught " : "";
370 const titleText = `${uncaught + error.name}: `;
371 title.innerHTML = this.options.escapeHTML
372 ? escapeHTML(titleText)
373 : titleText;
374
375 // We're pretty much copying what Mocha's doing in `reporters/base.js`
376 if(error.message && "function" === typeof error.message.toString)
377 stack.textContent = error.message;
378 else if("function" === typeof error.inspect)
379 stack.textContent = error.inspect();
380 }
381
382 // Format stack-trace if available
383 else{
384 const [titleText, stackData] = this.formatStackTrace(error);
385 title.innerHTML = titleText.replace(/[{}:()\]\[]+/g, s =>
386 `<span class="stack-trace">${s}</span>`);
387
388 for(const [html, callSite] of stackData){
389 const frame = New("span", {
390 className: "stack-frame",
391 innerHTML: html + "\n"
392 });
393 const {textContent} = frame;
394 if(this.options.stackFilter.test(textContent))
395 frame.hidden = true;
396 stack.appendChild(Object.assign(frame, {error, callSite}));
397 }
398 if(error.showDiff && (diff = this.createDiffBlock(error)))
399 div.classList.add("has-diff");
400 }
401
402 return addTo(div)(title, diff, stack)[0];
403 }
404
405
406
407 /**
408 * Generate a chunk of (possibly) highlighted JavaScript source.
409 *
410 * @param {String} src
411 * @return {HTMLPreElement}
412 */
413 createCodeBlock(src){
414 const pre = New("pre", {className: "code-block"});
415 const code = New("code", {textContent: src});
416
417 if(this.options.formatCode){
418 code.textContent = "";
419 src = clean(src).replace(/^(\x20{2})+/gm, s => "\t".repeat(s.length / 2));
420 for(let node of this.highlight(src))
421 code.appendChild(node);
422 }
423
424 pre.appendChild(code);
425 return pre;
426 }
427
428
429
430 /**
431 * Generate an HTML node for an "expected value VS actual value" comparison.
432 *
433 * If the error lacks "expected" and "actual" properties, null is returned.
434 *
435 * @param {AssertionError} error
436 * @return {HTMLElement}
437 */
438 createDiffBlock(error){
439 if(!error || !("expected" in error && "actual" in error))
440 return null;
441
442 const div = New("div", {className: "error-diff"});
443
444 const legend = addTo(
445 New("div", {className: "diff-legend"})
446 )(
447 New("span", {className: "diff-expected", textContent: "+ expected"}),
448 New("span", {className: "diff-actual", textContent: "- actual"})
449 )[0];
450
451
452 const patch = diff.createPatch("--",
453 print(error.actual),
454 print(error.expected)
455 );
456
457 const details = New("div", {
458 className: "diff-details",
459 innerHTML: painter.html(patch)
460 });
461 return addTo(div)(legend, details)[0];
462 }
463
464
465
466 /**
467 * Generate an HTML node for displaying a duration.
468 *
469 * @param {Number} input - Time expressed in milliseconds
470 * @param {Boolean} noUnits - Always display time in milliseconds
471 * @param {Object} attr - Additional HTML attributes to add to container
472 * @return {HTMLElement}
473 */
474 createDuration(input = 0, noUnits = false, attr = {}){
475 const attrib = Object.assign({}, attr, {className: "duration"});
476 const tagName = attr.tagName || "span";
477 delete attrib.tagName;
478
479 const span = New(tagName, attrib);
480 const data = New("data", {className: "amount"});
481 const unit = New("span", {className: "unit"});
482
483 Object.defineProperty(span, "value", {
484 get(){ return +data.value || 0; },
485
486 set(input){
487 input = Math.max(0, +input || 0);
488 data.value = input;
489
490 // Display a reader-friendly version of the duration
491 if(noUnits || input < 1000){
492 input = parseFloat(input.toFixed(2));
493 span.title = `${input} millisecond${input==1 ? "":"s"}`;
494 data.textContent = input;
495 unit.textContent = "ms";
496 }
497
498 else if(input >= 60000){
499 input = parseFloat((input / 60000).toFixed(2));
500 span.title = `${input} minute${input==1 ? "":"s"}`;
501 data.textContent = input;
502 unit.textContent = "m";
503 }
504
505 else{
506 input = parseFloat((input / 1000).toFixed(2));
507 span.title = `${input} second${input==1 ? "":"s"}`;
508 data.textContent = input;
509 unit.textContent = "s";
510 }
511 }
512 });
513
514 span.value = input;
515 span.appendChild(data);
516 span.appendChild(unit);
517 return span;
518 }
519
520
521
522 /**
523 * Gauge the speed of a finished test.
524 *
525 * @param {Runnable} test
526 * @return {String} Either "fast", "medium" or "slow"
527 */
528 rankSpeed(test){
529 const thresh = test.slow();
530 if(test.duration > thresh) return "slow";
531 if(test.duration > thresh / 2) return "okay";
532 return "fast";
533 }
534
535
536 /**
537 * Generate HTML code to display a formatted stack trace.
538 *
539 * @see {@link https://github.com/v8/v8/wiki/Stack-Trace-API}
540 * @param {AssertionError} error
541 * @return {Array} 2-string array with HTML code for title and stack
542 */
543 formatStackTrace(error){
544 let title = error.toString();
545 let stack = error.rawStack.slice();
546
547 // Reverse order if `flipStack` is used
548 if(this.options.flipStack)
549 stack.reverse();
550
551 const frames = stack.map(frame => {
552 const html = this.formatStackFrame(frame);
553 return [html, frame];
554 });
555 return [title, frames];
556 }
557
558
559 /**
560 * Generate HTML source for a stack frame.
561 *
562 * @param {CallSite} frame
563 * @return {String}
564 */
565 formatStackFrame(frame){
566 const result = ["at "];
567 const anon = "<anonymous>";
568
569 // Callee
570 const type = frame.getTypeName();
571 const method = frame.getMethodName();
572 const name = frame.getFunctionName();
573 const callee = frame.isConstructor()
574 ? "new " + (type || anon)
575 : (type ? type + "." : "")
576 + (name || method || anon);
577 result.push('<span class="stack-callee">'
578 + callee.replace(/(^new )?((?:[$\w]+\.)*<anonymous>)/, (_,a,b) =>
579 `${a || ""}<span class="stack-trace">${escapeHTML(b)}</span>`)
580 + "</span>");
581
582 // Bracketed sidenote about aliased method-call
583 if(method && method !== name)
584 result.push(`<span class="stack-notes"> [as ${escapeHTML(method)}]</span>`);
585
586 // Source file
587 let file = frame.getScriptNameOrSourceURL();
588 if(file){
589 const row = frame.getLineNumber();
590 const col = frame.getColumnNumber();
591 const escapedPath = escapeRegExp(this.options.packagePath + path.sep);
592 const pathPattern = new RegExp("^" + escapedPath);
593 const openingTag = this.options.linkPaths && pathPattern.test(file)
594 ? `<span class="stack-source link" data-line=${row} data-column=${col} data-path="${file}">`
595 : '<span class="stack-source">';
596
597 if(this.options.clipPaths)
598 file = file.replace(pathPattern, "");
599
600 file = escapeHTML(file);
601 result.push(` ${openingTag}(`
602 + '<span class="stack-filename">' + file + "</span>:"
603 + '<span class="stack-line">' + row + "</span>:"
604 + '<span class="stack-column">' + col + "</span>"
605 + ")</span>");
606 }
607
608 // Other source
609 else{
610 result.push('<span class="stack-source other">(');
611 if (frame.isNative()) result.push("native");
612 else if (frame.isEval()) result.push("eval at " + escapeHTML(frame.getPosition()));
613 result.push(")</span>");
614 }
615
616 return result.join("");
617 }
618
619
620 /**
621 * Highlight code chunks embedded in test results.
622 *
623 * @param {Reporter} input - Mocha reporter object
624 * @param {Object} atom - Reference to the Atom environment object
625 * @return {Array} An array of DOM nodes
626 */
627 highlight(input, atom){
628 const {grammars} = Reporter;
629 const nodes = [];
630
631 // Highlighting is disabled/unavailable
632 if(grammars === undefined)
633 return nodes;
634
635 for(let line of grammars["source.js"].tokenizeLines(input)){
636 for(let token of line){
637 const tag = document.createElement("span");
638 tag.textContent = token.value;
639
640 const scopes = token.scopes.join(".").split(/\./g);
641 const s = parseKeywords(scopes);
642 tag.dataset.scopes = Array.from(new Set(scopes).values()).join(" ");
643
644 // Don't use scopes as classes; they'll be targeted by themes
645 if(s.punctuation) tag.className = s.string ? "js-quote" : "js-punct";
646 else if(s.meta && s.brace || s.delimiter) tag.className = "js-punct";
647 else if(s.keyword || s.entity && s.name) tag.className = "js-key";
648 else if(s.function || s.storage) tag.className = "js-key";
649 else if(s.constant || s.string) tag.className = "js-value";
650 else if(s.property || s.variable) tag.className = "js-ident";
651 else tag.className = "js";
652
653 nodes.push(tag);
654 }
655 nodes.push(document.createTextNode("\n"));
656 }
657 return nodes;
658 }
659
660
661 /**
662 * Perform any asynchronous operations that need to complete before starting Mocha.
663 *
664 * @param {AtomMocha} main - Reference to the running AtomMocha instance.
665 * @return {Promise}
666 */
667 static beforeStart(main){
668 const promises = [this.loadEditorSettings()];
669
670 // Load JavaScript grammar to provide syntax highlighting
671 if(main.options.formatCode){
672 this.grammars = {};
673
674 promises.push(atom.packages.activatePackage("language-javascript").then(p => {
675 p.grammars.forEach(g => this.grammars[g.scopeName] = g);
676
677 // Make sure we don't screw with specs
678 atom.packages.deactivatePackage("language-javascript");
679 }));
680 }
681
682 return Promise.all(promises);
683 }
684
685
686
687 /**
688 * Attempt to load user's config to improve feedback quality.
689 *
690 * User configs are parsed statically. Because this feature is so cosmetic,
691 * it's not worth adding CSON/CoffeeScript as a hard dependency over.
692 *
693 * @return {Promise}
694 */
695 static loadEditorSettings(){
696
697 return new Promise((resolve, reject) => {
698 const configPath = atom.config.configFilePath;
699
700 fs.readFile(configPath, (error, data) => {
701 if(error) return reject(error);
702
703 // Break the config's content apart by top-level scope
704 const scopes = {};
705 data.toString().split(/^(?=\S)/gm).forEach(s => {
706 const scope = /^(?:"(?:[^"\\]|\\.)+"|'(?:[^'\\]|\\.)+'|\w+)/;
707 const editor = /\n {2}editor:\n[^\x00]+?\n(?: {2}\S|$)/;
708 const ed = (s.match(editor) || [""])[0];
709
710 // Pinch a bunch of parsed editor settings
711 if(s = (s.match(scope) || [""])[0].replace(/["']/g, ""))
712 scopes[s] = {
713 fontFamily: (ed.match(/^ +fontFamily:\s*("|')(.+)\1/im) || []).pop(),
714 fontSize: (ed.match(/^ +fontSize:\s*([\d.]+)/im) || []).pop(),
715 lineHeight: (ed.match(/^ +lineHeight:\s*([\d.]+)/im) || []).pop(),
716 tabLength: (ed.match(/^ +tabLength:\s*(\d+)/im) || []).pop()
717 };
718 });
719
720 // Now collate them in ascending order of precedence
721 const ed = {};
722 for(let scope of ["*", ".atom-mocha"]){
723 for(let k in scopes[scope]){
724 let v = scopes[scope][k]
725 if(v != null) ed[k] = v
726 }
727 }
728 Reporter.editorSettings = ed;
729 resolve();
730 });
731 });
732 }
733}
734
735
736module.exports = Reporter;