diff --git a/.changeset/calm-crews-carry.md b/.changeset/calm-crews-carry.md new file mode 100644 index 00000000..713d9d37 --- /dev/null +++ b/.changeset/calm-crews-carry.md @@ -0,0 +1,8 @@ +--- +'pleasantest': minor +--- + +Improve printing of HTML elements in error messages + +- Printed HTML now is syntax-highlighted +- Adjacent whitespace is collapsed in places where the browser would collapse it diff --git a/jest.config.js b/jest.config.js index 5b852c55..e0f595db 100644 --- a/jest.config.js +++ b/jest.config.js @@ -15,4 +15,5 @@ module.exports = { // ansi-regex is ESM and since we are using Jest in CJS mode, // it must be transpiled to CJS transformIgnorePatterns: ['/node_modules/(?!ansi-regex)'], + setupFilesAfterEnv: ['/jest.setup.ts'], }; diff --git a/jest.setup.ts b/jest.setup.ts new file mode 100644 index 00000000..75a7caa1 --- /dev/null +++ b/jest.setup.ts @@ -0,0 +1,8 @@ +// The PLEASANTEST_TESTING_ITSELF environment variable (used in utils.ts) +// allows us to detect whether PT is running in the context of its own tests +// We use it to disable syntax-highlighting in printed HTML in error messages +// so that the snapshots are more readable. +// When PT is used outside of its own tests, the environment variable will not be set, +// so the error messages will have syntax-highlighted HTML +if (process.env.PLEASANTEST_TESTING_ITSELF === undefined) + process.env.PLEASANTEST_TESTING_ITSELF = 'true'; diff --git a/src/extend-expect.ts b/src/extend-expect.ts index 76f9d40c..61d3a265 100644 --- a/src/extend-expect.ts +++ b/src/extend-expect.ts @@ -7,6 +7,7 @@ import { isElementHandle, isPromise, jsHandleToArray, + printColorsInErrorMessages, removeFuncFromStackTrace, } from './utils'; @@ -145,7 +146,7 @@ Received ${this.utils.printReceived(arg)}`, const messageWithElementsRevived = reviveElementsInString(message) const messageWithElementsStringified = messageWithElementsRevived .map(el => { - if (el instanceof Element) return printElement(el) + if (el instanceof Element) return printElement(el, ${printColorsInErrorMessages}) return el }) .join('') diff --git a/src/pptr-testing-library.ts b/src/pptr-testing-library.ts index 1b8ad99b..03ceadd3 100644 --- a/src/pptr-testing-library.ts +++ b/src/pptr-testing-library.ts @@ -1,5 +1,9 @@ import type { queries } from '@testing-library/dom'; -import { jsHandleToArray, removeFuncFromStackTrace } from './utils'; +import { + jsHandleToArray, + printColorsInErrorMessages, + removeFuncFromStackTrace, +} from './utils'; import type { ElementHandle, JSHandle } from 'puppeteer'; import { createClientRuntimeServer } from './module-server/client-runtime-server'; import type { AsyncHookTracker } from './async-hooks'; @@ -146,7 +150,7 @@ export const getQueriesForElement = ( const messageWithElementsStringified = messageWithElementsRevived .map(el => { if (el instanceof Element || el instanceof Document) - return printElement(el) + return printElement(el, ${printColorsInErrorMessages}) return el }) .join('') diff --git a/src/serialize/index.test.ts b/src/serialize/index.test.ts index ed08c142..134f3d17 100644 --- a/src/serialize/index.test.ts +++ b/src/serialize/index.test.ts @@ -26,16 +26,18 @@ test('regexes', () => { describe('printElement', () => { it('formats a document correctly', () => { - expect(printElement(document)).toMatchInlineSnapshot(`"#document"`); + expect(printElement(document, false)).toMatchInlineSnapshot(`"#document"`); }); it('formats an empty element', () => { const outerEl = document.createElement('div'); - expect(printElement(outerEl)).toMatchInlineSnapshot(`"
"`); + expect(printElement(outerEl, false)).toMatchInlineSnapshot(`"
"`); }); it('formats an element with a single text node', () => { const outerEl = document.createElement('div'); outerEl.innerHTML = 'asdf'; - expect(printElement(outerEl)).toMatchInlineSnapshot(`"
asdf
"`); + expect(printElement(outerEl, false)).toMatchInlineSnapshot( + `"
asdf
"`, + ); }); it('formats an element with multiple text nodes', () => { const outerEl = document.createElement('div'); @@ -43,17 +45,59 @@ describe('printElement', () => { document.createTextNode('first'), document.createTextNode('second'), ); - expect(printElement(outerEl)).toMatchInlineSnapshot(` - "
+ expect(printElement(outerEl, false)).toMatchInlineSnapshot( + `"
firstsecond
"`, + ); + }); + it('formats consecutive whitespace as single space except when white-space is set in CSS', () => { + const outerEl = document.createElement('div'); + outerEl.append( + document.createTextNode('first\n\n '), + document.createTextNode('\n second third\n '), + ); + expect(printElement(outerEl, false)).toMatchInlineSnapshot( + `"
first second third
"`, + ); + outerEl.style.whiteSpace = 'pre'; + expect(printElement(outerEl, false)).toMatchInlineSnapshot(` + "
first - second + + + second third + +
" + `); + outerEl.style.whiteSpace = 'pre-line'; + expect(printElement(outerEl, false)).toMatchInlineSnapshot(` + "
+ first + + + second third + +
" + `); + }); + it('Removes whitespace-only text nodes when printing elements across multiple lines', () => { + const outerEl = document.createElement('div'); + outerEl.innerHTML = ` + +

Hi

+ +

Hi

+ `; + expect(printElement(outerEl, false)).toMatchInlineSnapshot(` + "
+

Hi

+

Hi

" `); }); it('formats an element with nested children', () => { const outerEl = document.createElement('div'); outerEl.innerHTML = 'Hi'; - expect(printElement(outerEl)).toMatchInlineSnapshot(` + expect(printElement(outerEl, false)).toMatchInlineSnapshot(` "
Hi @@ -64,7 +108,7 @@ describe('printElement', () => { it('formats self-closing element', () => { const outerEl = document.createElement('div'); outerEl.innerHTML = ''; - expect(printElement(outerEl)).toMatchInlineSnapshot(` + expect(printElement(outerEl, false)).toMatchInlineSnapshot(` "
@@ -74,7 +118,7 @@ describe('printElement', () => { it('formats attributes on one line', () => { const outerEl = document.createElement('div'); outerEl.dataset.asdf = 'foo'; - expect(printElement(outerEl)).toMatchInlineSnapshot( + expect(printElement(outerEl, false)).toMatchInlineSnapshot( `"
"`, ); }); @@ -83,7 +127,7 @@ describe('printElement', () => { outerEl.dataset.asdf = 'foo'; outerEl.setAttribute('class', 'class'); outerEl.setAttribute('style', 'background: green'); - expect(printElement(outerEl)).toMatchInlineSnapshot(` + expect(printElement(outerEl, false)).toMatchInlineSnapshot(` "
{ outerEl.dataset.asdf = 'foo'; outerEl.setAttribute('class', 'class'); outerEl.setAttribute('style', 'background: green'); - expect(printElement(outerEl)).toMatchInlineSnapshot(` + expect(printElement(outerEl, false)).toMatchInlineSnapshot(` "
{ const outerEl = document.createElement('input'); outerEl.setAttribute('required', ''); outerEl.setAttribute('value', ''); - expect(printElement(outerEl)).toMatchInlineSnapshot( + expect(printElement(outerEl, false)).toMatchInlineSnapshot( `""`, ); }); diff --git a/src/serialize/index.ts b/src/serialize/index.ts index e3084c12..914dad1d 100644 --- a/src/serialize/index.ts +++ b/src/serialize/index.ts @@ -1,3 +1,4 @@ +import * as colors from 'kolorist'; interface Handler { name: string; toObj(input: T): Serialized; @@ -66,35 +67,82 @@ export const deserialize = (input: string) => ); }); -export const printElement = (el: Element | Document, depth = 3) => { +const noColor = (input: string) => input; +const indent = (input: string) => ` ${input.split('\n').join('\n ')}`; + +export const printElement = ( + el: Element | Document, + printColors = true, + depth = 3, +) => { if (el instanceof Document) return '#document'; let contents = ''; const attrs = [...el.attributes]; const splitAttrs = attrs.length > 2; - if (depth > 0 && el.childNodes.length <= 3) { - const singleLine = - !splitAttrs && - (el.childNodes.length === 0 || - (el.childNodes.length === 1 && el.childNodes[0] instanceof Text)); - for (const child of el.childNodes) { + let needsMultipleLines = false; + if (depth > 0 && el.childNodes.length <= 5) { + const whiteSpaceSetting = getComputedStyle(el).whiteSpace; + const printedChildren: string[] = []; + let child = el.firstChild; + while (child) { if (child instanceof Element) { - contents += `\n ${printElement(child, depth - 1).replace( - /\n/g, - '\n ', - )}`; + needsMultipleLines = true; + printedChildren.push(printElement(child, printColors, depth - 1)); } else if (child instanceof Text) { - contents += `${singleLine ? '' : '\n '}${child.textContent}`; + // Merge consecutive text nodes together so their text can be collapsed + let consecutiveMergedText = child.textContent || ''; + while (child.nextSibling instanceof Text) { + // We are collecting the consecutive siblings' text here + // so we are also skipping those siblings from being used by the outer loop + child = child.nextSibling; + consecutiveMergedText += child.textContent || ''; + } + printedChildren.push( + whiteSpaceSetting === '' || + whiteSpaceSetting === 'normal' || + whiteSpaceSetting === 'nowrap' || + whiteSpaceSetting === 'pre-line' + ? consecutiveMergedText.replace( + // Pre-line should collapse whitespace _except_ newlines + whiteSpaceSetting === 'pre-line' ? /[^\S\n]+/g : /\s+/g, + ' ', + ) + : consecutiveMergedText, + ); } + child = child.nextSibling; } + if (!needsMultipleLines) + needsMultipleLines = + splitAttrs || printedChildren.some((c) => c.includes('\n')); - if (!singleLine) contents += '\n'; + contents += needsMultipleLines + ? `\n${printedChildren + .filter((c) => c.trim() !== '') + .map((c) => indent(c)) + .join('\n')}\n` + : printedChildren.join(''); } else { contents = '[...]'; } const tagName = el.tagName.toLowerCase(); const selfClosing = el.childNodes.length === 0; - return `<${tagName}${ + // We haver to tell kolorist to print the colors + // beacuse by default it won't since we are in the browser + // (the colored message gets sent to node to be printed) + colors.options.enabled = true; + colors.options.supportLevel = 1; + + // Syntax highlighting groups + const highlight = { + bracket: printColors ? colors.cyan : noColor, + tagName: printColors ? colors.red : noColor, + equals: printColors ? colors.cyan : noColor, + attribute: printColors ? colors.blue : noColor, + string: printColors ? colors.green : noColor, + }; + return `${highlight.bracket('<')}${highlight.tagName(tagName)}${ attrs.length === 0 ? '' : splitAttrs ? '\n ' : ' ' }${attrs .map((attr) => { @@ -102,10 +150,22 @@ export const printElement = (el: Element | Document, depth = 3) => { attr.value === '' && typeof el[attr.name as keyof Element] === 'boolean' ) - return attr.name; - return `${attr.name}="${attr.value}"`; + return highlight.attribute(attr.name); + return `${highlight.attribute(attr.name)}${highlight.equals( + '=', + )}${highlight.string(`"${attr.value}"`)}`; }) .join(splitAttrs ? '\n ' : ' ')}${ - selfClosing ? `${splitAttrs ? '\n' : ' '}/` : splitAttrs ? '\n' : '' - }>${selfClosing ? '' : `${contents}`}`; + selfClosing + ? highlight.bracket(`${splitAttrs ? '\n' : ' '}/`) + : splitAttrs + ? '\n' + : '' + }${highlight.bracket('>')}${ + selfClosing + ? '' + : `${contents}${highlight.bracket('')}` + }`; }; diff --git a/src/user.ts b/src/user.ts index fe57c9ac..a79b2eaf 100644 --- a/src/user.ts +++ b/src/user.ts @@ -4,6 +4,7 @@ import { createClientRuntimeServer } from './module-server/client-runtime-server import { assertElementHandle, jsHandleToArray, + printColorsInErrorMessages, removeFuncFromStackTrace, } from './utils'; @@ -79,7 +80,7 @@ export const pleasantestUser = async ( const msgWithStringEls = msgWithLiveEls .map(el => { if (el instanceof Element || el instanceof Document) - return utils.printElement(el) + return utils.printElement(el, ${printColorsInErrorMessages}) return el }) .join('') diff --git a/src/utils.ts b/src/utils.ts index ec7419e6..3bd159ea 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,4 +1,5 @@ import type { ElementHandle, JSHandle } from 'puppeteer'; +import * as kolorist from 'kolorist'; export const jsHandleToArray = async (arrayHandle: JSHandle) => { const properties = await arrayHandle.getProperties(); @@ -100,3 +101,12 @@ export const printStackLine = ( : `${path}:${line}:${column}`; return ` at ${location}`; }; + +export const printColorsInErrorMessages = + // The PLEASANTEST_TESTING_ITSELF environment variable (set in jest.setup.ts) + // allows us to detect whether PT is running in the context of its own tests + // We use it to disable syntax-highlighting in printed HTML in error messages + // so that the snapshots are more readable. + // When PT is used outside of its own tests, the environment variable will not be set, + // so the error messages will have syntax-highlighted HTML + kolorist.options.enabled && process.env.PLEASANTEST_TESTING_ITSELF !== 'true'; diff --git a/tests/jest-dom-matchers/toBeEmptyDOMElement.test.ts b/tests/jest-dom-matchers/toBeEmptyDOMElement.test.ts index e1c4e640..a20a8447 100644 --- a/tests/jest-dom-matchers/toBeEmptyDOMElement.test.ts +++ b/tests/jest-dom-matchers/toBeEmptyDOMElement.test.ts @@ -18,11 +18,7 @@ test( Received: 
- -
- -
" `); }), diff --git a/tests/user/actionability.test.ts b/tests/user/actionability.test.ts index 1e7a5ecf..730880d9 100644 --- a/tests/user/actionability.test.ts +++ b/tests/user/actionability.test.ts @@ -5,6 +5,7 @@ import { createClientRuntimeServer, } from '../../src/module-server/client-runtime-server'; import path from 'path'; +import { printColorsInErrorMessages } from '../../src/utils'; const runWithUtils = async ( fn: (userUtil: typeof import('../../src/user-util'), ...args: Args) => Return, @@ -23,7 +24,7 @@ const runWithUtils = async ( const msgWithStringEls = msgWithLiveEls .map(el => { if (el instanceof Element || el instanceof Document) - return utils.printElement(el) + return utils.printElement(el, ${printColorsInErrorMessages}) return el }) .join('') diff --git a/tsconfig.json b/tsconfig.json index 21f96a55..efe176cf 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -26,6 +26,7 @@ "jsxFactory": "h" }, "include": [ + "*.ts", "src/**/*.ts", "tests/**/*.ts", "tests/**/*.tsx",