From 5906a2c05c5628ca7cf06cf4ca1bada4a7c392b9 Mon Sep 17 00:00:00 2001 From: Steve Gravrock Date: Wed, 8 Nov 2017 22:44:29 -0800 Subject: [PATCH] Filter Jasmine frames from stack traces [Finishes #2644992] --- lib/jasmine-core/jasmine.js | 166 +++++++++++++++++++++++++++- spec/core/ExceptionFormatterSpec.js | 59 ++++++++++ spec/core/StackTraceSpec.js | 166 ++++++++++++++++++++++++++++ spec/core/UtilSpec.js | 11 +- src/core/ExceptionFormatter.js | 34 +++++- src/core/StackTrace.js | 80 ++++++++++++++ src/core/requireCore.js | 3 +- src/core/util.js | 48 ++++++++ 8 files changed, 557 insertions(+), 10 deletions(-) create mode 100644 spec/core/StackTraceSpec.js create mode 100644 src/core/StackTrace.js diff --git a/lib/jasmine-core/jasmine.js b/lib/jasmine-core/jasmine.js index 19208da3..69698fdc 100644 --- a/lib/jasmine-core/jasmine.js +++ b/lib/jasmine-core/jasmine.js @@ -56,7 +56,8 @@ var getJasmineRequireObj = (function (jasmineGlobal) { j$.Clock = jRequire.Clock(); j$.DelayedFunctionScheduler = jRequire.DelayedFunctionScheduler(); j$.Env = jRequire.Env(j$); - j$.ExceptionFormatter = jRequire.ExceptionFormatter(); + j$.StackTrace = jRequire.StackTrace(j$); + j$.ExceptionFormatter = jRequire.ExceptionFormatter(j$); j$.Expectation = jRequire.Expectation(); j$.buildExpectationResult = jRequire.buildExpectationResult(); j$.JsApiReporter = jRequire.JsApiReporter(); @@ -461,6 +462,54 @@ getJasmineRequireObj().util = function(j$) { return Object.prototype.hasOwnProperty.call(obj, key); }; + function anyMatch(pattern, lines) { + var i; + + for (i = 0; i < lines.length; i++) { + if (lines[i].match(pattern)) { + return true; + } + } + + return false; + } + + function errorWithStack() { + // Don't throw and catch if we don't have to, because it makes it harder + // for users to debug their code with exception breakpoints. + var error = new Error(); + + if (error.stack) { + return error; + } + + // But some browsers (e.g. Phantom) only provide a stack trace if we throw. + try { + throw new Error(); + } catch (e) { + return e; + } + } + + function callerFile() { + var trace = new j$.StackTrace(errorWithStack().stack); + return trace.frames[2].file; + } + + util.jasmineFile = (function() { + var result; + + return function() { + var trace; + + if (!result) { + result = callerFile(); + } + + return result; + }; + }()); + return util; }; @@ -2186,8 +2235,10 @@ getJasmineRequireObj().errors = function() { ExpectationFailed: ExpectationFailed }; }; -getJasmineRequireObj().ExceptionFormatter = function() { - function ExceptionFormatter() { +getJasmineRequireObj().ExceptionFormatter = function(j$) { + + function ExceptionFormatter(options) { + var jasmineFile = (options && options.jasmineFile) || j$.util.jasmineFile(); this.message = function(error) { var message = ''; @@ -2209,8 +2260,34 @@ getJasmineRequireObj().ExceptionFormatter = function() { }; this.stack = function(error) { - return error ? error.stack : null; + if (!error || !error.stack) { + return null; + } + + var stackTrace = new j$.StackTrace(error.stack); + var lines = filterJasmine(stackTrace); + + if (stackTrace.message) { + lines.unshift(stackTrace.message); + } + + return lines.join('\n'); }; + + function filterJasmine(stackTrace) { + var result = [], + jasmineMarker = stackTrace.style === 'webkit' ? '' : ' at '; + + stackTrace.frames.forEach(function(frame) { + if (frame.file && frame.file !== jasmineFile) { + result.push(frame.raw); + } else if (result[result.length - 1] !== jasmineMarker) { + result.push(jasmineMarker); + } + }); + + return result; + } } return ExceptionFormatter; @@ -5033,6 +5110,87 @@ getJasmineRequireObj().SpyStrategy = function(j$) { return SpyStrategy; }; +getJasmineRequireObj().StackTrace = function(j$) { + function StackTrace(rawTrace) { + var lines = rawTrace + .split('\n') + .filter(function(line) { return line !== ''; }); + + if (lines[0].match(/^Error/)) { + this.message = lines.shift(); + } else { + this.message = undefined; + } + + var parseResult = tryParseFrames(lines); + this.frames = parseResult.frames; + this.style = parseResult.style; + } + + var framePatterns = [ + // PhantomJS on Linux, Node, Chrome, IE, Edge + // e.g. " at QueueRunner.run (http://localhost:8888/__jasmine__/jasmine.js:4320:20)" + // Note that the "function name" can include a surprisingly large set of + // characters, including angle brackets and square brackets. + { re: /^\s*at ([^\)]+) \(([^\)]+)\)$/, fnIx: 1, fileLineColIx: 2, style: 'v8' }, + + // NodeJS alternate form, often mixed in with the Chrome style + // e.g. " at /some/path:4320:20 + { re: /\s*at (.+)$/, fileLineColIx: 1, style: 'v8' }, + + // PhantomJS on OS X, Safari, Firefox + // e.g. "run@http://localhost:8888/__jasmine__/jasmine.js:4320:27" + // or "http://localhost:8888/__jasmine__/jasmine.js:4320:27" + { re: /^(([^@\s]+)@)?([^\s]+)$/, fnIx: 2, fileLineColIx: 3, style: 'webkit' } + ]; + + // regexes should capture the function name (if any) as group 1 + // and the file, line, and column as group 2. + function tryParseFrames(lines) { + var style = null; + var frames = lines.map(function(line) { + var convertedLine = first(framePatterns, function(pattern) { + var overallMatch = line.match(pattern.re), + fileLineColMatch; + if (!overallMatch) { return null; } + + fileLineColMatch = overallMatch[pattern.fileLineColIx].match( + /^(.*):(\d+):\d+$/); + if (!fileLineColMatch) { return null; } + + style = style || pattern.style; + return { + raw: line, + file: fileLineColMatch[1], + line: parseInt(fileLineColMatch[2], 10), + func: overallMatch[pattern.fnIx] + }; + }); + + return convertedLine || { raw: line }; + }); + + return { + style: style, + frames: frames + }; + } + + function first(items, fn) { + var i, result; + + for (i = 0; i < items.length; i++) { + result = fn(items[i]); + + if (result) { + return result; + } + } + } + + return StackTrace; +}; + getJasmineRequireObj().Suite = function(j$) { function Suite(attrs) { this.env = attrs.env; diff --git a/spec/core/ExceptionFormatterSpec.js b/spec/core/ExceptionFormatterSpec.js index cdea7c8c..a7069d3e 100644 --- a/spec/core/ExceptionFormatterSpec.js +++ b/spec/core/ExceptionFormatterSpec.js @@ -54,6 +54,65 @@ describe("ExceptionFormatter", function() { expect(new jasmineUnderTest.ExceptionFormatter().stack(error)).toMatch(/ExceptionFormatterSpec\.js.*\d+/) }); + it("filters Jasmine stack frames from V8 style traces", function() { + var error = { + stack: 'Error: nope\n' + + ' at fn1 (http://localhost:8888/__spec__/core/UtilSpec.js:115:19)\n' + + ' at fn2 (http://localhost:8888/__jasmine__/jasmine.js:4320:20)\n' + + ' at fn3 (http://localhost:8888/__jasmine__/jasmine.js:4320:20)\n' + + ' at fn4 (http://localhost:8888/__spec__/core/UtilSpec.js:110:19)\n' + }; + var subject = new jasmineUnderTest.ExceptionFormatter({ + jasmineFile: 'http://localhost:8888/__jasmine__/jasmine.js' + }); + var result = subject.stack(error); + expect(result).toEqual('Error: nope\n' + + ' at fn1 (http://localhost:8888/__spec__/core/UtilSpec.js:115:19)\n' + + ' at \n' + + ' at fn4 (http://localhost:8888/__spec__/core/UtilSpec.js:110:19)' + ); + }); + + it("filters Jamine stack frames from Webkit style traces", function() { + var error = { + stack: 'http://localhost:8888/__spec__/core/UtilSpec.js:115:28\n' + + 'fn1@http://localhost:8888/__jasmine__/jasmine.js:4320:27\n' + + 'fn2@http://localhost:8888/__jasmine__/jasmine.js:4320:27\n' + + 'http://localhost:8888/__spec__/core/UtilSpec.js:115:28' + }; + var subject = new jasmineUnderTest.ExceptionFormatter({ + jasmineFile: 'http://localhost:8888/__jasmine__/jasmine.js' + }); + var result = subject.stack(error); + expect(result).toEqual( + 'http://localhost:8888/__spec__/core/UtilSpec.js:115:28\n' + + '\n' + + 'http://localhost:8888/__spec__/core/UtilSpec.js:115:28' + ); + }); + + it("filters Jasmine stack frames in this environment", function() { + var error, i; + try { throw new Error("an error"); } catch(e) { error = e; } + var subject = new jasmineUnderTest.ExceptionFormatter({ + jasmineFile: jasmine.util.jasmineFile() + }); + var result = subject.stack(error); + var lines = result.split('\n'); + + if (lines[0].match(/an error/)) { + lines.shift(); + } + + expect(lines[0]).toMatch(/ExceptionFormatterSpec.js/); + expect(lines[1]).toMatch(//); + + // Node has some number of additional frames below Jasmine. + for (i = 2; i < lines.length; i++) { + expect(lines[i]).not.toMatch(/jasmine.js/); + } + }); + it("returns null if no Error provided", function() { expect(new jasmineUnderTest.ExceptionFormatter().stack()).toBeNull(); }); diff --git a/spec/core/StackTraceSpec.js b/spec/core/StackTraceSpec.js new file mode 100644 index 00000000..e07b306c --- /dev/null +++ b/spec/core/StackTraceSpec.js @@ -0,0 +1,166 @@ +describe("StackTrace", function() { + it("understands Chrome/IE/Edge style traces", function() { + var raw = + 'Error: nope\n' + + ' at UserContext. (http://localhost:8888/__spec__/core/UtilSpec.js:115:19)\n' + + ' at QueueRunner.run (http://localhost:8888/__jasmine__/jasmine.js:4320:20)'; + + var result = new jasmineUnderTest.StackTrace(raw); + + expect(result.message).toEqual('Error: nope'); + expect(result.style).toEqual('v8'); + expect(result.frames).toEqual([ + { + raw: ' at UserContext. (http://localhost:8888/__spec__/core/UtilSpec.js:115:19)', + func: 'UserContext.', + file: 'http://localhost:8888/__spec__/core/UtilSpec.js', + line: 115 + }, + { + raw: ' at QueueRunner.run (http://localhost:8888/__jasmine__/jasmine.js:4320:20)', + func: 'QueueRunner.run', + file: 'http://localhost:8888/__jasmine__/jasmine.js', + line: 4320 + } + ]); + }); + + it("understands Node style traces", function() { + var raw = 'Error\n' + + ' at /somewhere/jasmine/lib/jasmine-core/jasmine.js:4255:9\n' + + ' at QueueRunner.complete [as onComplete] (/somewhere/jasmine/lib/jasmine-core/jasmine.js:579:9)\n' + + ' at Immediate. (/somewhere/jasmine/lib/jasmine-core/jasmine.js:4314:12)\n' + + ' at runCallback (timers.js:672:20)'; + var result = new jasmineUnderTest.StackTrace(raw); + + expect(result.message).toEqual('Error'); + expect(result.style).toEqual('v8'); + expect(result.frames).toEqual([ + { + raw: ' at /somewhere/jasmine/lib/jasmine-core/jasmine.js:4255:9', + func: undefined, + file: '/somewhere/jasmine/lib/jasmine-core/jasmine.js', + line: 4255 + }, + { + raw: ' at QueueRunner.complete [as onComplete] (/somewhere/jasmine/lib/jasmine-core/jasmine.js:579:9)', + func: 'QueueRunner.complete [as onComplete]', + file: '/somewhere/jasmine/lib/jasmine-core/jasmine.js', + line: 579 + }, + { + raw: ' at Immediate. (/somewhere/jasmine/lib/jasmine-core/jasmine.js:4314:12)', + func: 'Immediate.', + file: '/somewhere/jasmine/lib/jasmine-core/jasmine.js', + line: 4314 + }, + { + raw: ' at runCallback (timers.js:672:20)', + func: 'runCallback', + file: 'timers.js', + line: 672 + } + ]); + }); + + it("understands Safari/Firefox/Phantom-OS X style traces", function() { + var raw = + 'http://localhost:8888/__spec__/core/UtilSpec.js:115:28\n' + + 'run@http://localhost:8888/__jasmine__/jasmine.js:4320:27'; + var result = new jasmineUnderTest.StackTrace(raw); + + expect(result.message).toBeFalsy(); + expect(result.style).toEqual('webkit'); + expect(result.frames).toEqual([ + { + raw: 'http://localhost:8888/__spec__/core/UtilSpec.js:115:28', + func: undefined, + file: 'http://localhost:8888/__spec__/core/UtilSpec.js', + line: 115 + }, + { + raw: 'run@http://localhost:8888/__jasmine__/jasmine.js:4320:27', + func: 'run', + file: 'http://localhost:8888/__jasmine__/jasmine.js', + line: 4320 + } + ]); + }); + + it("does not mistake gibberish for Safari/Firefox/Phantom-OS X style traces", function() { + var raw = 'randomcharsnotincludingwhitespace'; + var result = new jasmineUnderTest.StackTrace(raw); + expect(result.style).toBeNull(); + expect(result.frames).toEqual([ + { raw: raw } + ]); + }); + + it("understands Phantom-Linux style traces", function() { + var raw = + ' at UserContext. (http://localhost:8888/__spec__/core/UtilSpec.js:115:19)\n' + + ' at QueueRunner.run (http://localhost:8888/__jasmine__/jasmine.js:4320:20)'; + + var result = new jasmineUnderTest.StackTrace(raw); + + expect(result.message).toBeFalsy(); + expect(result.style).toEqual('v8'); + expect(result.frames).toEqual([ + { + raw: ' at UserContext. (http://localhost:8888/__spec__/core/UtilSpec.js:115:19)', + func: 'UserContext.', + file: 'http://localhost:8888/__spec__/core/UtilSpec.js', + line: 115 + }, + { + raw: ' at QueueRunner.run (http://localhost:8888/__jasmine__/jasmine.js:4320:20)', + func: 'QueueRunner.run', + file: 'http://localhost:8888/__jasmine__/jasmine.js', + line: 4320 + } + ]); + }); + + it("ignores blank lines", function() { + var raw = + ' at UserContext. (http://localhost:8888/__spec__/core/UtilSpec.js:115:19)\n'; + + var result = new jasmineUnderTest.StackTrace(raw); + + expect(result.frames).toEqual([ + { + raw: ' at UserContext. (http://localhost:8888/__spec__/core/UtilSpec.js:115:19)', + func: 'UserContext.', + file: 'http://localhost:8888/__spec__/core/UtilSpec.js', + line: 115 + } + ]); + }); + + it("omits properties except 'raw' for frames that are not understood", function() { + var raw = + ' at UserContext. (http://localhost:8888/__spec__/core/UtilSpec.js:115:19)\n' + + ' but this is quite unexpected\n' + + ' at QueueRunner.run (http://localhost:8888/__jasmine__/jasmine.js:4320:20)'; + + var result = new jasmineUnderTest.StackTrace(raw); + expect(result.style).toEqual('v8'); + expect(result.frames).toEqual([ + { + raw: ' at UserContext. (http://localhost:8888/__spec__/core/UtilSpec.js:115:19)', + func: 'UserContext.', + file: 'http://localhost:8888/__spec__/core/UtilSpec.js', + line: 115 + }, + { + raw: ' but this is quite unexpected' + }, + { + raw: ' at QueueRunner.run (http://localhost:8888/__jasmine__/jasmine.js:4320:20)', + func: 'QueueRunner.run', + file: 'http://localhost:8888/__jasmine__/jasmine.js', + line: 4320 + } + ]); + }); +}); diff --git a/spec/core/UtilSpec.js b/spec/core/UtilSpec.js index 84f08b8f..1cd616a2 100644 --- a/spec/core/UtilSpec.js +++ b/spec/core/UtilSpec.js @@ -91,6 +91,13 @@ describe("jasmineUnderTest.util", function() { expect(jasmineUnderTest.util.objectDifference(a, b)).toEqual({x: 1}); expect(jasmineUnderTest.util.objectDifference(b, a)).toEqual({y: 2}); - }) - }) + }); + }); + + describe("jasmineFile", function() { + it("returns the file containing jasmine.util", function() { + expect(jasmineUnderTest.util.jasmineFile()).toMatch(/util.js$/); + expect(jasmine.util.jasmineFile()).toMatch(/jasmine.js$/); + }); + }); }); diff --git a/src/core/ExceptionFormatter.js b/src/core/ExceptionFormatter.js index 74361a9a..e6808403 100644 --- a/src/core/ExceptionFormatter.js +++ b/src/core/ExceptionFormatter.js @@ -1,5 +1,7 @@ -getJasmineRequireObj().ExceptionFormatter = function() { - function ExceptionFormatter() { +getJasmineRequireObj().ExceptionFormatter = function(j$) { + + function ExceptionFormatter(options) { + var jasmineFile = (options && options.jasmineFile) || j$.util.jasmineFile(); this.message = function(error) { var message = ''; @@ -21,8 +23,34 @@ getJasmineRequireObj().ExceptionFormatter = function() { }; this.stack = function(error) { - return error ? error.stack : null; + if (!error || !error.stack) { + return null; + } + + var stackTrace = new j$.StackTrace(error.stack); + var lines = filterJasmine(stackTrace); + + if (stackTrace.message) { + lines.unshift(stackTrace.message); + } + + return lines.join('\n'); }; + + function filterJasmine(stackTrace) { + var result = [], + jasmineMarker = stackTrace.style === 'webkit' ? '' : ' at '; + + stackTrace.frames.forEach(function(frame) { + if (frame.file && frame.file !== jasmineFile) { + result.push(frame.raw); + } else if (result[result.length - 1] !== jasmineMarker) { + result.push(jasmineMarker); + } + }); + + return result; + } } return ExceptionFormatter; diff --git a/src/core/StackTrace.js b/src/core/StackTrace.js new file mode 100644 index 00000000..54ca89d6 --- /dev/null +++ b/src/core/StackTrace.js @@ -0,0 +1,80 @@ +getJasmineRequireObj().StackTrace = function(j$) { + function StackTrace(rawTrace) { + var lines = rawTrace + .split('\n') + .filter(function(line) { return line !== ''; }); + + if (lines[0].match(/^Error/)) { + this.message = lines.shift(); + } else { + this.message = undefined; + } + + var parseResult = tryParseFrames(lines); + this.frames = parseResult.frames; + this.style = parseResult.style; + } + + var framePatterns = [ + // PhantomJS on Linux, Node, Chrome, IE, Edge + // e.g. " at QueueRunner.run (http://localhost:8888/__jasmine__/jasmine.js:4320:20)" + // Note that the "function name" can include a surprisingly large set of + // characters, including angle brackets and square brackets. + { re: /^\s*at ([^\)]+) \(([^\)]+)\)$/, fnIx: 1, fileLineColIx: 2, style: 'v8' }, + + // NodeJS alternate form, often mixed in with the Chrome style + // e.g. " at /some/path:4320:20 + { re: /\s*at (.+)$/, fileLineColIx: 1, style: 'v8' }, + + // PhantomJS on OS X, Safari, Firefox + // e.g. "run@http://localhost:8888/__jasmine__/jasmine.js:4320:27" + // or "http://localhost:8888/__jasmine__/jasmine.js:4320:27" + { re: /^(([^@\s]+)@)?([^\s]+)$/, fnIx: 2, fileLineColIx: 3, style: 'webkit' } + ]; + + // regexes should capture the function name (if any) as group 1 + // and the file, line, and column as group 2. + function tryParseFrames(lines) { + var style = null; + var frames = lines.map(function(line) { + var convertedLine = first(framePatterns, function(pattern) { + var overallMatch = line.match(pattern.re), + fileLineColMatch; + if (!overallMatch) { return null; } + + fileLineColMatch = overallMatch[pattern.fileLineColIx].match( + /^(.*):(\d+):\d+$/); + if (!fileLineColMatch) { return null; } + + style = style || pattern.style; + return { + raw: line, + file: fileLineColMatch[1], + line: parseInt(fileLineColMatch[2], 10), + func: overallMatch[pattern.fnIx] + }; + }); + + return convertedLine || { raw: line }; + }); + + return { + style: style, + frames: frames + }; + } + + function first(items, fn) { + var i, result; + + for (i = 0; i < items.length; i++) { + result = fn(items[i]); + + if (result) { + return result; + } + } + } + + return StackTrace; +}; diff --git a/src/core/requireCore.js b/src/core/requireCore.js index 44e868e3..78fb343a 100644 --- a/src/core/requireCore.js +++ b/src/core/requireCore.js @@ -34,7 +34,8 @@ var getJasmineRequireObj = (function (jasmineGlobal) { j$.Clock = jRequire.Clock(); j$.DelayedFunctionScheduler = jRequire.DelayedFunctionScheduler(); j$.Env = jRequire.Env(j$); - j$.ExceptionFormatter = jRequire.ExceptionFormatter(); + j$.StackTrace = jRequire.StackTrace(j$); + j$.ExceptionFormatter = jRequire.ExceptionFormatter(j$); j$.Expectation = jRequire.Expectation(); j$.buildExpectationResult = jRequire.buildExpectationResult(); j$.JsApiReporter = jRequire.JsApiReporter(); diff --git a/src/core/util.js b/src/core/util.js index 825e8ae6..c53c3aca 100644 --- a/src/core/util.js +++ b/src/core/util.js @@ -100,5 +100,53 @@ getJasmineRequireObj().util = function(j$) { return Object.prototype.hasOwnProperty.call(obj, key); }; + function anyMatch(pattern, lines) { + var i; + + for (i = 0; i < lines.length; i++) { + if (lines[i].match(pattern)) { + return true; + } + } + + return false; + } + + function errorWithStack() { + // Don't throw and catch if we don't have to, because it makes it harder + // for users to debug their code with exception breakpoints. + var error = new Error(); + + if (error.stack) { + return error; + } + + // But some browsers (e.g. Phantom) only provide a stack trace if we throw. + try { + throw new Error(); + } catch (e) { + return e; + } + } + + function callerFile() { + var trace = new j$.StackTrace(errorWithStack().stack); + return trace.frames[2].file; + } + + util.jasmineFile = (function() { + var result; + + return function() { + var trace; + + if (!result) { + result = callerFile(); + } + + return result; + }; + }()); + return util; };