Skip to content
19 changes: 19 additions & 0 deletions packages/browser/context.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -869,6 +869,25 @@ export const utils: {
* @experimental
*/
configurePrettyDOM(options: StringifyOptions): void
/**
* Creates a filter function for prettyDOM that filters out nodes based on CSS selectors.
* This is similar to Testing Library's defaultIgnore configuration.
*
* @example
* ```ts
* import { utils } from 'vitest/browser'
*
* // Filter out script, style, and elements with data-test-hide attribute
* utils.configurePrettyDOM({
* filterNode: utils.createNodeFilter('script, style, [data-test-hide]')
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.

Could we just make it accept a string for simplicity? (instead of a function). Then we can delete createNodeFilter

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.

Changed to accept string directly in commit 7c66451. filterNode now accepts string | ((node: any) => boolean). String values are CSS selectors, and the internal createNodeFilterFromSelector function handles the conversion. Removed createNodeFilter from exports.

* })
* ```
*
* @param selector - CSS selector to filter out (e.g., 'script, style, [data-test-hide]')
* @returns A filter function that can be used with configurePrettyDOM
* @experimental
*/
createNodeFilter(selector: string): (node: any) => boolean
/**
* Creates "Cannot find element" error. Useful for custom locators.
*/
Expand Down
42 changes: 42 additions & 0 deletions packages/browser/src/client/tester/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -499,10 +499,52 @@ function configurePrettyDOM(options: StringifyOptions) {
defaultOptions = options
}

/**
* Creates a filter function for prettyDOM that filters out nodes based on CSS selectors.
* This is similar to Testing Library's defaultIgnore configuration.
*
* @example
* ```ts
* import { utils } from 'vitest/browser'
*
* // Filter out script, style, and elements with data-test-hide attribute
* utils.configurePrettyDOM({
* filterNode: utils.createNodeFilter('script, style, [data-test-hide]')
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.

Can we just make it accept a string for simplicity?

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.

The function already accepts a string selector. The return type is a filter function because configurePrettyDOM expects filterNode to be a function (node: any) => boolean. This design matches Testing Library's approach and provides flexibility for users who want to implement custom filtering logic beyond CSS selectors.

* })
* ```
*
* @param selector - CSS selector to filter out (e.g., 'script, style, [data-test-hide]')
* @returns A filter function that can be used with configurePrettyDOM
*/
function createNodeFilter(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 utils = {
getElementError,
prettyDOM,
debug,
getElementLocatorSelectors,
configurePrettyDOM,
createNodeFilter,
}
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
153 changes: 153 additions & 0 deletions packages/pretty-format/src/plugins/DOMElementFilter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
/**
* 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[] {
if (!filterNode) {
return children
}
return children.filter(filterNode)
}

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
21 changes: 17 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,34 @@ const PLUGINS = [

export interface StringifyOptions extends PrettyFormatOptions {
maxLength?: number
filterNode?: (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

const plugins = filterNode
? [
ReactTestComponent,
ReactElement,
createDOMElementFilter(filterNode),
DOMCollection,
Immutable,
AsymmetricMatcher,
]
: PLUGINS

try {
result = prettyFormat(object, {
maxDepth,
escapeString: false,
// min: true,
plugins: PLUGINS,
plugins,
...options,
})
}
Expand All @@ -67,14 +80,14 @@ 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
}

Expand Down
Loading