diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..93efc7b --- /dev/null +++ b/.travis.yml @@ -0,0 +1,21 @@ +language: node_js +cache: + directories: + - ~/.npm +notifications: + email: false +node_js: + - '12' + - '11' + - '10' + - '9' + - '8' + - '7' + - '6' +after_success: + - npm run travis-deploy-once "npm run semantic-release" +branches: + except: + - /^v\d+\.\d+\.\d+$/ +after_script: + - "test -e ./coverage/lcov.info && cat ./coverage/lcov.info | node_modules/.bin/coveralls" diff --git a/index.d.ts b/index.d.ts new file mode 100644 index 0000000..29acff6 --- /dev/null +++ b/index.d.ts @@ -0,0 +1,3 @@ +module "trace-unhandled" { + export function register( ): void; +} diff --git a/index.js b/index.js new file mode 100644 index 0000000..6f79771 --- /dev/null +++ b/index.js @@ -0,0 +1,2 @@ + +exports.register = ( ) => require( './register' ); diff --git a/index.spec.js b/index.spec.js new file mode 100644 index 0000000..d9cb6fc --- /dev/null +++ b/index.spec.js @@ -0,0 +1,21 @@ + +const index = require( './' ); + +describe( "register", ( ) => +{ + it( "should export 'register'", ( ) => + { + expect( index ).toMatchObject( { + register: expect.any( Function ), + } ); + } ); + + it( "should load 'register'", ( ) => + { + const spy = jest.fn( ); + jest.doMock( './register.js', spy ); + index.register( ); + expect( spy.mock.calls.length ).toBe( 1 ); + jest.dontMock( './register.js' ); + } ); +} ); diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..4bb9505 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,4 @@ +module.exports = { + testEnvironment: 'node', + coverageReporters: ['lcov', 'text', 'html'], +}; diff --git a/package.json b/package.json new file mode 100644 index 0000000..858f983 --- /dev/null +++ b/package.json @@ -0,0 +1,55 @@ +{ + "name": "trace-unhandled", + "version": "0.0.0-development", + "description": "Much better tracing of unhandled promise rejections in JavaScript", + "author": "Gustaf Räntilä", + "license": "MIT", + "bugs": { + "url": "https://github.com/grantila/trace-unhandled/issues" + }, + "homepage": "https://github.com/grantila/trace-unhandled#readme", + "main": "./index.js", + "types": "./index.d.ts", + "engines": { + "node": ">=6" + }, + "files": [ + "index.js", + "index.d.ts", + "register.js" + ], + "scripts": { + "test": "node --expose-gc node_modules/.bin/jest --coverage", + "travis-deploy-once": "travis-deploy-once", + "semantic-release": "semantic-release", + "cz": "git-cz" + }, + "repository": { + "type": "git", + "url": "https://github.com/grantila/trace-unhandled" + }, + "keywords": [ + "trace", + "unhandled", + "rejection", + "promise", + "stack", + "stacktrace" + ], + "devDependencies": { + "@types/jest": "^20", + "@types/node": "^12", + "already": "^1.8.0", + "commitizen": "^3", + "coveralls": "^3", + "cz-conventional-changelog": "^2", + "jest": "^20", + "semantic-release": "^15.13.18", + "travis-deploy-once": "^5" + }, + "config": { + "commitizen": { + "path": "./node_modules/cz-conventional-changelog" + } + } +} diff --git a/register.js b/register.js new file mode 100644 index 0000000..1b7c70e --- /dev/null +++ b/register.js @@ -0,0 +1,85 @@ + +Error.stackTraceLimit = 100; + +const reStackEntry = /\s+at\s/; + +function splitErrorStack( error ) +{ + const lines = error.stack.split( "\n" ); + const index = lines.findIndex( line => reStackEntry.test( line ) ); + const message = lines.slice( 0, index ).join( "\n" ); + return { message, lines: lines.slice( index ) }; +} + +function mergeErrors( traceError, mainError ) +{ + const { lines: traceLines } = splitErrorStack( traceError ); + const { lines: errorLines, message } = splitErrorStack( mainError ) + + if ( traceLines[ 0 ].includes( "at new TraceablePromise" ) ) + { + traceLines.shift( ); + + const ignore = [ + "at Function.reject ()", + "at Promise.__proto__.constructor.reject", + ]; + if ( ignore.some( test => traceLines[ 0 ].includes( test ) ) ) + traceLines.shift( ); + } + + traceLines.reverse( ); + errorLines.reverse( ); + + var i = 0; + for ( ; + i < errorLines.length && + i < traceLines.length && + errorLines[ i ] === traceLines[ i ]; + ++i + ); + + return message + + "\n ==== Promise at: ==================\n" + + traceLines.slice( i ).reverse( ).join( "\n" ) + + "\n\n ==== Error at: ====================\n" + + errorLines.slice( i ).reverse( ).join( "\n" ) + + "\n\n ==== Shared trace: ================\n" + + errorLines.slice( 0, i ).reverse( ).join( "\n" ); +} + +process.on( "unhandledRejection", ( reason, promise ) => +{ + const stack = + promise.__tracedError + ? mergeErrors( promise.__tracedError, reason ) + : reason.stack; + + console.error( + `(node:${process.pid}) UnhandledPromiseRejectionWarning\n` + + ( + !promise.__tracedError + ? "" + : `[ Stacktrace altered by trace-unhandled-rejection ]\n` + ) + + stack + ); +} ); + +class TraceablePromise extends Promise +{ + constructor( executor ) + { + super( wrappedExecutor ); + + function wrappedExecutor( ...args ) + { + return executor( ...args ); + } + + const err = new Error( "Non-failing tracing error" ); + this.__tracedError = err; + } +} + +global.Promise = TraceablePromise; diff --git a/register.spec.js b/register.spec.js new file mode 100644 index 0000000..3792b33 --- /dev/null +++ b/register.spec.js @@ -0,0 +1,108 @@ + +const { Finally, Try, delay } = require( 'already' ); +require( './register' ); + +const withConsoleSpy = fn => async ( ) => +{ + const oldError = console.error; + console.error = jest.fn( ); + return Try( fn ) + .then( ...Finally( ( ) => + { + console.error = oldError; + } ) ); +} + +async function triggerUnhandledWarnings( ) +{ + await delay( 0 ); + global.gc && global.gc( ); + await delay( 0 ); +} + +function splitLines( lines ) +{ + return [ ].concat( ...lines.map( line => line.split( "\n" ) ) ); +} + +function cutColumn( line ) +{ + const m = line.match( /(.*:\d+):\d+$/ ); + if ( !m ) + return line; + return m[ 1 ]; +} + +function cutLocation( line ) +{ + const m = line.match( /(.*):\d+:\d+$/ ); + if ( !m ) + return line; + return m[ 1 ]; +} + +describe( "register", ( ) => +{ + it( "Handle simplest case (same location)", withConsoleSpy( async ( ) => + { + Promise.reject( new Error( "the error" ) ); + + await triggerUnhandledWarnings( ); + + const lines = splitLines( console.error.mock.calls[ 0 ] ) + .filter( line => line.includes( "register.spec.js" ) ) + .map( line => cutColumn( line ) ); + + expect( lines.length ).toBeGreaterThanOrEqual( 2 ); + expect( lines[ 0 ] ).toBe( lines[ 1 ] ); + } ) ); + + it( "Handle async case (different locations)", withConsoleSpy( async ( ) => + { + const err = new Error( "the error" ); + + Promise.reject( err ); + + await triggerUnhandledWarnings( ); + + const linesWoColumns = splitLines( console.error.mock.calls[ 0 ] ) + .filter( line => line.includes( "register.spec.js" ) ) + .map( line => cutColumn( line ) ); + + const linesWoLocation = splitLines( console.error.mock.calls[ 0 ] ) + .filter( line => line.includes( "register.spec.js" ) ) + .map( line => cutLocation( line ) ); + + expect( linesWoColumns.length ).toBeGreaterThanOrEqual( 2 ); + expect( linesWoColumns[ 0 ] ).not.toBe( linesWoColumns[ 1 ] ); + + expect( linesWoLocation.length ).toBeGreaterThanOrEqual( 2 ); + expect( linesWoLocation[ 0 ] ).toBe( linesWoLocation[ 1 ] ); + } ) ); + + it( "Handle async case (different locations)", withConsoleSpy( async ( ) => + { + const err = new Error( "the error" ); + + new Promise( ( resolve, reject ) => + { + reject( err ); + } ); + + await triggerUnhandledWarnings( ); + + const linesWoColumns = splitLines( console.error.mock.calls[ 0 ] ) + .filter( line => line.includes( "register.spec.js" ) ) + .map( line => cutColumn( line ) ); + + const linesWoLocation = splitLines( console.error.mock.calls[ 0 ] ) + .filter( line => line.includes( "register.spec.js" ) ) + .map( line => cutLocation( line ) ); + + expect( linesWoColumns.length ).toBeGreaterThanOrEqual( 2 ); + expect( linesWoColumns[ 0 ] ).not.toBe( linesWoColumns[ 1 ] ); + + expect( linesWoLocation.length ).toBeGreaterThanOrEqual( 2 ); + expect( linesWoLocation[ 0 ] ).toBe( linesWoLocation[ 1 ] ); + } ) ); +} );