Skip to content
68 changes: 68 additions & 0 deletions docs/api/browser/context.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,3 +231,71 @@ export const utils: {
getElementError(selector: string, container?: Element): Error
}
```

### configurePrettyDOM <Version>4.0.0</Version> {#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 <Version>4.1.0</Version> {#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.
:::
32 changes: 17 additions & 15 deletions packages/pretty-format/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
167 changes: 167 additions & 0 deletions packages/pretty-format/src/plugins/DOMElementFilter.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, string>>(
(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
50 changes: 46 additions & 4 deletions packages/utils/src/display.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { PrettyFormatOptions } from '@vitest/pretty-format'
import {
createDOMElementFilter,
format as prettyFormat,
plugins as prettyFormatPlugins,
} from '@vitest/pretty-format'
Expand Down Expand Up @@ -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,
})
}
Expand All @@ -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 {
Expand Down
Loading
Loading