Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve html printing #314

Merged
merged 8 commits into from Dec 6, 2021
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
8 changes: 8 additions & 0 deletions .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
1 change: 1 addition & 0 deletions jest.config.js
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
@@ -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 ahve syntax-highlighted HTML
calebeby marked this conversation as resolved.
Show resolved Hide resolved
if (process.env.PLEASANTEST_TESTING_ITSELF === undefined)
process.env.PLEASANTEST_TESTING_ITSELF = 'true';
3 changes: 2 additions & 1 deletion src/extend-expect.ts
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
@@ -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
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
94 changes: 76 additions & 18 deletions src/serialize/index.ts
@@ -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,103 @@ 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
calebeby marked this conversation as resolved.
Show resolved Hide resolved
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 hi = {
calebeby marked this conversation as resolved.
Show resolved Hide resolved
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 `${hi.bracket('<')}${hi.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 hi.attribute(attr.name);
return `${hi.attribute(attr.name)}${hi.equals('=')}${hi.string(
`"${attr.value}"`,
)}`;
})
.join(splitAttrs ? '\n ' : ' ')}${
selfClosing ? `${splitAttrs ? '\n' : ' '}/` : splitAttrs ? '\n' : ''
}>${selfClosing ? '' : `${contents}</${tagName}>`}`;
selfClosing
? hi.bracket(`${splitAttrs ? '\n' : ' '}/`)
: splitAttrs
? '\n'
: ''
}${hi.bracket('>')}${
selfClosing
? ''
: `${contents}${hi.bracket('</')}${hi.tagName(tagName)}${hi.bracket('>')}`
}`;
};
3 changes: 2 additions & 1 deletion src/user.ts
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
@@ -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 ahve syntax-highlighted HTML
calebeby marked this conversation as resolved.
Show resolved Hide resolved
kolorist.options.enabled && process.env.PLEASANTEST_TESTING_ITSELF !== 'true';
4 changes: 0 additions & 4 deletions tests/jest-dom-matchers/toBeEmptyDOMElement.test.ts
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
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
Expand Up @@ -26,6 +26,7 @@
"jsxFactory": "h"
},
"include": [
"*.ts",
"src/**/*.ts",
"tests/**/*.ts",
"tests/**/*.tsx",
Expand Down