diff --git a/package.json b/package.json index d43b3236..f0d8c0a5 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,7 @@ "chalk": "^4.1.0", "dom-accessibility-api": "^0.5.6", "lz-string": "^1.4.4", - "pretty-format": "^26.6.2" + "pretty-format": "^27.0.2" }, "devDependencies": { "@testing-library/jest-dom": "^5.11.6", @@ -63,6 +63,7 @@ "plugin:import/typescript" ], "rules": { + "@typescript-eslint/prefer-includes": "off", "import/prefer-default-export": "off", "import/no-unassigned-import": "off", "import/no-useless-path-segments": "off", diff --git a/src/DOMElementFilter.ts b/src/DOMElementFilter.ts new file mode 100644 index 00000000..bf5ff686 --- /dev/null +++ b/src/DOMElementFilter.ts @@ -0,0 +1,261 @@ +/** + * Source: https://github.com/facebook/jest/blob/e7bb6a1e26ffab90611b2593912df15b69315611/packages/pretty-format/src/plugins/DOMElement.ts + */ +/* eslint-disable -- trying to stay as close to the original as possible */ +/* istanbul ignore file */ +import type {Config, NewPlugin, Printer, Refs} from 'pretty-format' + +function escapeHTML(str: string): string { + return str.replace(//g, '>') +} +// Return empty string if keys is empty. +const printProps = ( + keys: Array, + props: Record, + config: Config, + indentation: string, + depth: number, + refs: Refs, + printer: Printer, +): string => { + const indentationNext = indentation + config.indent + const colors = config.colors + return keys + .map(key => { + const value = props[key] + let printed = printer(value, config, indentationNext, depth, refs) + + if (typeof value !== 'string') { + if (printed.indexOf('\n') !== -1) { + printed = + config.spacingOuter + + indentationNext + + printed + + config.spacingOuter + + indentation + } + printed = '{' + printed + '}' + } + + return ( + config.spacingInner + + indentation + + colors.prop.open + + key + + colors.prop.close + + '=' + + colors.value.open + + printed + + colors.value.close + ) + }) + .join('') +} + +// https://developer.mozilla.org/en-US/docs/Web/API/Node/nodeType#node_type_constants +const NodeTypeTextNode = 3 + +// Return empty string if children is empty. +const printChildren = ( + children: Array, + config: Config, + indentation: string, + depth: number, + refs: Refs, + printer: Printer, +): string => + children + .map(child => { + const printedChild = + typeof child === 'string' + ? printText(child, config) + : printer(child, config, indentation, depth, refs) + + if ( + printedChild === '' && + typeof child === 'object' && + child !== null && + (child as Node).nodeType !== NodeTypeTextNode + ) { + // A plugin serialized this Node to '' meaning we should ignore it. + return '' + } + return config.spacingOuter + indentation + printedChild + }) + .join('') + +const printText = (text: string, config: Config): string => { + const contentColor = config.colors.content + return contentColor.open + escapeHTML(text) + contentColor.close +} + +const printComment = (comment: string, config: Config): string => { + const commentColor = config.colors.comment + return ( + commentColor.open + + '' + + commentColor.close + ) +} + +// Separate the functions to format props, children, and element, +// so a plugin could override a particular function, if needed. +// Too bad, so sad: the traditional (but unnecessary) space +// in a self-closing tagColor requires a second test of printedProps. +const printElement = ( + type: string, + printedProps: string, + printedChildren: string, + config: Config, + indentation: string, +): string => { + const tagColor = config.colors.tag + return ( + tagColor.open + + '<' + + type + + (printedProps && + tagColor.close + + printedProps + + config.spacingOuter + + indentation + + tagColor.open) + + (printedChildren + ? '>' + + tagColor.close + + printedChildren + + config.spacingOuter + + indentation + + tagColor.open + + '' + + tagColor.close + ) +} + +const printElementAsLeaf = (type: string, config: Config): string => { + const tagColor = config.colors.tag + return ( + tagColor.open + + '<' + + type + + tagColor.close + + ' …' + + tagColor.open + + ' />' + + tagColor.close + ) +} + +const ELEMENT_NODE = 1 +const TEXT_NODE = 3 +const COMMENT_NODE = 8 +const FRAGMENT_NODE = 11 + +const ELEMENT_REGEXP = /^((HTML|SVG)\w*)?Element$/ + +const testNode = (val: any) => { + const constructorName = val.constructor.name + const {nodeType, tagName} = val + const isCustomElement = + (typeof tagName === 'string' && tagName.includes('-')) || + (typeof val.hasAttribute === 'function' && val.hasAttribute('is')) + + return ( + (nodeType === ELEMENT_NODE && + (ELEMENT_REGEXP.test(constructorName) || isCustomElement)) || + (nodeType === TEXT_NODE && constructorName === 'Text') || + (nodeType === COMMENT_NODE && constructorName === 'Comment') || + (nodeType === FRAGMENT_NODE && constructorName === 'DocumentFragment') + ) +} + +export const test: NewPlugin['test'] = (val: any) => + val?.constructor?.name && testNode(val) + +type HandledType = Element | Text | Comment | DocumentFragment + +function nodeIsText(node: HandledType): node is Text { + return node.nodeType === TEXT_NODE +} + +function nodeIsComment(node: HandledType): node is Comment { + return node.nodeType === COMMENT_NODE +} + +function nodeIsFragment(node: HandledType): node is DocumentFragment { + return node.nodeType === FRAGMENT_NODE +} + +export default function createDOMElementFilter( + filterNode: (node: Node) => boolean, +): NewPlugin { + return { + test: (val: any) => val?.constructor?.name && testNode(val), + serialize: ( + node: HandledType, + config: Config, + indentation: string, + depth: number, + refs: Refs, + printer: Printer, + ) => { + if (nodeIsText(node)) { + return printText(node.data, config) + } + + if (nodeIsComment(node)) { + return printComment(node.data, config) + } + + const type = nodeIsFragment(node) + ? `DocumentFragment` + : node.tagName.toLowerCase() + + if (++depth > config.maxDepth) { + return printElementAsLeaf(type, config) + } + + return printElement( + type, + printProps( + nodeIsFragment(node) + ? [] + : Array.from(node.attributes) + .map(attr => attr.name) + .sort(), + nodeIsFragment(node) + ? {} + : Array.from(node.attributes).reduce>( + (props, attribute) => { + props[attribute.name] = attribute.value + return props + }, + {}, + ), + config, + indentation + config.indent, + depth, + refs, + printer, + ), + printChildren( + Array.prototype.slice + .call(node.childNodes || node.children) + .filter(filterNode), + config, + indentation + config.indent, + depth, + refs, + printer, + ), + config, + indentation, + ) + }, + } +} diff --git a/src/__tests__/element-queries.js b/src/__tests__/element-queries.js index bb9d2fbf..8564ddff 100644 --- a/src/__tests__/element-queries.js +++ b/src/__tests__/element-queries.js @@ -32,11 +32,14 @@ test('get throws a useful error message', () => { getByAltText, getByTitle, getByRole, - } = render('
') + } = render( + `

Hello, Dave

', + ) + + expect(prettyDOM(container)).toMatchInlineSnapshot(` + " +

+ Hello, Dave +

+ " + `) +}) + +test('prettyDOM can include all elements with a custom filter', () => { + const {container} = renderIntoDocument( + '

Hello, Dave

', + ) + + expect( + prettyDOM(container, Number.POSITIVE_INFINITY, {filterNode: () => true}), + ).toMatchInlineSnapshot(` + " +