diff --git a/docs/api/expect.md b/docs/api/expect.md index f5322a8e52cd..4707574a2b85 100644 --- a/docs/api/expect.md +++ b/docs/api/expect.md @@ -422,7 +422,7 @@ test('structurally the same, but semantically different', () => { - **Type:** `(received: string) => Awaitable` -`toContain` asserts if the actual value is in an array. `toContain` can also check whether a string is a substring of another string. +`toContain` asserts if the actual value is in an array. `toContain` can also check whether a string is a substring of another string. Since Vitest 1.0, if you are running tests in a browser-like environment, this assertion can also check if class is contained in a `classList`, or an element is inside another one. ```ts import { expect, test } from 'vitest' @@ -430,6 +430,12 @@ import { getAllFruits } from './stocks.js' test('the fruit list contains orange', () => { expect(getAllFruits()).toContain('orange') + + const element = document.querySelector('#el') + // element has a class + expect(element.classList).toContain('flex') + // element is inside another one + expect(document.querySelector('#wrapper')).toContain(element) }) ``` diff --git a/packages/expect/src/jest-expect.ts b/packages/expect/src/jest-expect.ts index 58f6ed40edbe..0c8fa719863f 100644 --- a/packages/expect/src/jest-expect.ts +++ b/packages/expect/src/jest-expect.ts @@ -10,6 +10,15 @@ import { diff, stringify } from './jest-matcher-utils' import { JEST_MATCHERS_OBJECT } from './constants' import { recordAsyncExpect, wrapSoft } from './utils' +// polyfill globals because expect can be used in node environment +declare class Node { + contains(item: unknown): boolean +} +declare class DOMTokenList { + value: string + contains(item: unknown): boolean +} + // Jest Expect Compact export const JestChaiExpect: ChaiPlugin = (chai, utils) => { const { AssertionError } = chai @@ -164,6 +173,36 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => { return this.match(expected) }) def('toContain', function (item) { + const actual = this._obj as Iterable | string | Node | DOMTokenList + + if (typeof Node !== 'undefined' && actual instanceof Node) { + if (!(item instanceof Node)) + throw new TypeError(`toContain() expected a DOM node as the argument, but got ${typeof item}`) + + return this.assert( + actual.contains(item), + 'expected #{this} to contain element #{exp}', + 'expected #{this} not to contain element #{exp}', + item, + actual, + ) + } + + if (typeof DOMTokenList !== 'undefined' && actual instanceof DOMTokenList) { + assertTypes(item, 'class name', ['string']) + const isNot = utils.flag(this, 'negate') as boolean + const expectedClassList = isNot ? actual.value.replace(item, '').trim() : `${actual.value} ${item}` + return this.assert( + actual.contains(item), + `expected "${actual.value}" to contain "${item}"`, + `expected "${actual.value}" not to contain "${item}"`, + expectedClassList, + actual.value, + ) + } + // make "actual" indexable to have compatibility with jest + if (actual != null && typeof actual !== 'string') + utils.flag(this, 'object', Array.from(actual as Iterable)) return this.contain(item) }) def('toContainEqual', function (expected) { @@ -200,7 +239,7 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => { ) }) def('toBeGreaterThan', function (expected: number | bigint) { - const actual = this._obj + const actual = this._obj as number | bigint assertTypes(actual, 'actual', ['number', 'bigint']) assertTypes(expected, 'expected', ['number', 'bigint']) return this.assert( @@ -213,7 +252,7 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => { ) }) def('toBeGreaterThanOrEqual', function (expected: number | bigint) { - const actual = this._obj + const actual = this._obj as number | bigint assertTypes(actual, 'actual', ['number', 'bigint']) assertTypes(expected, 'expected', ['number', 'bigint']) return this.assert( @@ -226,7 +265,7 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => { ) }) def('toBeLessThan', function (expected: number | bigint) { - const actual = this._obj + const actual = this._obj as number | bigint assertTypes(actual, 'actual', ['number', 'bigint']) assertTypes(expected, 'expected', ['number', 'bigint']) return this.assert( @@ -239,7 +278,7 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => { ) }) def('toBeLessThanOrEqual', function (expected: number | bigint) { - const actual = this._obj + const actual = this._obj as number | bigint assertTypes(actual, 'actual', ['number', 'bigint']) assertTypes(expected, 'expected', ['number', 'bigint']) return this.assert( diff --git a/test/core/test/environments/jsdom.spec.ts b/test/core/test/environments/jsdom.spec.ts index 3272e774081f..95d47ec3c168 100644 --- a/test/core/test/environments/jsdom.spec.ts +++ b/test/core/test/environments/jsdom.spec.ts @@ -1,6 +1,12 @@ // @vitest-environment jsdom -import { expect, test } from 'vitest' +import { createColors, getDefaultColors, setupColors } from '@vitest/utils' +import { processError } from '@vitest/utils/error' +import { afterEach, expect, test } from 'vitest' + +afterEach(() => { + setupColors(createColors(true)) +}) const nodeMajor = Number(process.version.slice(1).split('.')[0]) @@ -21,3 +27,70 @@ test.runIf(nodeMajor >= 18)('fetch, Request, Response, and BroadcastChannel are expect(TextDecoder).toBeDefined() expect(BroadcastChannel).toBeDefined() }) + +test('toContain correctly handles DOM nodes', () => { + const wrapper = document.createElement('div') + const child = document.createElement('div') + const external = document.createElement('div') + wrapper.appendChild(child) + + const parent = document.createElement('div') + parent.appendChild(wrapper) + parent.appendChild(external) + + document.body.appendChild(parent) + const divs = document.querySelectorAll('div') + + expect(divs).toContain(wrapper) + expect(divs).toContain(parent) + expect(divs).toContain(external) + + expect(wrapper).toContain(child) + expect(wrapper).not.toContain(external) + + wrapper.classList.add('flex', 'flex-col') + + expect(wrapper.classList).toContain('flex-col') + expect(wrapper.classList).not.toContain('flex-row') + + expect(() => { + expect(wrapper).toContain('some-element') + }).toThrowErrorMatchingInlineSnapshot(`[TypeError: toContain() expected a DOM node as the argument, but got string]`) + + expect(() => { + expect(wrapper.classList).toContain('flex-row') + }).toThrowErrorMatchingInlineSnapshot(`[AssertionError: expected "flex flex-col" to contain "flex-row"]`) + expect(() => { + expect(wrapper.classList).toContain(2) + }).toThrowErrorMatchingInlineSnapshot(`[TypeError: class name value must be string, received "number"]`) + + setupColors(getDefaultColors()) + + try { + expect(wrapper.classList).toContain('flex-row') + expect.unreachable() + } + catch (err: any) { + expect(processError(err).diff).toMatchInlineSnapshot(` + "- Expected + + Received + + - flex flex-col flex-row + + flex flex-col" + `) + } + + try { + expect(wrapper.classList).not.toContain('flex') + expect.unreachable() + } + catch (err: any) { + expect(processError(err).diff).toMatchInlineSnapshot(` + "- Expected + + Received + + - flex-col + + flex flex-col" + `) + } +})