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
})

Check failure on line 84 in packages/pretty-format/src/plugins/DOMElementFilter.ts

View workflow job for this annotation

GitHub Actions / Lint: node-latest, ubuntu-latest

Trailing spaces not allowed
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot remove trailing spaces in the file and don't forget to run linter before commiting

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed trailing spaces from lines 84 and 89, and ran linter in commit 89b3b50.

// Apply additional user-provided filter if specified
if (filterNode) {
filtered = filtered.filter(filterNode)
}

Check failure on line 89 in packages/pretty-format/src/plugins/DOMElementFilter.ts

View workflow job for this annotation

GitHub Actions / Lint: node-latest, ubuntu-latest

Trailing spaces not allowed
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