diff --git a/docs/api/browser/context.md b/docs/api/browser/context.md index b16d098ed869..c21120246236 100644 --- a/docs/api/browser/context.md +++ b/docs/api/browser/context.md @@ -231,3 +231,71 @@ export const utils: { getElementError(selector: string, container?: Element): Error } ``` + +### configurePrettyDOM 4.0.0 {#configureprettydom} + +The `configurePrettyDOM` function allows you to configure default options for the `prettyDOM` and `debug` functions. This is useful for customizing how HTML is formatted in test failure messages. + +```ts +import { utils } from 'vitest/browser' + +utils.configurePrettyDOM({ + maxDepth: 3, + filterNode: 'script, style, [data-test-hide]' +}) +``` + +#### Options + +- **`maxDepth`** - Maximum depth to print nested elements (default: `Infinity`) +- **`maxLength`** - Maximum length of the output string (default: `7000`) +- **`filterNode`** - A CSS selector string or function to filter out nodes from the output. When a string is provided, elements matching the selector will be excluded. When a function is provided, it should return `false` to exclude a node. +- **`highlight`** - Enable syntax highlighting (default: `true`) +- And other options from [`pretty-format`](https://www.npmjs.com/package/@vitest/pretty-format) + +#### Filtering with CSS Selectors 4.1.0 {#filtering-with-css-selectors} + +The `filterNode` option allows you to hide irrelevant markup (like scripts, styles, or hidden elements) from test failure messages, making it easier to identify the actual cause of failures. + +```ts +import { utils } from 'vitest/browser' + +// Filter out common noise elements +utils.configurePrettyDOM({ + filterNode: 'script, style, [data-test-hide]' +}) + +// Or use directly with prettyDOM +const html = utils.prettyDOM(element, undefined, { + filterNode: 'script, style' +}) +``` + +**Common Patterns:** + +Filter out scripts and styles: +```ts +utils.configurePrettyDOM({ filterNode: 'script, style' }) +``` + +Hide specific elements with data attributes: +```ts +utils.configurePrettyDOM({ filterNode: '[data-test-hide]' }) +``` + +Hide nested content within an element: +```ts +// Hides all children of elements with data-test-hide-content +utils.configurePrettyDOM({ filterNode: '[data-test-hide-content] *' }) +``` + +Combine multiple selectors: +```ts +utils.configurePrettyDOM({ + filterNode: 'script, style, [data-test-hide], svg' +}) +``` + +::: tip +This feature is inspired by Testing Library's [`defaultIgnore`](https://testing-library.com/docs/dom-testing-library/api-configuration/#defaultignore) configuration. +::: diff --git a/packages/pretty-format/src/index.ts b/packages/pretty-format/src/index.ts index e96cba860f28..c1936504d11f 100644 --- a/packages/pretty-format/src/index.ts +++ b/packages/pretty-format/src/index.ts @@ -30,21 +30,7 @@ import Immutable from './plugins/Immutable' import ReactElement from './plugins/ReactElement' import ReactTestComponent from './plugins/ReactTestComponent' -export type { - Colors, - CompareKeys, - Config, - NewPlugin, - OldPlugin, - Options, - OptionsReceived, - Plugin, - Plugins, - PrettyFormatOptions, - Printer, - Refs, - Theme, -} from './types' +export { createDOMElementFilter } from './plugins/DOMElementFilter' const toString = Object.prototype.toString const toISOString = Date.prototype.toISOString @@ -559,6 +545,22 @@ export function format(val: unknown, options?: OptionsReceived): string { return printComplexValue(val, getConfig(options), '', 0, []) } +export type { + Colors, + CompareKeys, + Config, + NewPlugin, + OldPlugin, + Options, + OptionsReceived, + Plugin, + Plugins, + PrettyFormatOptions, + Printer, + Refs, + Theme, +} from './types' + export const plugins: { AsymmetricMatcher: NewPlugin DOMCollection: NewPlugin diff --git a/packages/pretty-format/src/plugins/DOMElementFilter.ts b/packages/pretty-format/src/plugins/DOMElementFilter.ts new file mode 100644 index 000000000000..7123aee2838e --- /dev/null +++ b/packages/pretty-format/src/plugins/DOMElementFilter.ts @@ -0,0 +1,167 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import type { Config, NewPlugin, Printer, Refs } from '../types' +import { + printChildren, + printComment, + printElement, + printElementAsLeaf, + printProps, + printShadowRoot, + printText, +} from './lib/markup' + +const ELEMENT_NODE = 1 +const TEXT_NODE = 3 +const COMMENT_NODE = 8 +const FRAGMENT_NODE = 11 + +const ELEMENT_REGEXP = /^(?:(?:HTML|SVG)\w*)?Element$/ + +function testHasAttribute(val: any) { + try { + return typeof val.hasAttribute === 'function' && val.hasAttribute('is') + } + catch { + return false + } +} + +function testNode(val: any) { + const constructorName = val.constructor.name + const { nodeType, tagName } = val + const isCustomElement + = (typeof tagName === 'string' && tagName.includes('-')) + || testHasAttribute(val) + + 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 interface FilterConfig extends Config { + filterNode?: (node: any) => boolean +} + +function filterChildren(children: any[], filterNode?: (node: any) => boolean): any[] { + // Filter out text nodes that only contain whitespace to prevent empty lines + // This is done regardless of whether a filterNode is provided + let filtered = children.filter((node) => { + // Filter out text nodes that are only whitespace + if (node.nodeType === TEXT_NODE) { + const text = node.data || '' + // Keep text nodes that have non-whitespace content + return text.trim().length > 0 + } + return true + }) + + // Apply additional user-provided filter if specified + if (filterNode) { + filtered = filtered.filter(filterNode) + } + + return filtered +} + +export function createDOMElementFilter(filterNode?: (node: any) => boolean): NewPlugin { + return { + test, + 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) + } + + const children = Array.prototype.slice.call(node.childNodes || node.children) + const filteredChildren = filterChildren(children, filterNode) + + const shadowChildren = (nodeIsFragment(node) || !node.shadowRoot) + ? [] + : Array.prototype.slice.call(node.shadowRoot.children) + const filteredShadowChildren = filterChildren(shadowChildren, filterNode) + + return printElement( + type, + printProps( + nodeIsFragment(node) + ? [] + : Array.from(node.attributes, attr => attr.name).sort(), + nodeIsFragment(node) + ? {} + : [...node.attributes].reduce>( + (props, attribute) => { + props[attribute.name] = attribute.value + return props + }, + {}, + ), + config, + indentation + config.indent, + depth, + refs, + printer, + ), + (filteredShadowChildren.length > 0 + ? printShadowRoot(filteredShadowChildren, config, indentation + config.indent, depth, refs, printer) + : '') + + printChildren( + filteredChildren, + config, + indentation + config.indent, + depth, + refs, + printer, + ), + config, + indentation, + ) + }, + } +} + +export default createDOMElementFilter diff --git a/packages/utils/src/display.ts b/packages/utils/src/display.ts index 0a47fb88f727..7c1aebc9da02 100644 --- a/packages/utils/src/display.ts +++ b/packages/utils/src/display.ts @@ -1,5 +1,6 @@ import type { PrettyFormatOptions } from '@vitest/pretty-format' import { + createDOMElementFilter, format as prettyFormat, plugins as prettyFormatPlugins, } from '@vitest/pretty-format' @@ -42,22 +43,39 @@ const PLUGINS = [ export interface StringifyOptions extends PrettyFormatOptions { maxLength?: number + filterNode?: string | ((node: any) => boolean) } export function stringify( object: unknown, maxDepth = 10, - { maxLength, ...options }: StringifyOptions = {}, + { maxLength, filterNode, ...options }: StringifyOptions = {}, ): string { const MAX_LENGTH = maxLength ?? 10000 let result + // Convert string selector to filter function + const filterFn = typeof filterNode === 'string' + ? createNodeFilterFromSelector(filterNode) + : filterNode + + const plugins = filterFn + ? [ + ReactTestComponent, + ReactElement, + createDOMElementFilter(filterFn), + DOMCollection, + Immutable, + AsymmetricMatcher, + ] + : PLUGINS + try { result = prettyFormat(object, { maxDepth, escapeString: false, // min: true, - plugins: PLUGINS, + plugins, ...options, }) } @@ -67,17 +85,41 @@ export function stringify( maxDepth, escapeString: false, // min: true, - plugins: PLUGINS, + plugins, ...options, }) } // Prevents infinite loop https://github.com/vitest-dev/vitest/issues/7249 return result.length >= MAX_LENGTH && maxDepth > 1 - ? stringify(object, Math.floor(Math.min(maxDepth, Number.MAX_SAFE_INTEGER) / 2), { maxLength, ...options }) + ? stringify(object, Math.floor(Math.min(maxDepth, Number.MAX_SAFE_INTEGER) / 2), { maxLength, filterNode, ...options }) : result } +function createNodeFilterFromSelector(selector: string): (node: any) => boolean { + const ELEMENT_NODE = 1 + const COMMENT_NODE = 8 + + return (node: any) => { + // Filter out comments + if (node.nodeType === COMMENT_NODE) { + return false + } + + // Filter out elements matching the selector + if (node.nodeType === ELEMENT_NODE && node.matches) { + try { + return !node.matches(selector) + } + catch { + return true + } + } + + return true + } +} + export const formatRegExp: RegExp = /%[sdjifoOc%]/g interface FormatOptions { diff --git a/test/browser/test/utils.test.ts b/test/browser/test/utils.test.ts index 5d97ae4952cd..f0ca2a356091 100644 --- a/test/browser/test/utils.test.ts +++ b/test/browser/test/utils.test.ts @@ -1,4 +1,4 @@ -import { afterEach, expect, it, test } from 'vitest' +import { afterEach, beforeEach, expect, it, test } from 'vitest' import { commands, utils } from 'vitest/browser' import { inspect } from 'vitest/internal/browser' @@ -7,6 +7,10 @@ afterEach(() => { document.body.innerHTML = '' }) +beforeEach(() => { + utils.configurePrettyDOM({}) +}) + it('utils package correctly uses loupe', async () => { expect(inspect({ test: 1 })).toBe('{ test: 1 }') }) @@ -109,3 +113,96 @@ test('changing the defaults works', async () => { " `) }) + +test('filterNode option filters out matching elements', async () => { + const div = document.createElement('div') + div.innerHTML = ` +
+ + + hidden content + visible content +
+ ` + document.body.append(div) + + const result = await commands.stripVTControlCharacters(utils.prettyDOM(div, undefined, { filterNode: 'script, style, [data-test-hide]' })) + + expect(result).not.toContain('console.log') + expect(result).not.toContain('color: red') + expect(result).not.toContain('hidden content') + expect(result).toContain('visible content') + expect(result).toMatchInlineSnapshot(` + "
+
+ + visible content + +
+
" + `) +}) + +test('filterNode with configurePrettyDOM affects default behavior', async () => { + utils.configurePrettyDOM({ filterNode: 'script, style, [data-test-hide]' }) + + const div = document.createElement('div') + div.innerHTML = ` +
+ + + hidden content + visible content +
+ ` + document.body.append(div) + + const result = await commands.stripVTControlCharacters(utils.prettyDOM(div)) + + expect(result).not.toContain('console.log') + expect(result).not.toContain('color: red') + expect(result).not.toContain('hidden content') + expect(result).toContain('visible content') + expect(result).toMatchInlineSnapshot(` + "
+
+ + visible content + +
+
" + `) +}) + +test('filterNode with wildcard selector filters nested content', async () => { + const div = document.createElement('div') + div.innerHTML = ` +
+
+ nested hidden +
deeply nested hidden
+
+ visible +
+ ` + document.body.append(div) + + const result = await commands.stripVTControlCharacters(utils.prettyDOM(div, undefined, { filterNode: '[data-test-hide-content] *' })) + + expect(result).not.toContain('nested hidden') + expect(result).not.toContain('deeply nested hidden') + expect(result).toContain('visible') + expect(result).toContain('data-test-hide-content') + expect(result).toMatchInlineSnapshot(` + "
+
+
+ + visible + +
+
" + `) +})