diff --git a/.editorconfig b/.editorconfig index 40c60644b..fa17e8c88 100644 --- a/.editorconfig +++ b/.editorconfig @@ -10,6 +10,9 @@ charset = utf-8 trim_trailing_whitespace = true insert_final_newline = true +[test/cli/TapReporter.js] +trim_trailing_whitespace = false + [*.{yml,md}] indent_style = space indent_size = 2 diff --git a/package-lock.json b/package-lock.json index 83853ac7b..b7ba8cfc2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "qunit", - "version": "2.14.1-pre", + "version": "2.15.0-pre", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -4566,6 +4566,12 @@ "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", "dev": true }, + "kleur": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.4.tgz", + "integrity": "sha512-8QADVssbrFjivHWQU7KkMgptGTl6WAcSdlbBPY4uNF+mWr6DGcKrvY2w4FQJoXch7+fKMjj0dRrL75vk3k23OA==", + "dev": true + }, "lcov-parse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/lcov-parse/-/lcov-parse-1.0.0.tgz", diff --git a/package.json b/package.json index bf81b102b..68a8527d4 100644 --- a/package.json +++ b/package.json @@ -70,6 +70,7 @@ "grunt-eslint": "^23.0.0", "grunt-git-authors": "^3.2.0", "grunt-search": "^0.1.8", + "kleur": "4.1.4", "npm-reporter": "file:./test/cli/fixtures/npm-reporter", "nyc": "^15.1.0", "proxyquire": "^1.8.0", diff --git a/src/core.js b/src/core.js index dd5d98375..08789337f 100644 --- a/src/core.js +++ b/src/core.js @@ -7,6 +7,7 @@ import Assert from "./assert"; import Logger from "./logger"; import Test, { test, pushFailure } from "./test"; import exportQUnit from "./export"; +import reporters from "./reporters"; import config from "./core/config"; import { extend, objectType, is, now } from "./core/utilities"; @@ -42,6 +43,7 @@ extend( QUnit, { dump, equiv, + reporters, is, objectType, on, diff --git a/src/reporters.js b/src/reporters.js new file mode 100644 index 000000000..4e52753c8 --- /dev/null +++ b/src/reporters.js @@ -0,0 +1,7 @@ +import ConsoleReporter from "./reporters/ConsoleReporter.js"; +import TapReporter from "./reporters/TapReporter.js"; + +export default { + console: ConsoleReporter, + tap: TapReporter +}; diff --git a/src/reporters/ConsoleReporter.js b/src/reporters/ConsoleReporter.js new file mode 100644 index 000000000..fb042efa7 --- /dev/null +++ b/src/reporters/ConsoleReporter.js @@ -0,0 +1,36 @@ +import { console } from "../globals"; + +export default class ConsoleReporter { + constructor( runner, options = {} ) { + + // Cache references to console methods to ensure we can report failures + // from tests tests that mock the console object itself. + // https://github.com/qunitjs/qunit/issues/1340 + this.log = options.log || console.log.bind( console ); + + runner.on( "runStart", this.onRunStart.bind( this ) ); + runner.on( "testStart", this.onTestStart.bind( this ) ); + runner.on( "testEnd", this.onTestEnd.bind( this ) ); + runner.on( "runEnd", this.onRunEnd.bind( this ) ); + } + + static init( runner, options ) { + return new ConsoleReporter( runner, options ); + } + + onRunStart( runStart ) { + this.log( "runStart", runStart ); + } + + onTestStart( test ) { + this.log( "testStart", test ); + } + + onTestEnd( test ) { + this.log( "testEnd", test ); + } + + onRunEnd( runEnd ) { + this.log( "runEnd", runEnd ); + } +} diff --git a/src/reporters/TapReporter.js b/src/reporters/TapReporter.js new file mode 100644 index 000000000..7aea237be --- /dev/null +++ b/src/reporters/TapReporter.js @@ -0,0 +1,254 @@ +import kleur from "kleur"; +import { console } from "../globals"; +const hasOwn = Object.hasOwnProperty; + +/** + * Format a given value into YAML. + * + * YAML is a superset of JSON that supports all the same data + * types and syntax, and more. As such, it is always possible + * to fallback to JSON.stringfify, but we generally avoid + * that to make output easier to read for humans. + * + * Supported data types: + * + * - null + * - boolean + * - number + * - string + * - array + * - object + * + * Anything else (including NaN, Infinity, and undefined) + * must be described in strings, for display purposes. + * + * Note that quotes are optional in YAML strings if the + * strings are "simple", and as such we generally prefer + * that for improved readability. We output strings in + * one of three ways: + * + * - bare unquoted text, for simple one-line strings. + * - JSON (quoted text), for complex one-line strings. + * - YAML Block, for complex multi-line strings. + * + * Objects with cyclical references will be stringifed as + * "[Circular]" as they cannot otherwise be represented. + */ +function prettyYamlValue( value, indent = 4 ) { + if ( value === undefined ) { + + // Not supported in JSON/YAML, turn into string + // and let the below output it as bare string. + value = String( value ); + } + + // Support IE 9-11: Use isFinite instead of ES6 Number.isFinite + if ( typeof value === "number" && !isFinite( value ) ) { + + // Turn NaN and Infinity into simple strings. + // Paranoia: Don't return directly just in case there's + // a way to add special characters here. + value = String( value ); + } + + if ( typeof value === "number" ) { + + // Simple numbers + return JSON.stringify( value ); + } + + if ( typeof value === "string" ) { + + // If any of these match, then we can't output it + // as bare unquoted text, because that would either + // cause data loss or invalid YAML syntax. + // + // - Quotes, escapes, line breaks, or JSON-like stuff. + const rSpecialJson = /['"\\/[{}\]\r\n]/; + + // - Characters that are special at the start of a YAML value + const rSpecialYaml = /[-?:,[\]{}#&*!|=>'"%@`]/; + + // - Leading or trailing whitespace. + const rUntrimmed = /(^\s|\s$)/; + + // - Ambiguous as YAML number, e.g. '2', '-1.2', '.2', or '2_000' + const rNumerical = /^[\d._-]+$/; + + // - Ambiguous as YAML bool. + // Use case-insensitive match, although technically only + // fully-lower, fully-upper, or uppercase-first would be ambiguous. + // e.g. true/True/TRUE, but not tRUe. + const rBool = /^(true|false|y|n|yes|no|on|off)$/i; + + // Is this a complex string? + if ( + value === "" || + rSpecialJson.test( value ) || + rSpecialYaml.test( value[ 0 ] ) || + rUntrimmed.test( value ) || + rNumerical.test( value ) || + rBool.test( value ) + ) { + if ( !/\n/.test( value ) ) { + + // Complex one-line string, use JSON (quoted string) + return JSON.stringify( value ); + } + + // See also + // Support IE 9-11: Avoid ES6 String#repeat + const prefix = ( new Array( indent + 1 ) ).join( " " ); + + const trailingLinebreakMatch = value.match( /\n+$/ ); + const trailingLinebreaks = trailingLinebreakMatch ? + trailingLinebreakMatch[ 0 ].length : 0; + + if ( trailingLinebreaks === 1 ) { + + // Use the most straight-forward "Block" string in YAML + // without any "Chomping" indicators. + const lines = value + + // Ignore the last new line, since we'll get that one for free + // with the straight-forward Block syntax. + .replace( /\n$/, "" ) + .split( "\n" ) + .map( line => prefix + line ); + return "|\n" + lines.join( "\n" ); + } else { + + // This has either no trailing new lines, or more than 1. + // Use |+ so that YAML parsers will preserve it exactly. + const lines = value + .split( "\n" ) + .map( line => prefix + line ); + return "|+\n" + lines.join( "\n" ); + } + } else { + + // Simple string, use bare unquoted text + return value; + } + } + + // Handle null, boolean, array, and object + return JSON.stringify( decycledShallowClone( value ), null, 2 ); +} + +/** + * Creates a shallow clone of an object where cycles have + * been replaced with "[Circular]". + */ +function decycledShallowClone( object, ancestors = [] ) { + if ( ancestors.indexOf( object ) !== -1 ) { + return "[Circular]"; + } + + let clone; + + const type = Object.prototype.toString + .call( object ) + .replace( /^\[.+\s(.+?)]$/, "$1" ) + .toLowerCase(); + + switch ( type ) { + case "array": + ancestors.push( object ); + clone = object.map( function( element ) { + return decycledShallowClone( element, ancestors ); + } ); + ancestors.pop(); + break; + case "object": + ancestors.push( object ); + clone = {}; + Object.keys( object ).forEach( function( key ) { + clone[ key ] = decycledShallowClone( object[ key ], ancestors ); + } ); + ancestors.pop(); + break; + default: + clone = object; + } + + return clone; +} + +export default class TapReporter { + constructor( runner, options = {} ) { + + // Cache references to console methods to ensure we can report failures + // from tests tests that mock the console object itself. + // https://github.com/qunitjs/qunit/issues/1340 + this.log = options.log || console.log.bind( console ); + + this.testCount = 0; + + runner.on( "runStart", this.onRunStart.bind( this ) ); + runner.on( "testEnd", this.onTestEnd.bind( this ) ); + runner.on( "runEnd", this.onRunEnd.bind( this ) ); + } + + static init( runner, options ) { + return new TapReporter( runner, options ); + } + + onRunStart( _globalSuite ) { + this.log( "TAP version 13" ); + } + + onTestEnd( test ) { + this.testCount = this.testCount + 1; + + if ( test.status === "passed" ) { + this.log( `ok ${this.testCount} ${test.fullName.join( " > " )}` ); + } else if ( test.status === "skipped" ) { + this.log( + kleur.yellow( `ok ${this.testCount} # SKIP ${test.fullName.join( " > " )}` ) + ); + } else if ( test.status === "todo" ) { + this.log( + kleur.cyan( `not ok ${this.testCount} # TODO ${test.fullName.join( " > " )}` ) + ); + test.errors.forEach( ( error ) => this.logError( error, "todo" ) ); + } else { + this.log( + kleur.red( `not ok ${this.testCount} ${test.fullName.join( " > " )}` ) + ); + test.errors.forEach( ( error ) => this.logError( error ) ); + } + } + + onRunEnd( globalSuite ) { + this.log( `1..${globalSuite.testCounts.total}` ); + this.log( `# pass ${globalSuite.testCounts.passed}` ); + this.log( kleur.yellow( `# skip ${globalSuite.testCounts.skipped}` ) ); + this.log( kleur.cyan( `# todo ${globalSuite.testCounts.todo}` ) ); + this.log( kleur.red( `# fail ${globalSuite.testCounts.failed}` ) ); + } + + logError( error, severity ) { + let out = " ---"; + out += `\n message: ${prettyYamlValue( error.message || "failed" )}`; + out += `\n severity: ${prettyYamlValue( severity || "failed" )}`; + + if ( hasOwn.call( error, "actual" ) ) { + out += `\n actual : ${prettyYamlValue( error.actual )}`; + } + + if ( hasOwn.call( error, "expected" ) ) { + out += `\n expected: ${prettyYamlValue( error.expected )}`; + } + + if ( error.stack ) { + + // Since stacks aren't user generated, take a bit of liberty by + // adding a trailing new line to allow a straight-forward YAML Blocks. + out += `\n stack: ${prettyYamlValue( error.stack + "\n" )}`; + } + + out += "\n ..."; + this.log( out ); + } +} diff --git a/test/cli/ConsoleReporter.js b/test/cli/ConsoleReporter.js new file mode 100644 index 000000000..c12aaed79 --- /dev/null +++ b/test/cli/ConsoleReporter.js @@ -0,0 +1,37 @@ +const { EventEmitter } = require( "events" ); + +QUnit.module( "ConsoleReporter", hooks => { + let emitter; + let callCount; + + hooks.beforeEach( function() { + emitter = new EventEmitter(); + callCount = 0; + const con = { + log: () => { + callCount++; + } + }; + QUnit.reporters.console.init( emitter, con ); + } ); + + QUnit.test( "Event \"runStart\"", assert => { + emitter.emit( "runStart", {} ); + assert.equal( callCount, 1 ); + } ); + + QUnit.test( "Event \"runEnd\"", assert => { + emitter.emit( "runEnd", {} ); + assert.equal( callCount, 1 ); + } ); + + QUnit.test( "Event \"testStart\"", assert => { + emitter.emit( "testStart", {} ); + assert.equal( callCount, 1 ); + } ); + + QUnit.test( "Event \"testEnd\"", assert => { + emitter.emit( "testEnd", {} ); + assert.equal( callCount, 1 ); + } ); +} ); diff --git a/test/cli/TapReporter.js b/test/cli/TapReporter.js new file mode 100644 index 000000000..06e9589d9 --- /dev/null +++ b/test/cli/TapReporter.js @@ -0,0 +1,378 @@ +const kleur = require( "kleur" ); +const { EventEmitter } = require( "events" ); + +function mockStack( error ) { + error.stack = ` at Object. (/dev/null/test/unit/data.js:6:5) + at require (internal/modules/cjs/helpers.js:22:18) + at /dev/null/node_modules/mocha/lib/mocha.js:220:27 + at startup (internal/bootstrap/node.js:283:19)`; + return error; +} + +function makeFailingTestEnd( actualValue ) { + return { + name: "Failing", + suiteName: null, + fullName: [ "Failing" ], + status: "failed", + runtime: 0, + errors: [ { + passed: false, + actual: actualValue, + expected: "expected" + } ], + assertions: null + }; +} + +QUnit.module( "TapReporter", hooks => { + let emitter; + let last; + let buffer; + + function log( str ) { + buffer += str + "\n"; + last = str; + } + + hooks.beforeEach( function() { + emitter = new EventEmitter(); + last = undefined; + buffer = ""; + QUnit.reporters.tap.init( emitter, { + log: log + } ); + } ); + + QUnit.test( "output the TAP header", assert => { + emitter.emit( "runStart", {} ); + + assert.strictEqual( last, "TAP version 13" ); + } ); + + QUnit.test( "output ok for a passing test", assert => { + const expected = "ok 1 name"; + + emitter.emit( "testEnd", { + name: "name", + suiteName: null, + fullName: [ "name" ], + status: "passed", + runtime: 0, + errors: [], + assertions: [] + } ); + + assert.strictEqual( last, expected ); + } ); + + QUnit.test( "output ok for a skipped test", assert => { + const expected = kleur.yellow( "ok 1 # SKIP name" ); + + emitter.emit( "testEnd", { + name: "name", + suiteName: null, + fullName: [ "name" ], + status: "skipped", + runtime: 0, + errors: [], + assertions: [] + } ); + assert.strictEqual( last, expected ); + } ); + + QUnit.test( "output not ok for a todo test", assert => { + const expected = kleur.cyan( "not ok 1 # TODO name" ); + + emitter.emit( "testEnd", { + name: "name", + suiteName: null, + fullName: [ "name" ], + status: "todo", + runtime: 0, + errors: [], + assertions: [] + } ); + assert.strictEqual( last, expected ); + } ); + + QUnit.test( "output not ok for a failing test", assert => { + const expected = kleur.red( "not ok 1 name" ); + + emitter.emit( "testEnd", { + name: "name", + suiteName: null, + fullName: [ "name" ], + status: "failed", + runtime: 0, + errors: [], + assertions: [] + } ); + assert.strictEqual( last, expected ); + } ); + + QUnit.test( "output all errors for a failing test", assert => { + emitter.emit( "testEnd", { + name: "name", + suiteName: null, + fullName: [ "name" ], + status: "failed", + runtime: 0, + errors: [ + mockStack( new Error( "first error" ) ), + mockStack( new Error( "second error" ) ) + ], + assertions: [] + } ); + + assert.strictEqual( buffer, `${kleur.red( "not ok 1 name" )} + --- + message: first error + severity: failed + stack: | + at Object. (/dev/null/test/unit/data.js:6:5) + at require (internal/modules/cjs/helpers.js:22:18) + at /dev/null/node_modules/mocha/lib/mocha.js:220:27 + at startup (internal/bootstrap/node.js:283:19) + ... + --- + message: second error + severity: failed + stack: | + at Object. (/dev/null/test/unit/data.js:6:5) + at require (internal/modules/cjs/helpers.js:22:18) + at /dev/null/node_modules/mocha/lib/mocha.js:220:27 + at startup (internal/bootstrap/node.js:283:19) + ... +` + ); + } ); + + QUnit.test( "output expected value of Infinity", assert => { + emitter.emit( "testEnd", { + name: "Failing", + suiteName: null, + fullName: [ "Failing" ], + status: "failed", + runtime: 0, + errors: [ { + passed: false, + actual: "actual", + expected: Infinity + } ], + assertions: null + } ); + assert.strictEqual( last, ` --- + message: failed + severity: failed + actual : actual + expected: Infinity + ...` + ); + } ); + + QUnit.test( "output actual value of undefined", assert => { + emitter.emit( "testEnd", makeFailingTestEnd( undefined ) ); + assert.strictEqual( last, ` --- + message: failed + severity: failed + actual : undefined + expected: expected + ...` + ); + } ); + + QUnit.test( "output actual value of Infinity", assert => { + emitter.emit( "testEnd", makeFailingTestEnd( Infinity ) ); + assert.strictEqual( last, ` --- + message: failed + severity: failed + actual : Infinity + expected: expected + ...` + ); + } ); + + QUnit.test( "output actual value of a string", assert => { + emitter.emit( "testEnd", makeFailingTestEnd( "abc" ) ); + + // No redundant quotes + assert.strictEqual( last, ` --- + message: failed + severity: failed + actual : abc + expected: expected + ...` + ); + } ); + + QUnit.test( "output actual value with one trailing line break", assert => { + emitter.emit( "testEnd", makeFailingTestEnd( "abc\n" ) ); + assert.strictEqual( last, ` --- + message: failed + severity: failed + actual : | + abc + expected: expected + ...` + ); + } ); + + QUnit.test( "output actual value with two trailing line breaks", assert => { + emitter.emit( "testEnd", makeFailingTestEnd( "abc\n\n" ) ); + assert.strictEqual( last, ` --- + message: failed + severity: failed + actual : |+ + abc + + + expected: expected + ...` + ); + } ); + + QUnit.test( "output actual value of a number string", assert => { + emitter.emit( "testEnd", makeFailingTestEnd( "2" ) ); + + // Quotes required to disambiguate YAML value + assert.strictEqual( last, ` --- + message: failed + severity: failed + actual : "2" + expected: expected + ...` + ); + } ); + + QUnit.test( "output actual value of boolean string", assert => { + emitter.emit( "testEnd", makeFailingTestEnd( "true" ) ); + + // Quotes required to disambiguate YAML value + assert.strictEqual( last, ` --- + message: failed + severity: failed + actual : "true" + expected: expected + ...` + ); + } ); + + QUnit.test( "output actual value of 0", assert => { + emitter.emit( "testEnd", makeFailingTestEnd( 0 ) ); + assert.strictEqual( last, ` --- + message: failed + severity: failed + actual : 0 + expected: expected + ...` + ); + } ); + + QUnit.test( "output actual assertion value of empty array", assert => { + emitter.emit( "testEnd", makeFailingTestEnd( [] ) ); + assert.strictEqual( last, ` --- + message: failed + severity: failed + actual : [] + expected: expected + ...` + ); + } ); + + QUnit.test( "output actual value with a cyclical structure", assert => { + + /// Creates an object that has a cyclical reference. + function createCyclical() { + const cyclical = { a: "example" }; + cyclical.cycle = cyclical; + return cyclical; + } + emitter.emit( "testEnd", makeFailingTestEnd( createCyclical() ) ); + assert.strictEqual( last, ` --- + message: failed + severity: failed + actual : { + "a": "example", + "cycle": "[Circular]" +} + expected: expected + ...` + ); + } ); + + QUnit.test( "output actual value with a subobject cyclical structure", assert => { + + // Creates an object that has a cyclical reference in a subobject. + function createSubobjectCyclical() { + const cyclical = { a: "example", sub: {} }; + cyclical.sub.cycle = cyclical; + return cyclical; + } + emitter.emit( "testEnd", makeFailingTestEnd( createSubobjectCyclical() ) ); + assert.strictEqual( last, ` --- + message: failed + severity: failed + actual : { + "a": "example", + "sub": { + "cycle": "[Circular]" + } +} + expected: expected + ...` + ); + } ); + + QUnit.test( "output actual assertion value of an acyclical structure", assert => { + + // Creates an object that references another object more + // than once in an acyclical way. + function createDuplicateAcyclical() { + const duplicate = { + example: "value" + }; + return { + a: duplicate, + b: duplicate, + c: "unique" + }; + } + emitter.emit( "testEnd", makeFailingTestEnd( createDuplicateAcyclical() ) ); + assert.strictEqual( last, ` --- + message: failed + severity: failed + actual : { + "a": { + "example": "value" + }, + "b": { + "example": "value" + }, + "c": "unique" +} + expected: expected + ...` + ); + } ); + + QUnit.test( "output the total number of tests", assert => { + emitter.emit( "runEnd", { + testCounts: { + total: 6, + passed: 3, + failed: 2, + skipped: 1, + todo: 0 + } + } ); + + assert.strictEqual( buffer, `1..6 +# pass 3 +${kleur.yellow( "# skip 1" )} +${kleur.cyan( "# todo 0" )} +${kleur.red( "# fail 2" )} +` + ); + } ); +} );