├── screenshot-diff.jpg ├── bin └── cli.js ├── README.md ├── LICENSE ├── .gitignore ├── scratch.js ├── package.json ├── test-multi.js ├── test-large.js └── tap-nirvana.js /screenshot-diff.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/inadarei/tap-nirvana/HEAD/screenshot-diff.jpg -------------------------------------------------------------------------------- /bin/cli.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const tapBDiff = require('../tap-nirvana')(); 4 | 5 | process.stdin 6 | .pipe(tapBDiff) 7 | .pipe(process.stdout); 8 | 9 | process.on('exit', function (status) { 10 | 11 | if (status === 1) { 12 | process.exit(1); 13 | } 14 | 15 | if (tapBDiff.failed) { 16 | process.exit(1); 17 | } 18 | }); -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Tap Nirvana - Proper Reporter 2 | 3 | A TAP reporter optimized for developer comfort above anything else. Works with 4 | any TAP-compatible test runner, such as 5 | [tape](https://www.npmjs.com/package/tape) or 6 | [blue-tape](https://www.npmjs.com/package/blue-tape) (the promisified version of 7 | tape). 8 | 9 | ## Usage: 10 | 11 | Snippet from a package.json: 12 | 13 | ``` 14 | "devDependencies": { 15 | "blue-tape": "^1.0.0", 16 | "tap-nirvana": "^1.0.5", 17 | "nyc": "^11.3.0" 18 | }, 19 | "scripts": { 20 | "test": "nyc blue-tape test/**/*.js | tap-nirvana " 21 | } 22 | ``` 23 | 24 | ### Features: 25 | 26 | 1. Color-coded diffs of complex objects for easy expected/actual analysis 27 | 2. Laser-sharp pointer to where exceptions occured 28 | 3. Usually gets out of your way and reduces noise. 29 | 30 | ### Screenshot 31 | 32 | ![screenshot image](screenshot-diff.jpg) 33 | 34 | ### Credit 35 | 36 | TAP Nirvana is a fork of [Tap-Spec](https://github.com/scottcorgan/tap-spec) -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Irakli Nadareishvili 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (http://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # Typescript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | package-lock.json 61 | -------------------------------------------------------------------------------- /scratch.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | const through = require('through2'); 3 | const duplexer = require('duplexer'); 4 | 5 | var vdiff = require('variable-diff'); 6 | 7 | var jsdiff = require('json-diff'); 8 | var { colorize } = require('json-diff/lib/colorize'); 9 | 10 | var jsondiffpatch = require("jsondiffpatch"); 11 | 12 | const expected = { 13 | "one" : "val", 14 | "two" : "val", 15 | "three" : "val", 16 | "four" : "val", 17 | "five" : { 18 | "someting" : "lorem ipsum dolor sit amet", "other" : "lorem ipsum is getting too long" 19 | } 20 | }; 21 | 22 | const actual = { 23 | "five" : { 24 | "someting" : "lorem ipsum dolor sit amet", "other" : "lorem1 ipsum is getting too long" 25 | }, 26 | "four" : "val", 27 | "three" : "val", 28 | "two" : "val", 29 | }; 30 | 31 | 32 | 33 | var difference = colorize(jsdiff.diff(expected, actual)); 34 | console.log("json-diff"); 35 | console.log(difference); 36 | 37 | 38 | // --------- 39 | 40 | console.log("variable-diff"); 41 | result = vdiff(expected, actual); 42 | console.log(result.text); 43 | 44 | // -------- 45 | console.log("jsondiffpatch"); 46 | var delta = jsondiffpatch.diff(expected, actual); 47 | var deltaColor = jsondiffpatch.formatters.console.format(delta); 48 | console.log(deltaColor); -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tap-nirvana", 3 | "version": "1.1.0", 4 | "description": "Proper Diffing Reporter for TAP", 5 | "main": "tap-nirvana.js", 6 | "bin": { 7 | "tap-nirvana": "bin/cli.js" 8 | }, 9 | "dependencies": { 10 | "chalk": "^2.4.1", 11 | "duplexer": "^0.1.1", 12 | "figures": "^2.0.0", 13 | "json-stringify-pretty-compact": "^1.1.0", 14 | "lodash": "^4.17.10", 15 | "pretty-ms": "^3.2.0", 16 | "repeat-string": "^1.5.2", 17 | "tap-out": "^3.0.0", 18 | "through2": "^2.0.0", 19 | "variable-diff": "^1.1.0" 20 | }, 21 | "devDependencies": { 22 | "json-diff": "^0.5.2", 23 | "jsondiffpatch": "^0.3.11", 24 | "jsonlint": "^1.6.2", 25 | "tape": "^4.8.0", 26 | "tapes": "^4.1.0" 27 | }, 28 | "scripts": { 29 | "test": "echo \"Error: no test specified\" && exit 1" 30 | }, 31 | "repository": { 32 | "type": "git", 33 | "url": "git+https://github.com/inadarei/tap-nirvana.git" 34 | }, 35 | "keywords": [ 36 | "tap", 37 | "diff", 38 | "reporter" 39 | ], 40 | "author": "Irakli Nadareishvili", 41 | "license": "MIT", 42 | "bugs": { 43 | "url": "https://github.com/inadarei/tap-nirvana/issues" 44 | }, 45 | "homepage": "https://github.com/inadarei/tap-nirvana" 46 | } 47 | -------------------------------------------------------------------------------- /test-multi.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | const through = require('through2'); 3 | const duplexer = require('duplexer'); 4 | 5 | var vdiff = require('variable-diff'); 6 | 7 | var jsdiff = require('json-diff'); 8 | var { colorize } = require('json-diff/lib/colorize'); 9 | 10 | var jsondiffpatch = require("jsondiffpatch"); 11 | 12 | const expected = { 13 | "one" : "val", 14 | "two" : "val", 15 | "three" : "val", 16 | "four" : "val", 17 | "five" : { 18 | "someting" : "lorem ipsum dolor sit amet", "other" : "lorem ipsum is getting too long" 19 | } 20 | }; 21 | 22 | const actual = { 23 | "five" : { 24 | "someting" : "lorem ipsum dolor sit amet", "other" : "lorem1 ipsum is getting too long" 25 | }, 26 | "four" : "val", 27 | "three" : "val", 28 | "two" : "val", 29 | }; 30 | 31 | 32 | 33 | var difference = colorize(jsdiff.diff(expected, actual)); 34 | console.log("json-diff"); 35 | console.log(difference); 36 | 37 | 38 | // --------- 39 | 40 | console.log("variable-diff"); 41 | result = vdiff(expected, actual).text; 42 | console.log(result); 43 | 44 | // -------- 45 | console.log("jsondiffpatch"); 46 | var delta = jsondiffpatch.diff(expected, actual); 47 | var deltaColor = jsondiffpatch.formatters.console.format(delta); 48 | console.log(deltaColor); -------------------------------------------------------------------------------- /test-large.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | const through = require('through2'); 3 | const duplexer = require('duplexer'); 4 | 5 | var vdiff = require('variable-diff'); 6 | 7 | var jsdiff = require('json-diff'); 8 | var { colorize } = require('json-diff/lib/colorize'); 9 | 10 | var jsondiffpatch = require("jsondiffpatch"); 11 | 12 | const expected = { 13 | "class": ["order"], 14 | "properties": { 15 | "orderNumber": 42, 16 | "foonull": null, 17 | "itemCount": 3, 18 | "status": "pending" 19 | }, 20 | "entities": [ 21 | { 22 | "properties": {"entityId": "kfk24", "amount": 999}, 23 | "rel": ["foo", "bar"], 24 | "class": ["foo", "bar"], 25 | "links": [{"rel": ["self"], "href": "http://api.x.io/foo/bar"}] 26 | }, 27 | { 28 | "class": ["items", "collection"], 29 | "rel": ["http://x.io/rels/order-items"], 30 | "href": "http://api.x.io/orders/42/items" 31 | }, 32 | { 33 | "class": ["items", "collection"], 34 | "rel": ["http://x.io/rels/discounts", "second"], 35 | "href": "http://api.x.io/discounts/52/items", 36 | "title": "This is testing proper translation of link titles" 37 | }, 38 | { 39 | "properties": {"customerId": "pj123", "name": "Peter Joseph"}, 40 | "class": ["info", "customer"], 41 | "rel": ["info", "customer"], 42 | "links": [{"rel": ["self"], "href": "http://api.x.io/customers/pj123"}] 43 | } 44 | ], 45 | "actions": [ 46 | { 47 | "name": "add-item", 48 | "title": "Add Item", 49 | "method": "POST", 50 | "href": "http://api.x.io/orders/42/items", 51 | "type": "application/x-www-form-urlencoded", 52 | "fields": [ 53 | {"name": "orderNumber", "type": "hidden", "value": "42"}, 54 | {"name": "productCode", "type": "text"}, 55 | {"name": "quantity", "type": "number"} 56 | ] 57 | } 58 | ], 59 | "links": [ 60 | {"rel": ["self"], "href": "http://api.x.io/orders/42"}, 61 | {"rel": ["previous"], "href": "http://api.x.io/orders/41"}, 62 | {"rel": ["next"], "href": "http://api.x.io/orders/43"} 63 | ] 64 | }; 65 | 66 | const actual = { 67 | "class": ["order"], 68 | "properties": { 69 | "orderNumber": 42, 70 | "foonull": null, 71 | "itemCount": 3, 72 | "status": "pending" 73 | }, 74 | "entities": [ 75 | { 76 | "properties": {"entityId": "kfk24", "amount": 999}, 77 | "rel": ["foo", "bar"], 78 | "class": ["foo", "bar"], 79 | "links": [{"rel": ["self"], "href": "http://api.x.io/foo/bar"}] 80 | }, 81 | { 82 | "class": ["items", "collection"], 83 | "rel": ["http://x.io/rels/order-items"], 84 | "href": "http://api.x.io/orders/42/items" 85 | }, 86 | { 87 | "class": ["items", "collection"], 88 | "rel": ["http://x.io/rels/discounts", "second"], 89 | "href": "http://api.x.io/discounts/52/items", 90 | "title": "This is testing proper translation of link titles", 91 | "new": null 92 | }, 93 | { 94 | "properties": {"customerId": "pj123", "name": "Peter Joseph"}, 95 | "class": ["info", "customer"], 96 | "rel": ["info", "customer"], 97 | "links": [{"rel": ["self"], "href": "http://api.x.io/customers/pj123"}] 98 | } 99 | ], 100 | "actions": [ 101 | { 102 | "name": "add-item", 103 | "title": "Add Item", 104 | "method": "POST", 105 | "href": "http://api.x.io/orders/42/items", 106 | "type": "application/x-www-form-urlencoded", 107 | "fields": [ 108 | {"name": "orderNumber", "type": "hidden", "value": "42"}, 109 | {"name": "productCode", "type": "text"}, 110 | {"name": "quantity", "type": "number"} 111 | ] 112 | } 113 | ], 114 | "links": [ 115 | {"rel": ["self"], "href": "http://api.x.io/orders/42"}, 116 | {"rel": ["previous"], "href": "http://api.x.io/orders/41"}, 117 | {"rel": ["next"], "href": "http://api.x.io/orders/43"} 118 | ] 119 | }; 120 | 121 | 122 | 123 | var difference = colorize(jsdiff.diff(expected, actual)); 124 | console.log("json-diff"); 125 | console.log(difference); 126 | 127 | 128 | // --------- 129 | 130 | console.log("variable-diff"); 131 | result = vdiff(expected, actual).text; 132 | console.log(result); 133 | 134 | // -------- 135 | console.log("jsondiffpatch"); 136 | var delta = jsondiffpatch.diff(expected, actual); 137 | var deltaColor = jsondiffpatch.formatters.console.format(delta); 138 | console.log(deltaColor); -------------------------------------------------------------------------------- /tap-nirvana.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | 3 | const tapOut = require('tap-out'); 4 | const through = require('through2'); 5 | const duplexer = require('duplexer'); 6 | const format = require('chalk'); 7 | const prettyMs = require('pretty-ms'); 8 | const _ = require('lodash'); 9 | const repeat = require('repeat-string'); 10 | const symbols = require('figures'); 11 | const stringify = require("json-stringify-pretty-compact"); 12 | const vdiff = require ("variable-diff"); 13 | 14 | function lTrimList (lines) { 15 | 16 | var leftPadding; 17 | 18 | // Get minimum padding count 19 | _.each(lines, function (line) { 20 | 21 | var spaceLen = line.match(/^\s+/)[0].length; 22 | 23 | if (leftPadding === undefined || spaceLen < leftPadding) { 24 | leftPadding = spaceLen; 25 | } 26 | }); 27 | 28 | // Strip padding at beginning of line 29 | return _.map(lines, function (line) { 30 | return line.slice(leftPadding); 31 | }); 32 | } 33 | 34 | /** 35 | * If you try to deepEqual two JSON objects in tape, by the time these reach us 36 | * here, they are stringified Javascript (not JSON!) objects. So we need to 37 | * revive them and the only way is using eval. To make the usage of eval safe 38 | * we wrap it in JSON.stringify and then restore with JSON.parse 39 | * 40 | * @param {*String} jsString 41 | */ 42 | function reviveJSON(jsString) { 43 | let omg; 44 | eval ("omg = JSON.parse(JSON.stringify(" + jsString + "))"); 45 | return omg; 46 | } 47 | 48 | /** 49 | * Remove lines from error stack that belong to test runner. Nobody cares 50 | * @param {*String} stack 51 | */ 52 | function removeUselessStackLines(stack) { 53 | let pretty = stack.split('\n'); 54 | pretty = pretty.filter((line) => { 55 | return !line.includes('node_modules') && !line.includes('Error'); 56 | }); 57 | pretty = pretty.join('\n'); 58 | return pretty; 59 | } 60 | 61 | module.exports = function (spec) { 62 | 63 | spec = spec || {}; 64 | 65 | var OUTPUT_PADDING = spec.padding || ' '; 66 | 67 | var output = through(); 68 | var parser = tapOut(); 69 | var stream = duplexer(parser, output); 70 | var startTime = new Date().getTime(); 71 | 72 | output.push('\n'); 73 | 74 | parser.on('test', function (test) { 75 | output.push('\n' + pad(format.cyan(test.name)) + '\n'); 76 | }); 77 | 78 | // Passing assertions 79 | parser.on('pass', function (assertion) { 80 | 81 | var glyph = format.green(symbols.tick); 82 | var name = format.dim(assertion.name); 83 | 84 | output.push(pad(' ' + glyph + pad(name) + '\n')); 85 | }); 86 | 87 | // Failing assertions 88 | parser.on('fail', function (assertion) { 89 | 90 | var glyph = symbols.cross; 91 | var title = glyph + pad(assertion.name); 92 | var divider = _.fill( 93 | new Array((title).length + 1), 94 | '-' 95 | ).join(''); 96 | output.push(pad(' ' + format.red(title) + '\n')); 97 | output.push(pad(' ' + format.red(divider) + '\n')); 98 | 99 | let skipObjectDiff = true; 100 | let errorMessage = format.magenta("operator:") + " deepEqual\n"; 101 | 102 | if (assertion.error.operator === 'deepEqual') { 103 | skipObjectDiff = false; 104 | try { 105 | const exObj = reviveJSON(assertion.error.expected); 106 | const acObj = reviveJSON(assertion.error.actual); 107 | const expected = stringify(exObj); 108 | const actual = stringify(acObj); 109 | 110 | if (typeof exObj == 'object' && typeof acObj == 'object') { 111 | errorMessage += format.magenta("expected: ") + expected + "\n"; 112 | var difference = vdiff(exObj, acObj).text; 113 | errorMessage += format.magenta("diff: ") + difference + "\n"; 114 | const moreUsefulStack = removeUselessStackLines(assertion.error.stack); 115 | errorMessage += format.magenta("source: ") + format.gray(moreUsefulStack) + "\n"; 116 | } else { 117 | skipObjectDiff = true; 118 | } 119 | } catch (err) { 120 | console.log("error fired " + err); 121 | skipObjectDiff = true; 122 | } 123 | } 124 | 125 | if (skipObjectDiff) { 126 | const expected = assertion.error.expected; 127 | const actual = assertion.error.actual; 128 | //errorMessage += format.magenta("expected: ") + expected + "\n"; 129 | const delta = vdiff(expected, actual).text; 130 | errorMessage += format.magenta("diff: ") + delta + "\n"; 131 | const moreUsefulStack = removeUselessStackLines(assertion.error.stack); 132 | errorMessage += format.magenta("source: ") + format.gray(moreUsefulStack) + "\n"; 133 | } 134 | 135 | errorMessage = prettifyRawError(errorMessage, 3); 136 | output.push(errorMessage); 137 | 138 | stream.failed = true; 139 | }); 140 | 141 | parser.on('comment', function (comment) { 142 | output.push('\n' + pad(' ' + format.yellow(comment.raw))); 143 | }); 144 | 145 | // All done 146 | parser.on('output', function (results) { 147 | 148 | output.push('\n\n'); 149 | 150 | // Most likely a failure upstream 151 | if (results.plans.length < 1) { 152 | process.exit(1); 153 | } 154 | 155 | if (results.fail.length > 0) { 156 | output.push(formatErrors(results)); 157 | output.push('\n'); 158 | } 159 | 160 | output.push(formatTotals(results)); 161 | output.push('\n'); 162 | 163 | // Exit if no tests run. This is a result of 1 of 2 things: 164 | // 1. No tests were written 165 | // 2. There was some error before the TAP got to the parser 166 | if (results.tests.length === 0) { 167 | process.exit(1); 168 | } 169 | }); 170 | 171 | // Utils 172 | 173 | function prettifyRawError (rawError, indentIterations=1) { 174 | 175 | let pretty = rawError.split('\n'); 176 | pretty = pretty.map((line) => { 177 | let padded = line; 178 | for (let i=0; i<= indentIterations; i++) { 179 | padded = pad(padded); 180 | } 181 | return padded; 182 | }); 183 | 184 | pretty = pretty.join('\n') + '\n'; 185 | 186 | return pretty; 187 | } 188 | 189 | // this duplicates errors that we already showd. 190 | // @TODO : remove 191 | function formatErrors (results) { 192 | return ''; 193 | 194 | var failCount = results.fail.length; 195 | var past = (failCount === 1) ? 'was' : 'were'; 196 | var plural = (failCount === 1) ? 'failure' : 'failures'; 197 | 198 | var out = '\n' + pad(format.red.bold('Failed Tests:') + ' There ' + past + ' ' + format.red.bold(failCount) + ' ' + plural + '\n'); 199 | out += formatFailedAssertions(results); 200 | 201 | return out; 202 | } 203 | 204 | function formatTotals (results) { 205 | 206 | if (results.tests.length === 0) { 207 | return pad(format.red(symbols.cross + ' No tests found')); 208 | } 209 | 210 | return pad(format.green('passed: ' + results.pass.length + ',')) + 211 | pad(format.red('failed: ' + results.fail.length)) + 212 | pad('of ' + results.asserts.length + ' tests') + 213 | pad(format.dim('(' + prettyMs(new Date().getTime() - startTime) + ')')); 214 | } 215 | 216 | function formatFailedAssertions (results) { 217 | 218 | var out = ''; 219 | 220 | var groupedAssertions = _.groupBy(results.fail, function (assertion) { 221 | return assertion.test; 222 | }); 223 | 224 | _.each(groupedAssertions, function (assertions, testNumber) { 225 | 226 | // Wrie failed assertion's test name 227 | var test = _.find(results.tests, {number: parseInt(testNumber)}); 228 | out += '\n' + pad(' ' + test.name + '\n\n'); 229 | 230 | // Write failed assertion 231 | _.each(assertions, function (assertion) { 232 | 233 | out += pad(' ' + format.red(symbols.cross) + ' ' + format.red(assertion.name)) + '\n'; 234 | }); 235 | 236 | out += '\n'; 237 | }); 238 | 239 | return out; 240 | } 241 | 242 | function pad (str) { 243 | 244 | return OUTPUT_PADDING + str; 245 | } 246 | 247 | return stream; 248 | }; --------------------------------------------------------------------------------