diff --git a/package.json b/package.json index caeb3bdd..70d3c7f6 100644 --- a/package.json +++ b/package.json @@ -38,11 +38,13 @@ "types" ], "dependencies": { + "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.10.3", "@types/aria-query": "^4.2.0", "aria-query": "^4.2.2", "dom-accessibility-api": "^0.5.1", - "pretty-format": "^26.4.2" + "pretty-format": "^26.4.2", + "chalk": "^4.1.0" }, "devDependencies": { "@testing-library/jest-dom": "^5.10.1", @@ -76,4 +78,4 @@ "url": "https://github.com/testing-library/dom-testing-library/issues" }, "homepage": "https://github.com/testing-library/dom-testing-library#readme" -} +} \ No newline at end of file diff --git a/src/__tests__/get-user-code-frame.js b/src/__tests__/get-user-code-frame.js new file mode 100644 index 00000000..3b72fe10 --- /dev/null +++ b/src/__tests__/get-user-code-frame.js @@ -0,0 +1,82 @@ +import fs from 'fs' +import {getUserCodeFrame} from '../get-user-code-frame' + +jest.mock('fs', () => ({ + // We setup the contents of a sample file + readFileSync: jest.fn( + () => ` + import {screen} from '@testing-library/dom' + it('renders', () => { + document.body.appendChild( + document.createTextNode('Hello world') + ) + screen.debug() + expect(screen.getByText('Hello world')).toBeInTheDocument() + }) + `, + ), +})) + +const userStackFrame = 'at somethingWrong (/sample-error/error-example.js:7:14)' + +let globalErrorMock + +beforeEach(() => { + // Mock global.Error so we can setup our own stack messages + globalErrorMock = jest.spyOn(global, 'Error') +}) + +afterEach(() => { + global.Error.mockRestore() +}) + +test('it returns only user code frame when code frames from node_modules are first', () => { + const stack = `Error: Kaboom + at Object. (/sample-error/node_modules/@es2050/console/build/index.js:4:10) + ${userStackFrame} + ` + globalErrorMock.mockImplementationOnce(() => ({stack})) + const userTrace = getUserCodeFrame(stack) + + expect(userTrace).toMatchInlineSnapshot(` + "/sample-error/error-example.js:7:14 + 5 | document.createTextNode('Hello world') + 6 | ) + > 7 | screen.debug() + | ^ + " + `) +}) + +test('it returns only user code frame when node code frames are present afterwards', () => { + const stack = `Error: Kaboom + at Object. (/sample-error/node_modules/@es2050/console/build/index.js:4:10) + ${userStackFrame} + at Object. (/sample-error/error-example.js:14:1) + at internal/main/run_main_module.js:17:47 + ` + globalErrorMock.mockImplementationOnce(() => ({stack})) + const userTrace = getUserCodeFrame() + + expect(userTrace).toMatchInlineSnapshot(` + "/sample-error/error-example.js:7:14 + 5 | document.createTextNode('Hello world') + 6 | ) + > 7 | screen.debug() + | ^ + " + `) +}) + +test("it returns empty string if file from code frame can't be read", () => { + // Make fire read purposely fail + fs.readFileSync.mockImplementationOnce(() => { + throw Error() + }) + const stack = `Error: Kaboom + ${userStackFrame} + ` + globalErrorMock.mockImplementationOnce(() => ({stack})) + + expect(getUserCodeFrame(stack)).toEqual('') +}) diff --git a/src/__tests__/pretty-dom.js b/src/__tests__/pretty-dom.js index fd297888..1d1ce79b 100644 --- a/src/__tests__/pretty-dom.js +++ b/src/__tests__/pretty-dom.js @@ -1,6 +1,9 @@ import {prettyDOM, logDOM} from '../pretty-dom' +import {getUserCodeFrame} from '../get-user-code-frame' import {render, renderIntoDocument} from './helpers/test-utils' +jest.mock('../get-user-code-frame') + beforeEach(() => { jest.spyOn(console, 'log').mockImplementation(() => {}) }) @@ -60,6 +63,36 @@ test('logDOM logs prettyDOM to the console', () => { `) }) +test('logDOM logs prettyDOM with code frame to the console', () => { + getUserCodeFrame.mockImplementationOnce( + () => `"/home/john/projects/sample-error/error-example.js:7:14 + 5 | document.createTextNode('Hello World!') + 6 | ) + > 7 | screen.debug() + | ^ + " + `, + ) + const {container} = render('
Hello World!
') + logDOM(container) + expect(console.log).toHaveBeenCalledTimes(1) + expect(console.log.mock.calls[0][0]).toMatchInlineSnapshot(` + "
+
+ Hello World! +
+
+ + "/home/john/projects/sample-error/error-example.js:7:14 + 5 | document.createTextNode('Hello World!') + 6 | ) + > 7 | screen.debug() + | ^ + " + " + `) +}) + describe('prettyDOM fails with first parameter without outerHTML field', () => { test('with array', () => { expect(() => prettyDOM(['outerHTML'])).toThrowErrorMatchingInlineSnapshot( diff --git a/src/__tests__/screen.js b/src/__tests__/screen.js index 3a35bde8..2496e09f 100644 --- a/src/__tests__/screen.js +++ b/src/__tests__/screen.js @@ -1,6 +1,11 @@ import {screen} from '..' import {renderIntoDocument} from './helpers/test-utils' +// Since screen.debug internally calls getUserCodeFrame, we mock it so it doesn't affect these tests +jest.mock('../get-user-code-frame', () => ({ + getUserCodeFrame: () => '', +})) + beforeEach(() => { jest.spyOn(console, 'log').mockImplementation(() => {}) }) diff --git a/src/get-user-code-frame.js b/src/get-user-code-frame.js new file mode 100644 index 00000000..7cdb90b6 --- /dev/null +++ b/src/get-user-code-frame.js @@ -0,0 +1,65 @@ +// We try to load node dependencies +let chalk = null +let readFileSync = null +let codeFrameColumns = null + +try { + const nodeRequire = module && module.require + + readFileSync = nodeRequire.call(module, 'fs').readFileSync + codeFrameColumns = nodeRequire.call(module, '@babel/code-frame') + .codeFrameColumns + chalk = nodeRequire.call(module, 'chalk') +} catch { + // We're in a browser environment +} + +// frame has the form "at myMethod (location/to/my/file.js:10:2)" +function getCodeFrame(frame) { + const locationStart = frame.indexOf('(') + 1 + const locationEnd = frame.indexOf(')') + const frameLocation = frame.slice(locationStart, locationEnd) + + const frameLocationElements = frameLocation.split(':') + const [filename, line, column] = [ + frameLocationElements[0], + parseInt(frameLocationElements[1], 10), + parseInt(frameLocationElements[2], 10), + ] + + let rawFileContents = '' + try { + rawFileContents = readFileSync(filename, 'utf-8') + } catch { + return '' + } + + const codeFrame = codeFrameColumns( + rawFileContents, + { + start: {line, column}, + }, + { + highlightCode: true, + linesBelow: 0, + }, + ) + return `${chalk.dim(frameLocation)}\n${codeFrame}\n` +} + +function getUserCodeFrame() { + // If we couldn't load dependencies, we can't generate the user trace + /* istanbul ignore next */ + if (!readFileSync || !codeFrameColumns) { + return '' + } + const err = new Error() + const firstClientCodeFrame = err.stack + .split('\n') + .slice(1) // Remove first line which has the form "Error: TypeError" + .find(frame => !frame.includes('node_modules/')) // Ignore frames from 3rd party libraries + + return getCodeFrame(firstClientCodeFrame) +} + +export {getUserCodeFrame} diff --git a/src/pretty-dom.js b/src/pretty-dom.js index 56e8e90d..eed80e7b 100644 --- a/src/pretty-dom.js +++ b/src/pretty-dom.js @@ -1,4 +1,5 @@ import prettyFormat from 'pretty-format' +import {getUserCodeFrame} from './get-user-code-frame' import {getDocument} from './helpers' function inCypress(dom) { @@ -61,6 +62,13 @@ function prettyDOM(dom, maxLength, options) { : debugContent } -const logDOM = (...args) => console.log(prettyDOM(...args)) +const logDOM = (...args) => { + const userCodeFrame = getUserCodeFrame() + if (userCodeFrame) { + console.log(`${prettyDOM(...args)}\n\n${userCodeFrame}`) + } else { + console.log(prettyDOM(...args)) + } +} export {prettyDOM, logDOM}