diff --git a/src/chrome/chromeDebugAdapter.ts b/src/chrome/chromeDebugAdapter.ts index 3b4af79a6..c1777b95e 100644 --- a/src/chrome/chromeDebugAdapter.ts +++ b/src/chrome/chromeDebugAdapter.ts @@ -740,10 +740,38 @@ export abstract class ChromeDebugAdapter implements IDebugAdapter { protected onExceptionThrown(params: Crdp.Runtime.ExceptionThrownEvent): void { const formattedException = formatExceptionDetails(params.exceptionDetails); - this._session.sendEvent(new OutputEvent( - formattedException, - 'stderr' - )); + this.sourceMapFormattedException(formattedException).then(exceptionStr => { + this._session.sendEvent(new OutputEvent( + exceptionStr, + 'stderr' + )); + }); + } + + // We parse stack trace from `formattedException`, source map it and return a new string + protected async sourceMapFormattedException(formattedException: string): Promise { + const exceptionLines = formattedException.split(/\r?\n/); + + for (let i = 0, len = exceptionLines.length; i < len; ++i) { + const line = exceptionLines[i]; + const matches = line.match(/^\s+at (.*?)\s*\(?([^ ]+\.js):(\d+):(\d+)\)?$/); + + if (!matches) continue; + const path = matches[2]; + const lineNum = parseInt(matches[3], 10); + const columnNum = parseInt(matches[4], 10); + const clientPath = this._pathTransformer.getClientPathFromTargetPath(path); + const mapped = await this._sourceMapTransformer.mapToAuthored(clientPath || path, lineNum, columnNum); + + if (mapped && mapped.source && mapped.line && mapped.column) { + exceptionLines[i] = exceptionLines[i].replace( + `${path}:${lineNum}:${columnNum}`, + `${mapped.source}:${mapped.line}:${mapped.column}` + ); + } + } + + return exceptionLines.join('\n'); } /** diff --git a/src/chrome/consoleHelper.ts b/src/chrome/consoleHelper.ts index e5fbf0c47..90a3cffb4 100644 --- a/src/chrome/consoleHelper.ts +++ b/src/chrome/consoleHelper.ts @@ -140,7 +140,7 @@ function stackTraceToString(stackTrace: Crdp.Runtime.StackTrace): string { .map(frame => { const fnName = frame.functionName || (frame.url ? '(anonymous)' : '(eval)'); const fileName = frame.url ? url.parse(frame.url).pathname : 'eval'; - return ` at ${fnName} (${fileName}:${frame.lineNumber})`; + return ` at ${fnName} (${fileName}:${frame.lineNumber}:${frame.columnNumber})`; }) .join('\n'); } diff --git a/test/chrome/chromeDebugAdapter.test.ts b/test/chrome/chromeDebugAdapter.test.ts index 5c3dcbfdd..fda319825 100644 --- a/test/chrome/chromeDebugAdapter.test.ts +++ b/test/chrome/chromeDebugAdapter.test.ts @@ -75,26 +75,30 @@ suite('ChromeDebugAdapter', () => { mockSourceMapTransformer = getMockSourceMapTransformer(); mockPathTransformer = getMockPathTransformer(); + initChromeDebugAdapter(); + }); + + function initChromeDebugAdapter(): void { // Instantiate the ChromeDebugAdapter, injecting the mock ChromeConnection /* tslint:disable */ chromeDebugAdapter = new (require(MODULE_UNDER_TEST).ChromeDebugAdapter)({ - chromeConnection: function() { return mockChromeConnection.object; }, - lineColTransformer: function() { return mockLineNumberTransformer.object; }, - sourceMapTransformer: function() { return mockSourceMapTransformer.object; }, - pathTransformer: function() { return mockPathTransformer.object; } + chromeConnection: function () { return mockChromeConnection.object; }, + lineColTransformer: function () { return mockLineNumberTransformer.object; }, + sourceMapTransformer: function () { return mockSourceMapTransformer.object; }, + pathTransformer: function () { return mockPathTransformer.object; } }, - { - sendEvent: (e: DebugProtocol.Event) => { - if (sendEventHandler) { - // Filter telemetry events - if (!(e.event === 'output' && (e).body.category === 'telemetry')) { - sendEventHandler(e); + { + sendEvent: (e: DebugProtocol.Event) => { + if (sendEventHandler) { + // Filter telemetry events + if (!(e.event === 'output' && (e).body.category === 'telemetry')) { + sendEventHandler(e); + } } } - } - }); + }); /* tslint:enable */ - }); + } teardown(() => { sendEventHandler = undefined; @@ -113,6 +117,16 @@ suite('ChromeDebugAdapter', () => { mockEventEmitter.emit('Debugger.scriptParsed', { scriptId, url }); } + // Helper to run async asserts inside promises so they can be correctly awaited + function asyncAssert(assertFn: Function, resolve: (value?: any) => void, reject: (reason?: any) => void): void { + try { + assertFn(); + resolve(); + } catch (e) { + reject(e); + } + } + suite('attach()', () => { test('if successful, an initialized event is fired', () => { let initializedFired = false; @@ -468,6 +482,68 @@ suite('ChromeDebugAdapter', () => { }); }); + suite('onExceptionThrown', () => { + const authoredPath = '/Users/me/error.ts'; + const generatedPath = 'http://localhost:9999/error.js'; + + const getExceptionStr = (path, line) => 'Error: kaboom!\n' + + ` at error (${path}:${line}:1)\n` + + ` at ${path}:${line}:1`; + + const generatedExceptionStr = getExceptionStr(generatedPath, 6); + const authoredExceptionStr = getExceptionStr(authoredPath, 12); + + const exceptionEvent: Crdp.Runtime.ExceptionThrownEvent = { + "timestamp": 1490164925297, + "exceptionDetails": { + "exceptionId": 21, + "text": "Uncaught", + "lineNumber": 5, + "columnNumber": 10, + "url": "http://localhost:9999/error.js", + "stackTrace": null, + "exception": { + "type": "object", + "subtype": "error", + "className": "Error", + "description": generatedExceptionStr, + "objectId": "{\"injectedScriptId\":148,\"id\":1}" + }, + "executionContextId": 148 + } + }; + + test('passes through exception when no source mapping present', async () => { + await chromeDebugAdapter.attach(ATTACH_ARGS); + const sendEventP = new Promise((resolve, reject) => { + sendEventHandler = (event) => + asyncAssert(() => assert.equal(event.body.output, generatedExceptionStr), resolve, reject); + }); + + mockEventEmitter.emit('Runtime.exceptionThrown', exceptionEvent); + await sendEventP; + }); + + test('translates callstack to authored files via source mapping', async () => { + // We need to reset mocks and re-initialize chromeDebugAdapter + // because reset() creates a new instance of object + mockSourceMapTransformer.reset(); + initChromeDebugAdapter(); + + await chromeDebugAdapter.attach(ATTACH_ARGS); + const sendEventP = new Promise((resolve, reject) => { + sendEventHandler = (event) => + asyncAssert(() => assert.equal(event.body.output, authoredExceptionStr), resolve, reject); + }); + + mockSourceMapTransformer.setup(m => m.mapToAuthored(It.isValue(generatedPath), It.isAnyNumber(), It.isAnyNumber())) + .returns(() => Promise.resolve({ source: authoredPath, line: 12, column: 1 })); + + mockEventEmitter.emit('Runtime.exceptionThrown', exceptionEvent); + await sendEventP; + }); + }); + suite('setExceptionBreakpoints()', () => { }); suite('stepping', () => { }); suite('stackTrace()', () => { }); diff --git a/test/chrome/consoleHelper.test.ts b/test/chrome/consoleHelper.test.ts index dcab2c142..58054f07c 100644 --- a/test/chrome/consoleHelper.test.ts +++ b/test/chrome/consoleHelper.test.ts @@ -99,7 +99,7 @@ suite('ConsoleHelper', () => { suite('console.assert()', () => { test(`Prints params and doesn't resolve format specifiers`, () => { - doAssertForString(Runtime.makeAssert('Fail %s 123', 456), 'Assertion failed: Fail %s 123 456\n at myFn (/script/a.js:4)', true); + doAssertForString(Runtime.makeAssert('Fail %s 123', 456), 'Assertion failed: Fail %s 123 456\n at myFn (/script/a.js:4:1)', true); }); }); }); @@ -162,7 +162,7 @@ namespace Runtime { export function makeAssert(...args: any[]): Crdp.Runtime.ConsoleAPICalledEvent { const fakeStackTrace: Crdp.Runtime.StackTrace = { - callFrames: [{ url: '/script/a.js', lineNumber: 4, columnNumber: 0, scriptId: '1', functionName: 'myFn' }] + callFrames: [{ url: '/script/a.js', lineNumber: 4, columnNumber: 1, scriptId: '1', functionName: 'myFn' }] }; return makeMockMessage('assert', args, { level: 'error', stackTrace: fakeStackTrace }); } diff --git a/test/mocks/debugProtocolMocks.ts b/test/mocks/debugProtocolMocks.ts index e3e3769ad..8ee877941 100644 --- a/test/mocks/debugProtocolMocks.ts +++ b/test/mocks/debugProtocolMocks.ts @@ -48,7 +48,7 @@ function getRuntimeStubs(mockEventEmitter) { evaluate() { }, onConsoleAPICalled(handler) { mockEventEmitter.on('Runtime.consoleAPICalled', handler); }, - onExceptionThrown(handler) { mockEventEmitter.on('Runtime.onExceptionThrown', handler); }, + onExceptionThrown(handler) { mockEventEmitter.on('Runtime.exceptionThrown', handler); }, onExecutionContextsCleared(handler) { mockEventEmitter.on('Runtime.executionContextsCleared', handler); } }; }