Skip to content

Commit

Permalink
Improve html printing (#314)
Browse files Browse the repository at this point in the history
Co-authored-by: Gerardo Rodriguez <gerardo@cloudfour.com>
  • Loading branch information
calebeby and gerardo-rodriguez authored Dec 6, 2021
1 parent 5cc28a4 commit 542f3f9
Show file tree
Hide file tree
Showing 12 changed files with 174 additions and 39 deletions.
8 changes: 8 additions & 0 deletions .changeset/calm-crews-carry.md
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: ['<rootDir>/node_modules/(?!ansi-regex)'],
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
};
8 changes: 8 additions & 0 deletions jest.setup.ts
Original file line number Diff line number Diff line change
@@ -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';
3 changes: 2 additions & 1 deletion src/extend-expect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
isElementHandle,
isPromise,
jsHandleToArray,
printColorsInErrorMessages,
removeFuncFromStackTrace,
} from './utils';

Expand Down Expand Up @@ -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('')
Expand Down
8 changes: 6 additions & 2 deletions src/pptr-testing-library.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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('')
Expand Down
68 changes: 56 additions & 12 deletions src/serialize/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,34 +26,78 @@ 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(`"<div />"`);
expect(printElement(outerEl, false)).toMatchInlineSnapshot(`"<div />"`);
});
it('formats an element with a single text node', () => {
const outerEl = document.createElement('div');
outerEl.innerHTML = 'asdf';
expect(printElement(outerEl)).toMatchInlineSnapshot(`"<div>asdf</div>"`);
expect(printElement(outerEl, false)).toMatchInlineSnapshot(
`"<div>asdf</div>"`,
);
});
it('formats an element with multiple text nodes', () => {
const outerEl = document.createElement('div');
outerEl.append(
document.createTextNode('first'),
document.createTextNode('second'),
);
expect(printElement(outerEl)).toMatchInlineSnapshot(`
"<div>
expect(printElement(outerEl, false)).toMatchInlineSnapshot(
`"<div>firstsecond</div>"`,
);
});
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(
`"<div>first second third </div>"`,
);
outerEl.style.whiteSpace = 'pre';
expect(printElement(outerEl, false)).toMatchInlineSnapshot(`
"<div style=\\"white-space: pre;\\">
first
second
second third
</div>"
`);
outerEl.style.whiteSpace = 'pre-line';
expect(printElement(outerEl, false)).toMatchInlineSnapshot(`
"<div style=\\"white-space: pre-line;\\">
first
second third
</div>"
`);
});
it('Removes whitespace-only text nodes when printing elements across multiple lines', () => {
const outerEl = document.createElement('div');
outerEl.innerHTML = `
<h1> Hi </h1>
<h2>Hi </h2>
`;
expect(printElement(outerEl, false)).toMatchInlineSnapshot(`
"<div>
<h1> Hi </h1>
<h2>Hi </h2>
</div>"
`);
});
it('formats an element with nested children', () => {
const outerEl = document.createElement('div');
outerEl.innerHTML = '<strong><a>Hi</a></strong>';
expect(printElement(outerEl)).toMatchInlineSnapshot(`
expect(printElement(outerEl, false)).toMatchInlineSnapshot(`
"<div>
<strong>
<a>Hi</a>
Expand All @@ -64,7 +108,7 @@ describe('printElement', () => {
it('formats self-closing element', () => {
const outerEl = document.createElement('div');
outerEl.innerHTML = '<input><img>';
expect(printElement(outerEl)).toMatchInlineSnapshot(`
expect(printElement(outerEl, false)).toMatchInlineSnapshot(`
"<div>
<input />
<img />
Expand All @@ -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(
`"<div data-asdf=\\"foo\\" />"`,
);
});
Expand All @@ -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(`
"<div
data-asdf=\\"foo\\"
class=\\"class\\"
Expand All @@ -97,7 +141,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(`
"<div
data-asdf=\\"foo\\"
class=\\"class\\"
Expand All @@ -111,7 +155,7 @@ describe('printElement', () => {
const outerEl = document.createElement('input');
outerEl.setAttribute('required', '');
outerEl.setAttribute('value', '');
expect(printElement(outerEl)).toMatchInlineSnapshot(
expect(printElement(outerEl, false)).toMatchInlineSnapshot(
`"<input required value=\\"\\" />"`,
);
});
Expand Down
96 changes: 78 additions & 18 deletions src/serialize/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import * as colors from 'kolorist';
interface Handler<T, Serialized extends unknown> {
name: string;
toObj(input: T): Serialized;
Expand Down Expand Up @@ -66,46 +67,105 @@ 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) => {
if (
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}</${tagName}>`}`;
selfClosing
? highlight.bracket(`${splitAttrs ? '\n' : ' '}/`)
: splitAttrs
? '\n'
: ''
}${highlight.bracket('>')}${
selfClosing
? ''
: `${contents}${highlight.bracket('</')}${highlight.tagName(
tagName,
)}${highlight.bracket('>')}`
}`;
};
3 changes: 2 additions & 1 deletion src/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { createClientRuntimeServer } from './module-server/client-runtime-server
import {
assertElementHandle,
jsHandleToArray,
printColorsInErrorMessages,
removeFuncFromStackTrace,
} from './utils';

Expand Down Expand Up @@ -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('')
Expand Down
10 changes: 10 additions & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
@@ -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();
Expand Down Expand Up @@ -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';
4 changes: 0 additions & 4 deletions tests/jest-dom-matchers/toBeEmptyDOMElement.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,7 @@ test(
Received:
<div data-testid=\\"notempty\\">
<div data-testid=\\"empty\\" />
</div>"
`);
}),
Expand Down
3 changes: 2 additions & 1 deletion tests/user/actionability.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <Args extends any[], Return extends unknown>(
fn: (userUtil: typeof import('../../src/user-util'), ...args: Args) => Return,
Expand All @@ -23,7 +24,7 @@ const runWithUtils = async <Args extends any[], Return extends unknown>(
const msgWithStringEls = msgWithLiveEls
.map(el => {
if (el instanceof Element || el instanceof Document)
return utils.printElement(el)
return utils.printElement(el, ${printColorsInErrorMessages})
return el
})
.join('')
Expand Down
1 change: 1 addition & 0 deletions tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"jsxFactory": "h"
},
"include": [
"*.ts",
"src/**/*.ts",
"tests/**/*.ts",
"tests/**/*.tsx",
Expand Down

0 comments on commit 542f3f9

Please sign in to comment.