diff --git a/docs/guide/browser/assertion-api.md b/docs/guide/browser/assertion-api.md index 2126fe110e22..090d8b31f3d2 100644 --- a/docs/guide/browser/assertion-api.md +++ b/docs/guide/browser/assertion-api.md @@ -4,45 +4,13 @@ title: Assertion API | Browser Mode # Assertion API -Vitest bundles the [`@testing-library/jest-dom`](https://github.com/testing-library/jest-dom) library to provide a wide range of DOM assertions out of the box. For detailed documentation, you can read the `jest-dom` readme: - -- [`toBeDisabled`](https://github.com/testing-library/jest-dom#toBeDisabled) -- [`toBeEnabled`](https://github.com/testing-library/jest-dom#toBeEnabled) -- [`toBeEmptyDOMElement`](https://github.com/testing-library/jest-dom#toBeEmptyDOMElement) -- [`toBeInTheDocument`](https://github.com/testing-library/jest-dom#toBeInTheDocument) -- [`toBeInvalid`](https://github.com/testing-library/jest-dom#toBeInvalid) -- [`toBeRequired`](https://github.com/testing-library/jest-dom#toBeRequired) -- [`toBeValid`](https://github.com/testing-library/jest-dom#toBeValid) -- [`toBeVisible`](https://github.com/testing-library/jest-dom#toBeVisible) -- [`toContainElement`](https://github.com/testing-library/jest-dom#toContainElement) -- [`toContainHTML`](https://github.com/testing-library/jest-dom#toContainHTML) -- [`toHaveAccessibleDescription`](https://github.com/testing-library/jest-dom#toHaveAccessibleDescription) -- [`toHaveAccessibleErrorMessage`](https://github.com/testing-library/jest-dom#toHaveAccessibleErrorMessage) -- [`toHaveAccessibleName`](https://github.com/testing-library/jest-dom#toHaveAccessibleName) -- [`toHaveAttribute`](https://github.com/testing-library/jest-dom#toHaveAttribute) -- [`toHaveClass`](https://github.com/testing-library/jest-dom#toHaveClass) -- [`toHaveFocus`](https://github.com/testing-library/jest-dom#toHaveFocus) -- [`toHaveFormValues`](https://github.com/testing-library/jest-dom#toHaveFormValues) -- [`toHaveStyle`](https://github.com/testing-library/jest-dom#toHaveStyle) -- [`toHaveTextContent`](https://github.com/testing-library/jest-dom#toHaveTextContent) -- [`toHaveValue`](https://github.com/testing-library/jest-dom#toHaveValue) -- [`toHaveDisplayValue`](https://github.com/testing-library/jest-dom#toHaveDisplayValue) -- [`toBeChecked`](https://github.com/testing-library/jest-dom#toBeChecked) -- [`toBePartiallyChecked`](https://github.com/testing-library/jest-dom#toBePartiallyChecked) -- [`toHaveRole`](https://github.com/testing-library/jest-dom#toHaveRole) -- [`toHaveErrorMessage`](https://github.com/testing-library/jest-dom#toHaveErrorMessage) - -If you are using [TypeScript](/guide/browser/#typescript) or want to have correct type hints in `expect`, make sure you have either `@vitest/browser/providers/playwright` or `@vitest/browser/providers/webdriverio` referenced in your [setup file](/config/#setupfile) or a [config file](/config/) depending on the provider you use. If you use the default `preview` provider, you can specify `@vitest/browser/matchers` instead. - -::: code-group -```ts [preview] -/// -``` -```ts [playwright] -/// -``` -```ts [webdriverio] -/// +Vitest provides a wide range of DOM assertions out of the box forked from [`@testing-library/jest-dom`](https://github.com/testing-library/jest-dom) library with the added support for locators and built-in retry-ability. + +::: tip TypeScript Support +If you are using [TypeScript](/guide/browser/#typescript) or want to have correct type hints in `expect`, make sure you have `@vitest/browser/context` referenced somewhere. If you never imported from there, you can add a `reference` comment in any file that's covered by your `tsconfig.json`: + +```ts +/// ``` ::: @@ -55,25 +23,1026 @@ import { page } from '@vitest/browser/context' test('error banner is rendered', async () => { triggerError() - // @testing-library provides queries with built-in retry-ability - // It will try to find the banner until it's rendered + // This creates a locator that will try to find the element + // when any of its methods are called. + // This call by itself doesn't check the existence of the element. const banner = page.getByRole('alert', { name: /error/i, }) // Vitest provides `expect.element` with built-in retry-ability - // It will check `element.textContent` until it's equal to "Error!" + // It will repeatedly check that the element exists in the DOM and that + // the content of `element.textContent` is equal to "Error!" + // until all the conditions are met await expect.element(banner).toHaveTextContent('Error!') }) ``` +We recommend to always use `expect.element` when working with `page.getBy*` locators to reduce test flakiness. Note that `expect.element` accepts a second option: + +```ts +interface ExpectPollOptions { + // The interval to retry the assertion for in milliseconds + // Defaults to "expect.poll.interval" config option + interval?: number + // Time to retry the assertion for in milliseconds + // Defaults to "expect.poll.timeout" config option + timeout?: number + // The message printed when the assertion fails + message?: string +} +``` + ::: tip `expect.element` is a shorthand for `expect.poll(() => element)` and works in exactly the same way. -`toHaveTextContent` and all other [`@testing-library/jest-dom`](https://github.com/testing-library/jest-dom) assertions are still available on a regular `expect` without a built-in retry-ability mechanism: +`toHaveTextContent` and all other assertions are still available on a regular `expect` without a built-in retry-ability mechanism: ```ts // will fail immediately if .textContent is not `'Error!'` expect(banner).toHaveTextContent('Error!') ``` ::: + +## toBeDisabled + +```ts +function toBeDisabled(): Promise +``` + +Allows you to check whether an element is disabled from the user's perspective. + +Matches if the element is a form control and the `disabled` attribute is specified on this element or the +element is a descendant of a form element with a `disabled` attribute. + +Note that only native control elements such as HTML `button`, `input`, `select`, `textarea`, `option`, `optgroup` +can be disabled by setting "disabled" attribute. "disabled" attribute on other elements is ignored, unless it's a custom element. + +```html + +``` + +```ts +await expect.element(getByTestId('button')).toBeDisabled() // ✅ +await expect.element(getByTestId('button')).not.toBeDisabled() // ❌ +``` + +## toBeEnabled + +```ts +function toBeEnabled(): Promise +``` + +Allows you to check whether an element is not disabled from the user's perspective. + +Works like [`not.toBeDisabled()`](#tobedisabled). Use this matcher to avoid double negation in your tests. + +```html + +``` + +```ts +await expect.element(getByTestId('button')).toBeEnabled() // ✅ +await expect.element(getByTestId('button')).not.toBeEnabled() // ❌ +``` + +## toBeEmptyDOMElement + +```ts +function toBeEmptyDOMElement(): Promise +``` + +This allows you to assert whether an element has no visible content for the user. It ignores comments but will fail if the element contains white-space. + +```html + + + +``` + +```ts +await expect.element(getByTestId('empty')).toBeEmptyDOMElement() +await expect.element(getByTestId('not-empty')).not.toBeEmptyDOMElement() +await expect.element( + getByTestId('with-whitespace') +).not.toBeEmptyDOMElement() +``` + +## toBeInTheDocument + +```ts +function toBeInTheDocument(): Promise +``` + +Assert whether an element is present in the document or not. + +```html + +``` + +```ts +await expect.element(getByTestId('svg-element')).toBeInTheDocument() +await expect.element(getByTestId('does-not-exist')).not.toBeInTheDocument() +``` + +::: warning +This matcher does not find detached elements. The element must be added to the document to be found by `toBeInTheDocument`. If you desire to search in a detached element, please use: [`toContainElement`](#tocontainelement). +::: + +## toBeInvalid + +```ts +function toBeInvalid(): Promise +``` + +This allows you to check if an element, is currently invalid. + +An element is invalid if it has an [`aria-invalid` attribute](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-invalid) with no value or a value of `"true"`, or if the result of [`checkValidity()`](https://developer.mozilla.org/en-US/docs/Web/HTML/Constraint_validation) is `false`. + +```html + + + + + +
+ +
+ +
+ +
+``` + +```ts +await expect.element(getByTestId('no-aria-invalid')).not.toBeInvalid() +await expect.element(getByTestId('aria-invalid')).toBeInvalid() +await expect.element(getByTestId('aria-invalid-value')).toBeInvalid() +await expect.element(getByTestId('aria-invalid-false')).not.toBeInvalid() + +await expect.element(getByTestId('valid-form')).not.toBeInvalid() +await expect.element(getByTestId('invalid-form')).toBeInvalid() +``` + +## toBeRequired + +```ts +function toBeRequired(): Promise +``` + +This allows you to check if a form element is currently required. + +An element is required if it is having a `required` or `aria-required="true"` attribute. + +```html + + + + + + + + +
+
+``` + +```ts +await expect.element(getByTestId('required-input')).toBeRequired() +await expect.element(getByTestId('aria-required-input')).toBeRequired() +await expect.element(getByTestId('conflicted-input')).toBeRequired() +await expect.element(getByTestId('aria-not-required-input')).not.toBeRequired() +await expect.element(getByTestId('optional-input')).not.toBeRequired() +await expect.element(getByTestId('unsupported-type')).not.toBeRequired() +await expect.element(getByTestId('select')).toBeRequired() +await expect.element(getByTestId('textarea')).toBeRequired() +await expect.element(getByTestId('supported-role')).not.toBeRequired() +await expect.element(getByTestId('supported-role-aria')).toBeRequired() +``` + +## toBeValid + +```ts +function toBeValid(): Promise +``` + +This allows you to check if the value of an element, is currently valid. + +An element is valid if it has no [`aria-invalid` attribute](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-invalid) or an attribute value of "false". The result of [`checkValidity()`](https://developer.mozilla.org/en-US/docs/Web/HTML/Constraint_validation) must also be `true` if it's a form element. + +```html + + + + + +
+ +
+ +
+ +
+``` + +```ts +await expect.element(getByTestId('no-aria-invalid')).toBeValid() +await expect.element(getByTestId('aria-invalid')).not.toBeValid() +await expect.element(getByTestId('aria-invalid-value')).not.toBeValid() +await expect.element(getByTestId('aria-invalid-false')).toBeValid() + +await expect.element(getByTestId('valid-form')).toBeValid() +await expect.element(getByTestId('invalid-form')).not.toBeValid() +``` + +## toBeVisible + +```ts +function toBeVisible(): Promise +``` + +This allows you to check if an element is currently visible to the user. + +Element is considered visible when it has non-empty bounding box and does not have `visibility:hidden` computed style. + +Note that according to this definition: + +- Elements of zero size **are not** considered visible. +- Elements with `display:none` **are not** considered visible. +- Elements with `opacity:0` **are** considered visible. + +To check that at least one element from the list is visible, use `locator.first()`. + +```ts +// A specific element is visible. +await expect.element(page.getByText('Welcome')).toBeVisible() + +// At least one item in the list is visible. +await expect.element(page.getByTestId('todo-item').first()).toBeVisible() + +// At least one of the two elements is visible, possibly both. +await expect.element( + page.getByRole('button', { name: 'Sign in' }) + .or(page.getByRole('button', { name: 'Sign up' })) + .first() +).toBeVisible() +``` + +## toContainElement + +```ts +function toContainElement(element: HTMLElement | SVGElement | null): Promise +``` + +This allows you to assert whether an element contains another element as a descendant or not. + +```html + +``` + +```ts +const ancestor = getByTestId('ancestor') +const descendant = getByTestId('descendant') +const nonExistantElement = getByTestId('does-not-exist') + +await expect.element(ancestor).toContainElement(descendant) +await expect.element(descendant).not.toContainElement(ancestor) +await expect.element(ancestor).not.toContainElement(nonExistantElement) +``` + +## toContainHTML + +```ts +function toContainHTML(htmlText: string): Promise +``` + +Assert whether a string representing a HTML element is contained in another element. The string should contain valid html, and not any incomplete html. + +```html + +``` + +```ts +// These are valid usages +await expect.element(getByTestId('parent')).toContainHTML('') +await expect.element(getByTestId('parent')).toContainHTML('') +await expect.element(getByTestId('parent')).not.toContainHTML('
') + +// These won't work +await expect.element(getByTestId('parent')).toContainHTML('data-testid="child"') +await expect.element(getByTestId('parent')).toContainHTML('data-testid') +await expect.element(getByTestId('parent')).toContainHTML('
') +``` + +::: warning +Chances are you probably do not need to use this matcher. We encourage testing from the perspective of how the user perceives the app in a browser. That's why testing against a specific DOM structure is not advised. + +It could be useful in situations where the code being tested renders html that was obtained from an external source, and you want to validate that that html code was used as intended. + +It should not be used to check DOM structure that you control. Please, use [`toContainElement`](#tocontainelement) instead. +::: + +## toHaveAccessibleDescription + +```ts +function toHaveAccessibleDescription(description?: string | RegExp): Promise +``` + +This allows you to assert that an element has the expected +[accessible description](https://w3c.github.io/accname/). + +You can pass the exact string of the expected accessible description, or you can +make a partial match passing a regular expression, or by using +[`expect.stringContaining`](/api/expect#expect-stringcontaining) or [`expect.stringMatching`](/api/expect#expect-stringmatching). + +```html +Start +About +User profile pic +Company logo +The logo of Our Company +Company logo +``` + +```ts +await expect.element(getByTestId('link')).toHaveAccessibleDescription() +await expect.element(getByTestId('link')).toHaveAccessibleDescription('A link to start over') +await expect.element(getByTestId('link')).not.toHaveAccessibleDescription('Home page') +await expect.element(getByTestId('extra-link')).not.toHaveAccessibleDescription() +await expect.element(getByTestId('avatar')).not.toHaveAccessibleDescription() +await expect.element(getByTestId('logo')).not.toHaveAccessibleDescription('Company logo') +await expect.element(getByTestId('logo')).toHaveAccessibleDescription( + 'The logo of Our Company', +) +await expect.element(getByTestId('logo2')).toHaveAccessibleDescription( + 'The logo of Our Company', +) +``` + +## toHaveAccessibleErrorMessage + +```ts +function toHaveAccessibleErrorMessage(message?: string | RegExp): Promise +``` + +This allows you to assert that an element has the expected +[accessible error message](https://w3c.github.io/aria/#aria-errormessage). + +You can pass the exact string of the expected accessible error message. +Alternatively, you can perform a partial match by passing a regular expression +or by using +[`expect.stringContaining`](/api/expect#expect-stringcontaining) or [`expect.stringMatching`](/api/expect#expect-stringmatching). + +```html + + + + + +``` + +```ts +// Inputs with Valid Error Messages +await expect.element(getByRole('textbox', { name: 'Has Error' })).toHaveAccessibleErrorMessage() +await expect.element(getByRole('textbox', { name: 'Has Error' })).toHaveAccessibleErrorMessage( + 'This field is invalid', +) +await expect.element(getByRole('textbox', { name: 'Has Error' })).toHaveAccessibleErrorMessage( + /invalid/i, +) +await expect.element( + getByRole('textbox', { name: 'Has Error' }), +).not.toHaveAccessibleErrorMessage('This field is absolutely correct!') + +// Inputs without Valid Error Messages +await expect.element( + getByRole('textbox', { name: 'No Error Attributes' }), +).not.toHaveAccessibleErrorMessage() + +await expect.element( + getByRole('textbox', { name: 'Not Invalid' }), +).not.toHaveAccessibleErrorMessage() +``` + +## toHaveAccessibleName + +```ts +function toHaveAccessibleName(name?: string | RegExp): Promise +``` + +This allows you to assert that an element has the expected +[accessible name](https://w3c.github.io/accname/). It is useful, for instance, +to assert that form elements and buttons are properly labelled. + +You can pass the exact string of the expected accessible name, or you can make a +partial match passing a regular expression, or by using +[`expect.stringContaining`](/api/expect#expect-stringcontaining) or [`expect.stringMatching`](/api/expect#expect-stringmatching). + +```html +Test alt + +Test title + +

Test content

+ +``` + +```ts +const button = getByTestId('ok-button') + +await expect.element(button).toHaveAttribute('disabled') +await expect.element(button).toHaveAttribute('type', 'submit') +await expect.element(button).not.toHaveAttribute('type', 'button') + +await expect.element(button).toHaveAttribute( + 'type', + expect.stringContaining('sub') +) +await expect.element(button).toHaveAttribute( + 'type', + expect.not.stringContaining('but') +) +``` + +## toHaveClass + +```ts +function toHaveClass(...classNames: string[], options?: { exact: boolean }): Promise +function toHaveClass(...classNames: (string | RegExp)[]): Promise +``` + +This allows you to check whether the given element has certain classes within +its `class` attribute. You must provide at least one class, unless you are +asserting that an element does not have any classes. + +The list of class names may include strings and regular expressions. Regular +expressions are matched against each individual class in the target element, and +it is NOT matched against its full `class` attribute value as whole. + +::: warning +Note that you cannot use `exact: true` option when only regular expressions are provided. +::: + +```html + + +``` + +```ts +const deleteButton = getByTestId('delete-button') +const noClasses = getByTestId('no-classes') + +await expect.element(deleteButton).toHaveClass('extra') +await expect.element(deleteButton).toHaveClass('btn-danger btn') +await expect.element(deleteButton).toHaveClass(/danger/, 'btn') +await expect.element(deleteButton).toHaveClass('btn-danger', 'btn') +await expect.element(deleteButton).not.toHaveClass('btn-link') +await expect.element(deleteButton).not.toHaveClass(/link/) + +// ⚠️ regexp matches against individual classes, not the whole classList +await expect.element(deleteButton).not.toHaveClass(/btn extra/) + +// the element has EXACTLY a set of classes (in any order) +await expect.element(deleteButton).toHaveClass('btn-danger extra btn', { + exact: true +}) +// if it has more than expected it is going to fail +await expect.element(deleteButton).not.toHaveClass('btn-danger extra', { + exact: true +}) + +await expect.element(noClasses).not.toHaveClass() +``` + +## toHaveFocus + +```ts +function toHaveFocus(): Promise +``` + +This allows you to assert whether an element has focus or not. + +```html +
+``` + +```ts +const input = page.getByTestId('element-to-focus') +input.element().focus() +await expect.element(input).toHaveFocus() +input.element().blur() +await expect.element(input).not.toHaveFocus() +``` + +## toHaveFormValues + +```ts +function toHaveFormValues(expectedValues: Record): Promise +``` + +This allows you to check if a form or fieldset contains form controls for each given name, and having the specified value. + +::: tip +It is important to stress that this matcher can only be invoked on a [form](https://developer.mozilla.org/en-US/docs/Web/API/HTMLFormElement) or a [fieldset](https://developer.mozilla.org/en-US/docs/Web/API/HTMLFieldSetElement) element. + +This allows it to take advantage of the [`.elements`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLFormElement/elements) property in `form` and `fieldset` to reliably fetch all form controls within them. + +This also avoids the possibility that users provide a container that contains more than one `form`, thereby intermixing form controls that are not related, and could even conflict with one another. +::: + +This matcher abstracts away the particularities with which a form control value +is obtained depending on the type of form control. For instance, `` +elements have a `value` attribute, but `` elements return the value as a **number**, instead of + a string. +- `` elements: + - if there's a single one with the given `name` attribute, it is treated as a + **boolean**, returning `true` if the checkbox is checked, `false` if + unchecked. + - if there's more than one checkbox with the same `name` attribute, they are + all treated collectively as a single form control, which returns the value + as an **array** containing all the values of the selected checkboxes in the + collection. +- `` elements are all grouped by the `name` attribute, and + such a group treated as a single form control. This form control returns the + value as a **string** corresponding to the `value` attribute of the selected + radio button within the group. +- `` elements return the value as a **string**. This also + applies to `` elements having any other possible `type` attribute + that's not explicitly covered in different rules above (e.g. `search`, + `email`, `date`, `password`, `hidden`, etc.) +- `` elements return the value as an **array** containing all + the values of the [selected options](https://developer.mozilla.org/en-US/docs/Web/API/HTMLSelectElement/selectedOptions). +- ` + + + + + + +``` + +```ts +const input = page.getByLabelText('First name') +const textarea = page.getByLabelText('Description') +const selectSingle = page.getByLabelText('Fruit') +const selectMultiple = page.getByLabelText('Fruits') + +await expect.element(input).toHaveDisplayValue('Luca') +await expect.element(input).toHaveDisplayValue(/Luc/) +await expect.element(textarea).toHaveDisplayValue('An example description here.') +await expect.element(textarea).toHaveDisplayValue(/example/) +await expect.element(selectSingle).toHaveDisplayValue('Select a fruit...') +await expect.element(selectSingle).toHaveDisplayValue(/Select/) +await expect.element(selectMultiple).toHaveDisplayValue([/Avocado/, 'Banana']) +``` + +## toBeChecked + +```ts +function toBeChecked(): Promise +``` + +This allows you to check whether the given element is checked. It accepts an +`input` of type `checkbox` or `radio` and elements with a `role` of `checkbox`, +`radio` or `switch` with a valid `aria-checked` attribute of `"true"` or +`"false"`. + +```html + + +
+
+ + + +
+
+
+
+``` + +```ts +const inputCheckboxChecked = getByTestId('input-checkbox-checked') +const inputCheckboxUnchecked = getByTestId('input-checkbox-unchecked') +const ariaCheckboxChecked = getByTestId('aria-checkbox-checked') +const ariaCheckboxUnchecked = getByTestId('aria-checkbox-unchecked') +await expect.element(inputCheckboxChecked).toBeChecked() +await expect.element(inputCheckboxUnchecked).not.toBeChecked() +await expect.element(ariaCheckboxChecked).toBeChecked() +await expect.element(ariaCheckboxUnchecked).not.toBeChecked() + +const inputRadioChecked = getByTestId('input-radio-checked') +const inputRadioUnchecked = getByTestId('input-radio-unchecked') +const ariaRadioChecked = getByTestId('aria-radio-checked') +const ariaRadioUnchecked = getByTestId('aria-radio-unchecked') +await expect.element(inputRadioChecked).toBeChecked() +await expect.element(inputRadioUnchecked).not.toBeChecked() +await expect.element(ariaRadioChecked).toBeChecked() +await expect.element(ariaRadioUnchecked).not.toBeChecked() + +const ariaSwitchChecked = getByTestId('aria-switch-checked') +const ariaSwitchUnchecked = getByTestId('aria-switch-unchecked') +await expect.element(ariaSwitchChecked).toBeChecked() +await expect.element(ariaSwitchUnchecked).not.toBeChecked() +``` + +## toBePartiallyChecked + +```typescript +function toBePartiallyChecked(): Promise +``` + +This allows you to check whether the given element is partially checked. It +accepts an `input` of type `checkbox` and elements with a `role` of `checkbox` +with a `aria-checked="mixed"`, or `input` of type `checkbox` with +`indeterminate` set to `true` + +```html + + + +
+
+ +``` + +```ts +const ariaCheckboxMixed = getByTestId('aria-checkbox-mixed') +const inputCheckboxChecked = getByTestId('input-checkbox-checked') +const inputCheckboxUnchecked = getByTestId('input-checkbox-unchecked') +const ariaCheckboxChecked = getByTestId('aria-checkbox-checked') +const ariaCheckboxUnchecked = getByTestId('aria-checkbox-unchecked') +const inputCheckboxIndeterminate = getByTestId('input-checkbox-indeterminate') + +await expect.element(ariaCheckboxMixed).toBePartiallyChecked() +await expect.element(inputCheckboxChecked).not.toBePartiallyChecked() +await expect.element(inputCheckboxUnchecked).not.toBePartiallyChecked() +await expect.element(ariaCheckboxChecked).not.toBePartiallyChecked() +await expect.element(ariaCheckboxUnchecked).not.toBePartiallyChecked() + +inputCheckboxIndeterminate.element().indeterminate = true +await expect.element(inputCheckboxIndeterminate).toBePartiallyChecked() +``` + +## toHaveRole + +```ts +function toHaveRole(role: ARIARole): Promise +``` + +This allows you to assert that an element has the expected [role](https://www.w3.org/TR/html-aria/#docconformance). + +This is useful in cases where you already have access to an element via some query other than the role itself, and want to make additional assertions regarding its accessibility. + +The role can match either an explicit role (via the `role` attribute), or an implicit one via the [implicit ARIA semantics](https://www.w3.org/TR/html-aria/#docconformance). + +```html + +
Continue + +About +Invalid link +``` + +```ts +await expect.element(getByTestId('button')).toHaveRole('button') +await expect.element(getByTestId('button-explicit')).toHaveRole('button') +await expect.element(getByTestId('button-explicit-multiple')).toHaveRole('button') +await expect.element(getByTestId('button-explicit-multiple')).toHaveRole('switch') +await expect.element(getByTestId('link')).toHaveRole('link') +await expect.element(getByTestId('link-invalid')).not.toHaveRole('link') +await expect.element(getByTestId('link-invalid')).toHaveRole('generic') +``` + +::: warning +Roles are matched literally by string equality, without inheriting from the ARIA role hierarchy. As a result, querying a superclass role like `checkbox` will not include elements with a subclass role like `switch`. + +Also note that unlike `testing-library`, Vitest ignores all custom roles except the first valid one, following Playwright's behaviour: + +```jsx +
+ +await expect.element(getByTestId('switch')).toHaveRole('switch') // ✅ +await expect.element(getByTestId('switch')).toHaveRole('alert') // ❌ +``` +::: + +## toHaveSelection + +```ts +function toHaveSelection(selection?: string): Promise +``` + +This allows to assert that an element has a +[text selection](https://developer.mozilla.org/en-US/docs/Web/API/Selection). + +This is useful to check if text or part of the text is selected within an +element. The element can be either an input of type text, a textarea, or any +other element that contains text, such as a paragraph, span, div etc. + +::: warning +The expected selection is a string, it does not allow to check for +selection range indeces. +::: + +```html +
+ + +

prev

+

+ text selected text +

+

next

+
+``` + +```ts +getByTestId('text').element().setSelectionRange(5, 13) +await expect.element(getByTestId('text')).toHaveSelection('selected') + +getByTestId('textarea').element().setSelectionRange(0, 5) +await expect.element('textarea').toHaveSelection('text ') + +const selection = document.getSelection() +const range = document.createRange() +selection.removeAllRanges() +selection.empty() +selection.addRange(range) + +// selection of child applies to the parent as well +range.selectNodeContents(getByTestId('child').element()) +await expect.element(getByTestId('child')).toHaveSelection('selected') +await expect.element(getByTestId('parent')).toHaveSelection('selected') + +// selection that applies from prev all, parent text before child, and part child. +range.setStart(getByTestId('prev').element(), 0) +range.setEnd(getByTestId('child').element().childNodes[0], 3) +await expect.element(queryByTestId('prev')).toHaveSelection('prev') +await expect.element(queryByTestId('child')).toHaveSelection('sel') +await expect.element(queryByTestId('parent')).toHaveSelection('text sel') +await expect.element(queryByTestId('next')).not.toHaveSelection() + +// selection that applies from part child, parent text after child and part next. +range.setStart(getByTestId('child').element().childNodes[0], 3) +range.setEnd(getByTestId('next').element().childNodes[0], 2) +await expect.element(queryByTestId('child')).toHaveSelection('ected') +await expect.element(queryByTestId('parent')).toHaveSelection('ected text') +await expect.element(queryByTestId('prev')).not.toHaveSelection() +await expect.element(queryByTestId('next')).toHaveSelection('ne') +``` diff --git a/docs/guide/browser/index.md b/docs/guide/browser/index.md index a33310adee8a..d6f26d5b6cb2 100644 --- a/docs/guide/browser/index.md +++ b/docs/guide/browser/index.md @@ -401,7 +401,7 @@ However, Vitest also provides packages to render components for several popular If your framework is not represented, feel free to create your own package - it is a simple wrapper around the framework renderer and `page.elementLocator` API. We will add a link to it on this page. Make sure it has a name starting with `vitest-browser-`. -Besides rendering components and locating elements, you will also need to make assertions. Vitest bundles the [`@testing-library/jest-dom`](https://github.com/testing-library/jest-dom) library to provide a wide range of DOM assertions out of the box. Read more at the [Assertions API](/guide/browser/assertion-api). +Besides rendering components and locating elements, you will also need to make assertions. Vitest forks the [`@testing-library/jest-dom`](https://github.com/testing-library/jest-dom) library to provide a wide range of DOM assertions out of the box. Read more at the [Assertions API](/guide/browser/assertion-api). ```ts import { expect } from 'vitest' diff --git a/docs/guide/browser/locators.md b/docs/guide/browser/locators.md index 655974ea4aba..24d3413e3c22 100644 --- a/docs/guide/browser/locators.md +++ b/docs/guide/browser/locators.md @@ -5,9 +5,13 @@ outline: [2, 3] # Locators -A locator is a representation of an element or a number of elements. Every locator is defined by a string called a selector. Vitest abstracts this selector by providing convenient methods that generate those selectors behind the scenes. +A locator is a representation of an element or a number of elements. Every locator is defined by a string called a selector. Vitest abstracts this selector by providing convenient methods that generate them behind the scenes. -The locator API uses a fork of [Playwright's locators](https://playwright.dev/docs/api/class-locator) called [Ivya](https://npmjs.com/ivya). However, Vitest provides this API to every [provider](/guide/browser/config.html#browser-provider). +The locator API uses a fork of [Playwright's locators](https://playwright.dev/docs/api/class-locator) called [Ivya](https://npmjs.com/ivya). However, Vitest provides this API to every [provider](/guide/browser/config.html#browser-provider), not just playwright. + +::: tip +This page covers API usage. To better understand locators and their usage, read [Playwright's "Locators" documentation](https://playwright.dev/docs/locators). +::: ## getByRole diff --git a/packages/browser/context.d.ts b/packages/browser/context.d.ts index a5025e4d9280..2202de22df64 100644 --- a/packages/browser/context.d.ts +++ b/packages/browser/context.d.ts @@ -1,5 +1,6 @@ -import type { SerializedConfig } from 'vitest' +import { SerializedConfig } from 'vitest' import { ARIARole } from './aria-role.js' +import {} from './matchers.js' export type BufferEncoding = | 'ascii' diff --git a/packages/browser/jest-dom.d.ts b/packages/browser/jest-dom.d.ts index d44fd81963b9..21b5be83a1b5 100644 --- a/packages/browser/jest-dom.d.ts +++ b/packages/browser/jest-dom.d.ts @@ -2,718 +2,629 @@ import { ARIARole } from './aria-role.ts' -declare namespace matchers { - interface TestingLibraryMatchers { - /** - * @deprecated - * since v1.9.0 - * @description - * Assert whether a value is a DOM element, or not. Contrary to what its name implies, this matcher only checks - * that you passed to it a valid DOM element. - * - * It does not have a clear definition of what "the DOM" is. Therefore, it does not check whether that element - * is contained anywhere. - * @see - * [testing-library/jest-dom#toBeInTheDom](https://github.com/testing-library/jest-dom#toBeInTheDom) - */ - toBeInTheDOM(container?: HTMLElement | SVGElement): R - /** - * @description - * Assert whether an element is present in the document or not. - * @example - * - * - * expect(queryByTestId('svg-element')).toBeInTheDocument() - * expect(queryByTestId('does-not-exist')).not.toBeInTheDocument() - * @see - * [testing-library/jest-dom#tobeinthedocument](https://github.com/testing-library/jest-dom#tobeinthedocument) - */ - toBeInTheDocument(): R - /** - * @description - * This allows you to check if an element is currently visible to the user. - * - * An element is visible if **all** the following conditions are met: - * * it does not have its css property display set to none - * * it does not have its css property visibility set to either hidden or collapse - * * it does not have its css property opacity set to 0 - * * its parent element is also visible (and so on up to the top of the DOM tree) - * * it does not have the hidden attribute - * * if `
` it has the open attribute - * @example - *
- * Zero Opacity - *
- * - *
Visible Example
- * - * expect(getByTestId('zero-opacity')).not.toBeVisible() - * expect(getByTestId('visible')).toBeVisible() - * @see - * [testing-library/jest-dom#tobevisible](https://github.com/testing-library/jest-dom#tobevisible) - */ - toBeVisible(): R - /** - * @deprecated - * since v5.9.0 - * @description - * Assert whether an element has content or not. - * @example - * - * - * - * - * expect(getByTestId('empty')).toBeEmpty() - * expect(getByTestId('not-empty')).not.toBeEmpty() - * @see - * [testing-library/jest-dom#tobeempty](https://github.com/testing-library/jest-dom#tobeempty) - */ - toBeEmpty(): R - /** - * @description - * Assert whether an element has content or not. - * @example - * - * - * - * - * expect(getByTestId('empty')).toBeEmptyDOMElement() - * expect(getByTestId('not-empty')).not.toBeEmptyDOMElement() - * @see - * [testing-library/jest-dom#tobeemptydomelement](https://github.com/testing-library/jest-dom#tobeemptydomelement) - */ - toBeEmptyDOMElement(): R - /** - * @description - * Allows you to check whether an element is disabled from the user's perspective. - * - * Matches if the element is a form control and the `disabled` attribute is specified on this element or the - * element is a descendant of a form element with a `disabled` attribute. - * @example - * - * - * expect(getByTestId('button')).toBeDisabled() - * @see - * [testing-library/jest-dom#tobedisabled](https://github.com/testing-library/jest-dom#tobedisabled) - */ - toBeDisabled(): R - /** - * @description - * Allows you to check whether an element is not disabled from the user's perspective. - * - * Works like `not.toBeDisabled()`. - * - * Use this matcher to avoid double negation in your tests. - * @example - * - * - * expect(getByTestId('button')).toBeEnabled() - * @see - * [testing-library/jest-dom#tobeenabled](https://github.com/testing-library/jest-dom#tobeenabled) - */ - toBeEnabled(): R - /** - * @description - * Check if a form element, or the entire `form`, is currently invalid. - * - * An `input`, `select`, `textarea`, or `form` element is invalid if it has an `aria-invalid` attribute with no - * value or a value of "true", or if the result of `checkValidity()` is false. - * @example - * - * - *
- * - *
- * - * expect(getByTestId('no-aria-invalid')).not.toBeInvalid() - * expect(getByTestId('invalid-form')).toBeInvalid() - * @see - * [testing-library/jest-dom#tobeinvalid](https://github.com/testing-library/jest-dom#tobeinvalid) - */ - toBeInvalid(): R - /** - * @description - * This allows you to check if a form element is currently required. - * - * An element is required if it is having a `required` or `aria-required="true"` attribute. - * @example - * - *
- * - * expect(getByTestId('required-input')).toBeRequired() - * expect(getByTestId('supported-role')).not.toBeRequired() - * @see - * [testing-library/jest-dom#toberequired](https://github.com/testing-library/jest-dom#toberequired) - */ - toBeRequired(): R - /** - * @description - * Allows you to check if a form element is currently required. - * - * An `input`, `select`, `textarea`, or `form` element is invalid if it has an `aria-invalid` attribute with no - * value or a value of "false", or if the result of `checkValidity()` is true. - * @example - * - * - *
- * - *
- * - * expect(getByTestId('no-aria-invalid')).not.toBeValid() - * expect(getByTestId('invalid-form')).toBeInvalid() - * @see - * [testing-library/jest-dom#tobevalid](https://github.com/testing-library/jest-dom#tobevalid) - */ - toBeValid(): R - /** - * @description - * Allows you to assert whether an element contains another element as a descendant or not. - * @example - * - * - * - * - * const ancestor = getByTestId('ancestor') - * const descendant = getByTestId('descendant') - * const nonExistentElement = getByTestId('does-not-exist') - * expect(ancestor).toContainElement(descendant) - * expect(descendant).not.toContainElement(ancestor) - * expect(ancestor).not.toContainElement(nonExistentElement) - * @see - * [testing-library/jest-dom#tocontainelement](https://github.com/testing-library/jest-dom#tocontainelement) - */ - toContainElement(element: HTMLElement | SVGElement | null): R - /** - * @description - * Assert whether a string representing a HTML element is contained in another element. - * @example - * - * - * expect(getByTestId('parent')).toContainHTML('') - * @see - * [testing-library/jest-dom#tocontainhtml](https://github.com/testing-library/jest-dom#tocontainhtml) - */ - toContainHTML(htmlText: string): R - /** - * @description - * Allows you to check if a given element has an attribute or not. - * - * You can also optionally check that the attribute has a specific expected value or partial match using - * [expect.stringContaining](https://jestjs.io/docs/en/expect.html#expectnotstringcontainingstring) or - * [expect.stringMatching](https://jestjs.io/docs/en/expect.html#expectstringmatchingstring-regexp). - * @example - * - * - * expect(button).toHaveAttribute('disabled') - * expect(button).toHaveAttribute('type', 'submit') - * expect(button).not.toHaveAttribute('type', 'button') - * @see - * [testing-library/jest-dom#tohaveattribute](https://github.com/testing-library/jest-dom#tohaveattribute) - */ - toHaveAttribute(attr: string, value?: unknown): R - /** - * @description - * Check whether the given element has certain classes within its `class` attribute. - * - * You must provide at least one class, unless you are asserting that an element does not have any classes. - * @example - * - * - *
no classes
- * - * const deleteButton = getByTestId('delete-button') - * const noClasses = getByTestId('no-classes') - * expect(deleteButton).toHaveClass('btn') - * expect(deleteButton).toHaveClass('btn-danger xs') - * expect(deleteButton).toHaveClass(/danger/, 'xs') - * expect(deleteButton).toHaveClass('btn xs btn-danger', {exact: true}) - * expect(deleteButton).not.toHaveClass('btn xs btn-danger', {exact: true}) - * expect(noClasses).not.toHaveClass() - * @see - * [testing-library/jest-dom#tohaveclass](https://github.com/testing-library/jest-dom#tohaveclass) - */ - toHaveClass(...classNames: (string | RegExp)[] | [string, options?: {exact: boolean}]): R - /** - * @description - * This allows you to check whether the given form element has the specified displayed value (the one the - * end user will see). It accepts , - * - * - * - * - * - * - * - * const input = screen.getByLabelText('First name') - * const textarea = screen.getByLabelText('Description') - * const selectSingle = screen.getByLabelText('Fruit') - * const selectMultiple = screen.getByLabelText('Fruits') - * - * expect(input).toHaveDisplayValue('Luca') - * expect(textarea).toHaveDisplayValue('An example description here.') - * expect(selectSingle).toHaveDisplayValue('Select a fruit...') - * expect(selectMultiple).toHaveDisplayValue(['Banana', 'Avocado']) - * - * @see - * [testing-library/jest-dom#tohavedisplayvalue](https://github.com/testing-library/jest-dom#tohavedisplayvalue) - */ - toHaveDisplayValue(value: string | RegExp | Array): R - /** - * @description - * Assert whether an element has focus or not. - * @example - *
- * - *
- * - * const input = getByTestId('element-to-focus') - * input.focus() - * expect(input).toHaveFocus() - * input.blur() - * expect(input).not.toHaveFocus() - * @see - * [testing-library/jest-dom#tohavefocus](https://github.com/testing-library/jest-dom#tohavefocus) - */ - toHaveFocus(): R - /** - * @description - * Check if a form or fieldset contains form controls for each given name, and having the specified value. - * - * Can only be invoked on a form or fieldset element. - * @example - *
- * - * - * - * - *
- * - * expect(getByTestId('login-form')).toHaveFormValues({ - * username: 'jane.doe', - * rememberMe: true, - * }) - * @see - * [testing-library/jest-dom#tohaveformvalues](https://github.com/testing-library/jest-dom#tohaveformvalues) - */ - toHaveFormValues(expectedValues: Record): R - /** - * @description - * Check if an element has specific css properties with specific values applied. - * - * Only matches if the element has *all* the expected properties applied, not just some of them. - * @example - * - * - * const button = getByTestId('submit-button') - * expect(button).toHaveStyle('background-color: green') - * expect(button).toHaveStyle({ - * 'background-color': 'green', - * display: 'none' - * }) - * @see - * [testing-library/jest-dom#tohavestyle](https://github.com/testing-library/jest-dom#tohavestyle) - */ - toHaveStyle(css: string | Record): R - /** - * @description - * Check whether the given element has a text content or not. - * - * When a string argument is passed through, it will perform a partial case-sensitive match to the element - * content. - * - * To perform a case-insensitive match, you can use a RegExp with the `/i` modifier. - * - * If you want to match the whole content, you can use a RegExp to do it. - * @example - * Text Content - * - * const element = getByTestId('text-content') - * expect(element).toHaveTextContent('Content') - * // to match the whole content - * expect(element).toHaveTextContent(/^Text Content$/) - * // to use case-insensitive match - * expect(element).toHaveTextContent(/content$/i) - * expect(element).not.toHaveTextContent('content') - * @see - * [testing-library/jest-dom#tohavetextcontent](https://github.com/testing-library/jest-dom#tohavetextcontent) - */ - toHaveTextContent( - text: string | RegExp, - options?: {normalizeWhitespace: boolean}, - ): R - /** - * @description - * Check whether the given form element has the specified value. - * - * Accepts ``, ` + * + * + * + * + * + * + * + * const input = page.getByLabelText('First name') + * const textarea = page.getByLabelText('Description') + * const selectSingle = page.getByLabelText('Fruit') + * const selectMultiple = page.getByLabelText('Fruits') + * + * await expect.element(input).toHaveDisplayValue('Luca') + * await expect.element(textarea).toHaveDisplayValue('An example description here.') + * await expect.element(selectSingle).toHaveDisplayValue('Select a fruit...') + * await expect.element(selectMultiple).toHaveDisplayValue(['Banana', 'Avocado']) + * + * @see https://vitest.dev/guide/browser/assertion-api#tohavedisplayvalue + */ + toHaveDisplayValue(value: string | number | RegExp | Array): R + /** + * @description + * Assert whether an element has focus or not. + * @example + *
+ * + *
+ * + * const input = page.getByTestId('element-to-focus') + * input.element().focus() + * await expect.element(input).toHaveFocus() + * input.element().blur() + * await expect.element(input).not.toHaveFocus() + * @see https://vitest.dev/guide/browser/assertion-api#tohavefocus + */ + toHaveFocus(): R + /** + * @description + * Check if a form or fieldset contains form controls for each given name, and having the specified value. + * + * Can only be invoked on a form or fieldset element. + * @example + *
+ * + * + * + * + *
+ * + * await expect.element(page.getByTestId('login-form')).toHaveFormValues({ + * username: 'jane.doe', + * rememberMe: true, + * }) + * @see https://vitest.dev/guide/browser/assertion-api#tohaveformvalues + */ + toHaveFormValues(expectedValues: Record): R + /** + * @description + * Check if an element has specific css properties with specific values applied. + * + * Only matches if the element has *all* the expected properties applied, not just some of them. + * @example + * + * + * const button = page.getByTestId('submit-button') + * await expect.element(button).toHaveStyle('background-color: green') + * await expect.element(button).toHaveStyle({ + * 'background-color': 'green', + * display: 'none' + * }) + * @see https://vitest.dev/guide/browser/assertion-api#tohavestyle + */ + toHaveStyle(css: string | Partial): R + /** + * @description + * Check whether the given element has a text content or not. + * + * When a string argument is passed through, it will perform a partial case-sensitive match to the element + * content. + * + * To perform a case-insensitive match, you can use a RegExp with the `/i` modifier. + * + * If you want to match the whole content, you can use a RegExp to do it. + * @example + * Text Content + * + * const element = page.getByTestId('text-content') + * await expect.element(element).toHaveTextContent('Content') + * // to match the whole content + * await expect.element(element).toHaveTextContent(/^Text Content$/) + * // to use case-insensitive match + * await expect.element(element).toHaveTextContent(/content$/i) + * await expect.element(element).not.toHaveTextContent('content') + * @see https://vitest.dev/guide/browser/assertion-api#tohavetextcontent + */ + toHaveTextContent( + text: string | number | RegExp, + options?: {normalizeWhitespace: boolean}, + ): R + /** + * @description + * Check whether the given form element has the specified value. + * + * Accepts ``, ` + *

prev

+ *

text selected text

+ *

next

+ *
+ * + * page.getByTestId('text').element().setSelectionRange(5, 13) + * await expect.element(page.getByTestId('text')).toHaveSelection('selected') + * + * page.getByTestId('textarea').element().setSelectionRange(0, 5) + * await expect.element('textarea').toHaveSelection('text ') + * + * const selection = document.getSelection() + * const range = document.createRange() + * selection.removeAllRanges() + * selection.empty() + * selection.addRange(range) + * + * // selection of child applies to the parent as well + * range.selectNodeContents(page.getByTestId('child').element()) + * await expect.element(page.getByTestId('child')).toHaveSelection('selected') + * await expect.element(page.getByTestId('parent')).toHaveSelection('selected') + * + * // selection that applies from prev all, parent text before child, and part child. + * range.setStart(page.getByTestId('prev').element(), 0) + * range.setEnd(page.getByTestId('child').element().childNodes[0], 3) + * await expect.element(page.queryByTestId('prev')).toHaveSelection('prev') + * await expect.element(page.queryByTestId('child')).toHaveSelection('sel') + * await expect.element(page.queryByTestId('parent')).toHaveSelection('text sel') + * await expect.element(page.queryByTestId('next')).not.toHaveSelection() + * + * // selection that applies from part child, parent text after child and part next. + * range.setStart(page.getByTestId('child').element().childNodes[0], 3) + * range.setEnd(page.getByTestId('next').element().childNodes[0], 2) + * await expect.element(page.queryByTestId('child')).toHaveSelection('ected') + * await expect.element(page.queryByTestId('parent')).toHaveSelection('ected text') + * await expect.element(page.queryByTestId('prev')).not.toHaveSelection() + * await expect.element(page.queryByTestId('next')).toHaveSelection('ne') + * + * @see https://vitest.dev/guide/browser/assertion-api#tohaveselection + */ + toHaveSelection(selection?: string): R } - -// Needs to extend Record to be accepted by expect.extend() -// as it requires a string index signature. -declare const matchers: matchers.TestingLibraryMatchers & - Record - -declare namespace matchers$1 { - export { matchers as default }; -} - -export { matchers$1 as default }; diff --git a/packages/browser/matchers.d.ts b/packages/browser/matchers.d.ts index 3f1b24ce8ae3..1fec28aad190 100644 --- a/packages/browser/matchers.d.ts +++ b/packages/browser/matchers.d.ts @@ -1,10 +1,10 @@ import type { Locator } from '@vitest/browser/context' -import type jsdomMatchers from './jest-dom.js' +import type { TestingLibraryMatchers } from './jest-dom.js' import type { Assertion, ExpectPollOptions } from 'vitest' declare module 'vitest' { - interface JestAssertion extends jsdomMatchers.default.TestingLibraryMatchers {} - interface AsymmetricMatchersContaining extends jsdomMatchers.default.TestingLibraryMatchers {} + interface JestAssertion extends TestingLibraryMatchers {} + interface AsymmetricMatchersContaining extends TestingLibraryMatchers {} type Promisify = { [K in keyof O]: O[K] extends (...args: infer A) => infer R @@ -22,7 +22,7 @@ declare module 'vitest' { * You can set default timeout via `expect.poll.timeout` option in the config. * @see {@link https://vitest.dev/api/expect#poll} */ - element: (element: T, options?: ExpectPollOptions) => PromisifyDomAssertion> + element: (element: T, options?: ExpectPollOptions) => PromisifyDomAssertion> } } diff --git a/packages/browser/package.json b/packages/browser/package.json index cceaf8d95740..636dc451f8d5 100644 --- a/packages/browser/package.json +++ b/packages/browser/package.json @@ -99,7 +99,6 @@ "ws": "catalog:" }, "devDependencies": { - "@testing-library/jest-dom": "^6.6.3", "@types/ws": "catalog:", "@vitest/runner": "workspace:*", "@vitest/ui": "workspace:*", @@ -108,7 +107,7 @@ "@wdio/types": "^9.10.1", "birpc": "catalog:", "flatted": "catalog:", - "ivya": "^1.5.1", + "ivya": "^1.6.0", "mime": "^4.0.6", "pathe": "catalog:", "periscopic": "^4.0.2", diff --git a/packages/browser/rollup.config.js b/packages/browser/rollup.config.js index abc76cfd5e69..82eb3a945df7 100644 --- a/packages/browser/rollup.config.js +++ b/packages/browser/rollup.config.js @@ -75,6 +75,7 @@ export default () => 'locators/webdriverio': './src/client/tester/locators/webdriverio.ts', 'locators/preview': './src/client/tester/locators/preview.ts', 'locators/index': './src/client/tester/locators/index.ts', + 'expect-element': './src/client/tester/expect-element.ts', 'utils': './src/client/tester/public-utils.ts', }, output: { @@ -84,7 +85,11 @@ export default () => external, plugins: [ ...dtsUtilsClient.isolatedDecl(), - ...plugins, + ...plugins.filter(p => p.name !== 'unplugin-oxc'), + oxc({ + transform: { target: 'node18' }, + minify: true, + }), ], }, { diff --git a/packages/browser/src/client/tester/expect-element.ts b/packages/browser/src/client/tester/expect-element.ts index 5d836c506030..d4bd62e8b136 100644 --- a/packages/browser/src/client/tester/expect-element.ts +++ b/packages/browser/src/client/tester/expect-element.ts @@ -1,44 +1,45 @@ import type { Locator } from '@vitest/browser/context' import type { ExpectPollOptions } from 'vitest' -import * as matchers from '@testing-library/jest-dom/matchers' import { chai, expect } from 'vitest' +import { matchers } from './expect' import { processTimeoutOptions } from './utils' -export async function setupExpectDom(): Promise { - expect.extend(matchers) - expect.element = (elementOrLocator: T, options?: ExpectPollOptions) => { - if (!(elementOrLocator instanceof Element) && !('element' in elementOrLocator)) { - throw new Error(`Invalid element or locator: ${elementOrLocator}. Expected an instance of Element or Locator, received ${typeof elementOrLocator}`) +function element(elementOrLocator: T, options?: ExpectPollOptions): unknown { + if (elementOrLocator != null && !(elementOrLocator instanceof Element) && !('element' in elementOrLocator)) { + throw new Error(`Invalid element or locator: ${elementOrLocator}. Expected an instance of Element or Locator, received ${typeof elementOrLocator}`) + } + + return expect.poll(function element(this: object) { + if (elementOrLocator instanceof Element || elementOrLocator == null) { + return elementOrLocator } + chai.util.flag(this, '_poll.element', true) - return expect.poll(function element(this: object) { - if (elementOrLocator instanceof Element || elementOrLocator == null) { - return elementOrLocator - } - chai.util.flag(this, '_poll.element', true) - - const isNot = chai.util.flag(this, 'negate') as boolean - const name = chai.util.flag(this, '_name') as string - // element selector uses prettyDOM under the hood, which is an expensive call - // that should not be called on each failed locator attempt to avoid memory leak: - // https://github.com/vitest-dev/vitest/issues/7139 - const isLastPollAttempt = chai.util.flag(this, '_isLastPollAttempt') - // special case for `toBeInTheDocument` matcher - if (isNot && name === 'toBeInTheDocument') { - return elementOrLocator.query() - } - - if (isLastPollAttempt) { - return elementOrLocator.element() - } - - const result = elementOrLocator.query() - - if (!result) { - throw new Error(`Cannot find element with locator: ${JSON.stringify(elementOrLocator)}`) - } - - return result - }, processTimeoutOptions(options)) - } + const isNot = chai.util.flag(this, 'negate') as boolean + const name = chai.util.flag(this, '_name') as string + // element selector uses prettyDOM under the hood, which is an expensive call + // that should not be called on each failed locator attempt to avoid memory leak: + // https://github.com/vitest-dev/vitest/issues/7139 + const isLastPollAttempt = chai.util.flag(this, '_isLastPollAttempt') + // special case for `toBeInTheDocument` matcher + if (isNot && name === 'toBeInTheDocument') { + return elementOrLocator.query() + } + + if (isLastPollAttempt) { + return elementOrLocator.element() + } + + const result = elementOrLocator.query() + + if (!result) { + throw new Error(`Cannot find element with locator: ${JSON.stringify(elementOrLocator)}`) + } + + return result + }, processTimeoutOptions(options)) } + +expect.extend(matchers) +// Vitest typecheck doesn't pick up this assignment for some reason +Object.assign(expect, { element }) diff --git a/packages/browser/src/client/tester/expect/index.ts b/packages/browser/src/client/tester/expect/index.ts new file mode 100644 index 000000000000..69db1d533ae1 --- /dev/null +++ b/packages/browser/src/client/tester/expect/index.ts @@ -0,0 +1,52 @@ +import type { MatchersObject } from '@vitest/expect' +import toBeChecked from './toBeChecked' +import toBeEmptyDOMElement from './toBeEmptyDOMElement' +import { toBeDisabled, toBeEnabled } from './toBeEnabled' +import toBeInTheDocument from './toBeInTheDocument' +import { toBeInvalid, toBeValid } from './toBeInvalid' +import toBePartiallyChecked from './toBePartiallyChecked' +import toBeRequired from './toBeRequired' +import toBeVisible from './toBeVisible' +import toContainElement from './toContainElement' +import toContainHTML from './toContainHTML' +import toHaveAccessibleDescription from './toHaveAccessibleDescription' +import toHaveAccessibleErrorMessage from './toHaveAccessibleErrorMessage' +import toHaveAccessibleName from './toHaveAccessibleName' +import toHaveAttribute from './toHaveAttribute' +import toHaveClass from './toHaveClass' +import toHaveDisplayValue from './toHaveDisplayValue' +import toHaveFocus from './toHaveFocus' +import toHaveFormValues from './toHaveFormValues' +import toHaveRole from './toHaveRole' +import toHaveSelection from './toHaveSelection' +import toHaveStyle from './toHaveStyle' +import toHaveTextContent from './toHaveTextContent' +import toHaveValue from './toHaveValue' + +export const matchers: MatchersObject = { + toBeDisabled, + toBeEnabled, + toBeEmptyDOMElement, + toBeInTheDocument, + toBeInvalid, + toBeRequired, + toBeValid, + toBeVisible, + toContainElement, + toContainHTML, + toHaveAccessibleDescription, + toHaveAccessibleErrorMessage, + toHaveAccessibleName, + toHaveAttribute, + toHaveClass, + toHaveFocus, + toHaveFormValues, + toHaveStyle, + toHaveTextContent, + toHaveValue, + toHaveDisplayValue, + toBeChecked, + toBePartiallyChecked, + toHaveRole, + toHaveSelection, +} diff --git a/packages/browser/src/client/tester/expect/toBeChecked.ts b/packages/browser/src/client/tester/expect/toBeChecked.ts new file mode 100644 index 000000000000..bfd75421f144 --- /dev/null +++ b/packages/browser/src/client/tester/expect/toBeChecked.ts @@ -0,0 +1,77 @@ +/** + * The MIT License (MIT) + * Copyright (c) 2017 Kent C. Dodds + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + */ + +import type { ExpectationResult, MatcherState } from '@vitest/expect' +import type { Locator } from '../locators' +import { getAriaChecked, getAriaCheckedRoles, getAriaRole } from 'ivya/utils' +import { getElementFromUserInput, isInputElement, toSentence } from './utils' + +const supportedRoles = getAriaCheckedRoles() + +export default function toBeChecked( + this: MatcherState, + actual: Element | Locator, +): ExpectationResult { + const htmlElement = getElementFromUserInput(actual, toBeChecked, this) + + const isValidInput = () => { + return ( + isInputElement(htmlElement) + && ['checkbox', 'radio'].includes(htmlElement.type) + ) + } + + const isValidAriaElement = () => { + return ( + supportedRoles.includes(getAriaRole(htmlElement) || '') + && ['true', 'false'].includes(htmlElement.getAttribute('aria-checked') || '') + ) + } + + if (!isValidInput() && !isValidAriaElement()) { + return { + pass: false, + message: () => + `only inputs with type="checkbox" or type="radio" or elements with ${supportedRolesSentence()} and a valid aria-checked attribute can be used with .toBeChecked(). Use .toHaveValue() instead`, + } + } + + const checkedValue = getAriaChecked(htmlElement) + const isChecked = checkedValue === true // don't tolerate "mixed", see toBePartiallyChecked + + return { + pass: isChecked, + message: () => { + const is = isChecked ? 'is' : 'is not' + return [ + this.utils.matcherHint( + `${this.isNot ? '.not' : ''}.toBeChecked`, + 'element', + '', + ), + '', + `Received element ${is} checked:`, + ` ${this.utils.printReceived(htmlElement.cloneNode(false))}`, + ].join('\n') + }, + } +} + +function supportedRolesSentence() { + return toSentence( + supportedRoles.map(role => `role="${role}"`), + { lastWordConnector: ' or ' }, + ) +} diff --git a/packages/browser/src/client/tester/expect/toBeEmptyDOMElement.ts b/packages/browser/src/client/tester/expect/toBeEmptyDOMElement.ts new file mode 100644 index 000000000000..53785ee6baaa --- /dev/null +++ b/packages/browser/src/client/tester/expect/toBeEmptyDOMElement.ts @@ -0,0 +1,49 @@ +/** + * The MIT License (MIT) + * Copyright (c) 2017 Kent C. Dodds + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + */ + +import type { ExpectationResult, MatcherState } from '@vitest/expect' +import type { Locator } from '../locators' +import { getElementFromUserInput } from './utils' + +export default function toBeEmptyDOMElement( + this: MatcherState, + actual: Element | Locator, +): ExpectationResult { + const htmlElement = getElementFromUserInput(actual, toBeEmptyDOMElement, this) + + return { + pass: isEmptyElement(htmlElement), + message: () => { + return [ + this.utils.matcherHint( + `${this.isNot ? '.not' : ''}.toBeEmptyDOMElement`, + 'element', + '', + ), + '', + 'Received:', + ` ${this.utils.printReceived(htmlElement.innerHTML)}`, + ].join('\n') + }, + } +} + +/** + * Identifies if an element doesn't contain child nodes (excluding comments) + */ +function isEmptyElement(element: HTMLElement | SVGElement): boolean { + const nonCommentChildNodes = [...element.childNodes].filter(node => node.nodeType !== Node.COMMENT_NODE) + return nonCommentChildNodes.length === 0 +} diff --git a/packages/browser/src/client/tester/expect/toBeEnabled.ts b/packages/browser/src/client/tester/expect/toBeEnabled.ts new file mode 100644 index 000000000000..274af8dfcb65 --- /dev/null +++ b/packages/browser/src/client/tester/expect/toBeEnabled.ts @@ -0,0 +1,75 @@ +/** + * The MIT License (MIT) + * Copyright (c) 2017 Kent C. Dodds + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + */ + +import type { ExpectationResult, MatcherState } from '@vitest/expect' +import type { Locator } from '../locators' +import { getAriaDisabled } from 'ivya/utils' +import { getElementFromUserInput, getTag } from './utils' + +export function toBeDisabled( + this: MatcherState, + actual: Element | Locator, +): ExpectationResult { + const htmlElement = getElementFromUserInput(actual, toBeDisabled, this) + const isDisabled = isElementDisabled(htmlElement) + return { + pass: isDisabled, + message: () => { + const is = isDisabled ? 'is' : 'is not' + return [ + this.utils.matcherHint( + `${this.isNot ? '.not' : ''}.toBeDisabled`, + 'element', + '', + ), + '', + `Received element ${is} disabled:`, + ` ${this.utils.printReceived(htmlElement.cloneNode(false))}`, + ].join('\n') + }, + } +} + +export function toBeEnabled( + this: MatcherState, + actual: Element | Locator, +): ExpectationResult { + const htmlElement = getElementFromUserInput(actual, toBeEnabled, this) + const isDisabled = isElementDisabled(htmlElement) + return { + pass: !isDisabled, + message: () => { + const is = !isDisabled ? 'is' : 'is not' + return [ + this.utils.matcherHint( + `${this.isNot ? '.not' : ''}.toBeEnabled`, + 'element', + '', + ), + '', + `Received element ${is} enabled:`, + ` ${this.utils.printReceived(htmlElement.cloneNode(false))}`, + ].join('\n') + }, + } +} + +function isElementDisabled(element: HTMLElement | SVGElement) { + // ivya doesn't support custom elements check + if (getTag(element).includes('-')) { + return element.hasAttribute('disabled') + } + return getAriaDisabled(element) +} diff --git a/packages/browser/src/client/tester/expect/toBeInTheDocument.ts b/packages/browser/src/client/tester/expect/toBeInTheDocument.ts new file mode 100644 index 000000000000..a913622e6bf4 --- /dev/null +++ b/packages/browser/src/client/tester/expect/toBeInTheDocument.ts @@ -0,0 +1,59 @@ +/** + * The MIT License (MIT) + * Copyright (c) 2017 Kent C. Dodds + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + */ + +import type { ExpectationResult, MatcherState } from '@vitest/expect' +import type { Locator } from '../locators' +import { getElementFromUserInput } from './utils' + +export default function toBeInTheDocument( + this: MatcherState, + actual: Element | Locator | null, +): ExpectationResult { + let htmlElement: null | HTMLElement | SVGElement = null + + if (actual !== null || !this.isNot) { + htmlElement = getElementFromUserInput(actual, toBeInTheDocument, this) + } + + const pass + = htmlElement === null + ? false + : htmlElement.ownerDocument === htmlElement.getRootNode({ composed: true }) + + const errorFound = () => { + return `expected document not to contain element, found ${this.utils.stringify( + htmlElement?.cloneNode(true), + )} instead` + } + const errorNotFound = () => { + return `element could not be found in the document` + } + + return { + pass, + message: () => { + return [ + this.utils.matcherHint( + `${this.isNot ? '.not' : ''}.toBeInTheDocument`, + 'element', + '', + ), + '', + + this.utils.RECEIVED_COLOR(this.isNot ? errorFound() : errorNotFound()), + ].join('\n') + }, + } +} diff --git a/packages/browser/src/client/tester/expect/toBeInvalid.ts b/packages/browser/src/client/tester/expect/toBeInvalid.ts new file mode 100644 index 000000000000..33d1a165c866 --- /dev/null +++ b/packages/browser/src/client/tester/expect/toBeInvalid.ts @@ -0,0 +1,93 @@ +/** + * The MIT License (MIT) + * Copyright (c) 2017 Kent C. Dodds + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + */ + +import type { ExpectationResult, MatcherState } from '@vitest/expect' +import type { Locator } from '../locators' +import { getElementFromUserInput, getTag } from './utils' + +const FORM_TAGS = ['FORM', 'INPUT', 'SELECT', 'TEXTAREA'] + +function isElementHavingAriaInvalid(element: HTMLElement | SVGElement) { + return ( + element.hasAttribute('aria-invalid') + && element.getAttribute('aria-invalid') !== 'false' + ) +} + +function isSupportsValidityMethod(element: HTMLElement | SVGElement): element is HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement | HTMLFormElement { + return FORM_TAGS.includes(getTag(element)) +} + +function isElementInvalid(element: HTMLElement | SVGElement) { + const isHaveAriaInvalid = isElementHavingAriaInvalid(element) + if (isSupportsValidityMethod(element)) { + return isHaveAriaInvalid || !element.checkValidity() + } + else { + return isHaveAriaInvalid + } +} + +export function toBeInvalid( + this: MatcherState, + element: HTMLElement | SVGElement | Locator, +): ExpectationResult { + const htmlElement = getElementFromUserInput(element, toBeInvalid, this) + + const isInvalid = isElementInvalid(htmlElement) + + return { + pass: isInvalid, + message: () => { + const is = isInvalid ? 'is' : 'is not' + return [ + this.utils.matcherHint( + `${this.isNot ? '.not' : ''}.toBeInvalid`, + 'element', + '', + ), + '', + `Received element ${is} currently invalid:`, + ` ${this.utils.printReceived(htmlElement.cloneNode(false))}`, + ].join('\n') + }, + } +} + +export function toBeValid( + this: MatcherState, + element: HTMLElement | SVGElement | Locator, +): ExpectationResult { + const htmlElement = getElementFromUserInput(element, toBeInvalid, this) + + const isValid = !isElementInvalid(htmlElement) + + return { + pass: isValid, + message: () => { + const is = isValid ? 'is' : 'is not' + return [ + this.utils.matcherHint( + `${this.isNot ? '.not' : ''}.toBeValid`, + 'element', + '', + ), + '', + `Received element ${is} currently valid:`, + ` ${this.utils.printReceived(htmlElement.cloneNode(false))}`, + ].join('\n') + }, + } +} diff --git a/packages/browser/src/client/tester/expect/toBePartiallyChecked.ts b/packages/browser/src/client/tester/expect/toBePartiallyChecked.ts new file mode 100644 index 000000000000..327d87a2b581 --- /dev/null +++ b/packages/browser/src/client/tester/expect/toBePartiallyChecked.ts @@ -0,0 +1,80 @@ +/** + * The MIT License (MIT) + * Copyright (c) 2017 Kent C. Dodds + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + */ + +import type { ExpectationResult, MatcherState } from '@vitest/expect' +import type { Locator } from '../locators' +import { getAriaChecked as ivyaGetAriaChecked } from 'ivya/utils' +import { getElementFromUserInput, isInputElement } from './utils' + +export default function toBePartiallyChecked( + this: MatcherState, + actual: Element | Locator, +): ExpectationResult { + const htmlElement = getElementFromUserInput(actual, toBePartiallyChecked, this) + + const isValidInput = () => { + return ( + isInputElement(htmlElement) && htmlElement.type === 'checkbox' + ) + } + + const isValidAriaElement = () => { + return htmlElement.getAttribute('role') === 'checkbox' + } + + if (!isValidInput() && !isValidAriaElement()) { + return { + pass: false, + message: () => + 'only inputs with type="checkbox" or elements with role="checkbox" and a valid aria-checked attribute can be used with .toBePartiallyChecked(). Use .toHaveValue() instead', + } + } + + const isPartiallyChecked = isAriaMixed(htmlElement) + + return { + pass: isPartiallyChecked, + message: () => { + const is = isPartiallyChecked ? 'is' : 'is not' + return [ + this.utils.matcherHint( + `${this.isNot ? '.not' : ''}.toBePartiallyChecked`, + 'element', + '', + ), + '', + `Received element ${is} partially checked:`, + ` ${this.utils.printReceived(htmlElement.cloneNode(false))}`, + ].join('\n') + }, + } +} + +function isAriaMixed(element: HTMLElement | SVGElement): boolean { + const isMixed = ivyaGetAriaChecked(element) === 'mixed' + if (!isMixed) { + // playwright only looks at aria-checked if element is not a checkbox/radio + if ( + isInputElement(element) + && ['checkbox', 'radio'].includes((element as HTMLInputElement).type) + ) { + const ariaValue = element.getAttribute('aria-checked') + if (ariaValue === 'mixed') { + return true + } + } + } + return isMixed +} diff --git a/packages/browser/src/client/tester/expect/toBeRequired.ts b/packages/browser/src/client/tester/expect/toBeRequired.ts new file mode 100644 index 000000000000..132b64eadc3c --- /dev/null +++ b/packages/browser/src/client/tester/expect/toBeRequired.ts @@ -0,0 +1,96 @@ +/** + * The MIT License (MIT) + * Copyright (c) 2017 Kent C. Dodds + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + */ + +import type { ExpectationResult, MatcherState } from '@vitest/expect' +import type { Locator } from '../locators' +import { getElementFromUserInput, getTag } from './utils' + +// form elements that support 'required' +const FORM_TAGS = ['SELECT', 'TEXTAREA'] + +const ARIA_FORM_TAGS = ['INPUT', 'SELECT', 'TEXTAREA'] + +const UNSUPPORTED_INPUT_TYPES = [ + 'color', + 'hidden', + 'range', + 'submit', + 'image', + 'reset', +] + +const SUPPORTED_ARIA_ROLES = [ + 'checkbox', + 'combobox', + 'gridcell', + 'listbox', + 'radiogroup', + 'spinbutton', + 'textbox', + 'tree', +] + +function isRequiredOnFormTagsExceptInput(element: HTMLElement | SVGElement) { + return FORM_TAGS.includes(getTag(element)) && element.hasAttribute('required') +} + +function isRequiredOnSupportedInput(element: HTMLElement | SVGElement) { + return ( + getTag(element) === 'INPUT' + && element.hasAttribute('required') + && ((element.hasAttribute('type') + && !UNSUPPORTED_INPUT_TYPES.includes(element.getAttribute('type') || '')) + || !element.hasAttribute('type')) + ) +} + +function isElementRequiredByARIA(element: HTMLElement | SVGElement) { + return ( + element.hasAttribute('aria-required') + && element.getAttribute('aria-required') === 'true' + && (ARIA_FORM_TAGS.includes(getTag(element)) + || (element.hasAttribute('role') + && SUPPORTED_ARIA_ROLES.includes(element.getAttribute('role') || ''))) + ) +} + +export default function toBeRequired( + this: MatcherState, + element: HTMLElement | SVGElement | Locator, +): ExpectationResult { + const htmlElement = getElementFromUserInput(element, toBeRequired, this) + + const isRequired + = isRequiredOnFormTagsExceptInput(htmlElement) + || isRequiredOnSupportedInput(htmlElement) + || isElementRequiredByARIA(htmlElement) + + return { + pass: isRequired, + message: () => { + const is = isRequired ? 'is' : 'is not' + return [ + this.utils.matcherHint( + `${this.isNot ? '.not' : ''}.toBeRequired`, + 'element', + '', + ), + '', + `Received element ${is} required:`, + ` ${this.utils.printReceived(htmlElement.cloneNode(false))}`, + ].join('\n') + }, + } +} diff --git a/packages/browser/src/client/tester/expect/toBeVisible.ts b/packages/browser/src/client/tester/expect/toBeVisible.ts new file mode 100644 index 000000000000..afa599b629bc --- /dev/null +++ b/packages/browser/src/client/tester/expect/toBeVisible.ts @@ -0,0 +1,81 @@ +/** + * The MIT License (MIT) + * Copyright (c) 2017 Kent C. Dodds + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + */ + +import type { ExpectationResult, MatcherState } from '@vitest/expect' +import type { Locator } from '../locators' +import { server } from '@vitest/browser/context' +import { beginAriaCaches, endAriaCaches, isElementVisible as ivyaIsVisible } from 'ivya/utils' +import { getElementFromUserInput } from './utils' + +export default function toBeVisible( + this: MatcherState, + actual: Element | Locator, +): ExpectationResult { + const htmlElement = getElementFromUserInput(actual, toBeVisible, this) + const isInDocument + = htmlElement.ownerDocument === htmlElement.getRootNode({ composed: true }) + beginAriaCaches() + const isVisible = isInDocument && isElementVisible(htmlElement) + endAriaCaches() + return { + pass: isVisible, + message: () => { + const is = isVisible ? 'is' : 'is not' + return [ + this.utils.matcherHint( + `${this.isNot ? '.not' : ''}.toBeVisible`, + 'element', + '', + ), + '', + `Received element ${is} visible${ + isInDocument ? '' : ' (element is not in the document)' + }:`, + ` ${this.utils.printReceived(htmlElement.cloneNode(false))}`, + ].join('\n') + }, + } +} + +function isElementVisible(element: HTMLElement | SVGElement): boolean { + const isIvyaVisible = ivyaIsVisible(element) + // if it's visible or not, but we are not in webkit, respect the result + if (server.browser !== 'webkit') { + return isIvyaVisible + } + // if we are in webkit and it's not visible, fallback to jest-dom check + // because ivya doesn't use .checkVisibility here + const detailsElement = element.closest('details') + if (!detailsElement || element === detailsElement) { + return isIvyaVisible + } + return isElementVisibleInDetails(element as HTMLElement) +} + +function isElementVisibleInDetails(targetElement: HTMLElement) { + let currentElement: HTMLElement | null = targetElement + + while (currentElement) { + if (currentElement.tagName === 'DETAILS') { + const isSummary = currentElement.querySelector('summary') === targetElement + if (!(currentElement as HTMLDetailsElement).open && !isSummary) { + return false + } + } + currentElement = currentElement.parentElement + } + + return targetElement.offsetParent !== null +} diff --git a/packages/browser/src/client/tester/expect/toContainElement.ts b/packages/browser/src/client/tester/expect/toContainElement.ts new file mode 100644 index 000000000000..b0b95a48f3cb --- /dev/null +++ b/packages/browser/src/client/tester/expect/toContainElement.ts @@ -0,0 +1,50 @@ +/** + * The MIT License (MIT) + * Copyright (c) 2017 Kent C. Dodds + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + */ + +import type { ExpectationResult, MatcherState } from '@vitest/expect' +import type { Locator } from '../locators' +import { getElementFromUserInput } from './utils' + +export default function toContainElement( + this: MatcherState, + actual: Element | Locator, + expectedElement: Element | Locator | null, +): ExpectationResult { + const containerElement = getElementFromUserInput(actual, toContainElement, this) + const childElement = expectedElement !== null + ? getElementFromUserInput(expectedElement, toContainElement, this) + : null + + return { + pass: containerElement.contains(childElement), + message: () => { + return [ + this.utils.matcherHint( + `${this.isNot ? '.not' : ''}.toContainElement`, + 'element', + 'element', + ), + '', + + this.utils.RECEIVED_COLOR(`${this.utils.stringify( + containerElement.cloneNode(false), + )} ${ + this.isNot ? 'contains:' : 'does not contain:' + } ${this.utils.stringify(childElement ? childElement.cloneNode(false) : null)} + `), + ].join('\n') + }, + } +} diff --git a/packages/browser/src/client/tester/expect/toContainHTML.ts b/packages/browser/src/client/tester/expect/toContainHTML.ts new file mode 100644 index 000000000000..6710f1f8882c --- /dev/null +++ b/packages/browser/src/client/tester/expect/toContainHTML.ts @@ -0,0 +1,53 @@ +/** + * The MIT License (MIT) + * Copyright (c) 2017 Kent C. Dodds + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + */ + +import type { ExpectationResult, MatcherState } from '@vitest/expect' +import type { Locator } from '../locators' +import { getElementFromUserInput } from './utils' + +function getNormalizedHtml(container: HTMLElement | SVGElement, htmlText: string) { + const div = container.ownerDocument.createElement('div') + div.innerHTML = htmlText + return div.innerHTML +} + +export default function toContainHTML( + this: MatcherState, + actual: Element | Locator, + htmlText: string, +): ExpectationResult { + const htmlElement = getElementFromUserInput(actual, toContainHTML, this) + + if (typeof htmlText !== 'string') { + throw new TypeError(`.toContainHTML() expects a string value, got ${htmlText}`) + } + + return { + pass: htmlElement.outerHTML.includes(getNormalizedHtml(htmlElement, htmlText)), + message: () => { + return [ + this.utils.matcherHint( + `${this.isNot ? '.not' : ''}.toContainHTML`, + 'element', + '', + ), + 'Expected:', + ` ${this.utils.EXPECTED_COLOR(htmlText)}`, + 'Received:', + ` ${this.utils.printReceived(htmlElement.cloneNode(true))}`, + ].join('\n') + }, + } +} diff --git a/packages/browser/src/client/tester/expect/toHaveAccessibleDescription.ts b/packages/browser/src/client/tester/expect/toHaveAccessibleDescription.ts new file mode 100644 index 000000000000..cf729126eaf2 --- /dev/null +++ b/packages/browser/src/client/tester/expect/toHaveAccessibleDescription.ts @@ -0,0 +1,67 @@ +/** + * The MIT License (MIT) + * Copyright (c) 2017 Kent C. Dodds + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + */ + +import type { ExpectationResult, MatcherState } from '@vitest/expect' +import type { Locator } from '../locators' +import { getElementAccessibleDescription } from 'ivya/utils' +import { getElementFromUserInput, getMessage } from './utils' + +export default function toHaveAccessibleDescription( + this: MatcherState, + actual: Element | Locator, + expectedAccessibleDescription?: string | RegExp, +): ExpectationResult { + const htmlElement = getElementFromUserInput(actual, toHaveAccessibleDescription, this) + const actualAccessibleDescription = getElementAccessibleDescription(htmlElement, false) + + const missingExpectedValue = arguments.length === 1 + + let pass = false + if (missingExpectedValue) { + // When called without an expected value we only want to validate that the element has an + // accessible description, whatever it may be. + pass = actualAccessibleDescription !== '' + } + else { + pass + = expectedAccessibleDescription instanceof RegExp + ? expectedAccessibleDescription.test(actualAccessibleDescription) + : this.equals( + actualAccessibleDescription, + expectedAccessibleDescription, + this.customTesters, + ) + } + + return { + pass, + + message: () => { + const to = this.isNot ? 'not to' : 'to' + return getMessage( + this, + this.utils.matcherHint( + `${this.isNot ? '.not' : ''}.toHaveAccessibleDescription`, + 'element', + '', + ), + `Expected element ${to} have accessible description`, + expectedAccessibleDescription, + 'Received', + actualAccessibleDescription, + ) + }, + } +} diff --git a/packages/browser/src/client/tester/expect/toHaveAccessibleErrorMessage.ts b/packages/browser/src/client/tester/expect/toHaveAccessibleErrorMessage.ts new file mode 100644 index 000000000000..f8a6de590f4c --- /dev/null +++ b/packages/browser/src/client/tester/expect/toHaveAccessibleErrorMessage.ts @@ -0,0 +1,78 @@ +/** + * The MIT License (MIT) + * Copyright (c) 2017 Kent C. Dodds + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + */ + +import type { ExpectationResult, MatcherState } from '@vitest/expect' +import type { Locator } from '../locators' +import { getElementAccessibleErrorMessage } from 'ivya/utils' +import { getElementFromUserInput, getMessage, redent } from './utils' + +export default function toHaveAccessibleErrorMessage( + this: MatcherState, + actual: Element | Locator, + expectedAccessibleErrorMessage?: string | RegExp, +): ExpectationResult { + const htmlElement = getElementFromUserInput(actual, toHaveAccessibleErrorMessage, this) + const actualAccessibleErrorMessage = getElementAccessibleErrorMessage(htmlElement) ?? '' + + const missingExpectedValue = arguments.length === 1 + + let pass = false + if (missingExpectedValue) { + // When called without an expected value we only want to validate that the element has an + // accessible description, whatever it may be. + pass = actualAccessibleErrorMessage !== '' + } + else { + pass + = expectedAccessibleErrorMessage instanceof RegExp + ? expectedAccessibleErrorMessage.test(actualAccessibleErrorMessage) + : this.equals( + actualAccessibleErrorMessage, + expectedAccessibleErrorMessage, + this.customTesters, + ) + } + + return { + pass, + + message: () => { + const to = this.isNot ? 'not to' : 'to' + if (expectedAccessibleErrorMessage == null) { + return [ + this.utils.matcherHint( + `${this.isNot ? '.not' : ''}.toHaveAccessibleErrorMessage`, + 'element', + '', + ), + `Expected element ${to} have accessible error message, but got${!this.isNot ? ' nothing' : ''}`, + this.isNot ? this.utils.RECEIVED_COLOR(redent(actualAccessibleErrorMessage, 2)) : '', + ].filter(Boolean).join('\n\n') + } + return getMessage( + this, + this.utils.matcherHint( + `${this.isNot ? '.not' : ''}.toHaveAccessibleErrorMessage`, + 'element', + '', + ), + `Expected element ${to} have accessible error message`, + expectedAccessibleErrorMessage, + 'Received', + actualAccessibleErrorMessage, + ) + }, + } +} diff --git a/packages/browser/src/client/tester/expect/toHaveAccessibleName.ts b/packages/browser/src/client/tester/expect/toHaveAccessibleName.ts new file mode 100644 index 000000000000..e0b3296aef16 --- /dev/null +++ b/packages/browser/src/client/tester/expect/toHaveAccessibleName.ts @@ -0,0 +1,62 @@ +/** + * The MIT License (MIT) + * Copyright (c) 2017 Kent C. Dodds + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + */ + +import type { ExpectationResult, MatcherState } from '@vitest/expect' +import type { Locator } from '../locators' +import { getElementAccessibleName } from 'ivya/utils' +import { getElementFromUserInput, getMessage } from './utils' + +export default function toHaveAccessibleName( + this: MatcherState, + actual: Element | Locator, + expectedAccessibleName?: string | RegExp, +): ExpectationResult { + const htmlElement = getElementFromUserInput(actual, toHaveAccessibleName, this) + const actualAccessibleName = getElementAccessibleName(htmlElement, false) + const missingExpectedValue = arguments.length === 1 + + let pass = false + if (missingExpectedValue) { + // When called without an expected value we only want to validate that the element has an + // accessible name, whatever it may be. + pass = actualAccessibleName !== '' + } + else { + pass + = expectedAccessibleName instanceof RegExp + ? expectedAccessibleName.test(actualAccessibleName) + : this.equals(actualAccessibleName, expectedAccessibleName, this.customTesters) + } + + return { + pass, + + message: () => { + const to = this.isNot ? 'not to' : 'to' + return getMessage( + this, + this.utils.matcherHint( + `${this.isNot ? '.not' : ''}.${toHaveAccessibleName.name}`, + 'element', + '', + ), + `Expected element ${to} have accessible name`, + expectedAccessibleName, + 'Received', + actualAccessibleName, + ) + }, + } +} diff --git a/packages/browser/src/client/tester/expect/toHaveAttribute.ts b/packages/browser/src/client/tester/expect/toHaveAttribute.ts new file mode 100644 index 000000000000..dfe2bb069fe4 --- /dev/null +++ b/packages/browser/src/client/tester/expect/toHaveAttribute.ts @@ -0,0 +1,74 @@ +/** + * The MIT License (MIT) + * Copyright (c) 2017 Kent C. Dodds + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + */ + +import type { ExpectationResult, MatcherState } from '@vitest/expect' +import type { Locator } from '../locators' +import { getElementFromUserInput, getMessage } from './utils' + +export default function toHaveAttribute( + this: MatcherState, + actual: Element | Locator, + attribute: string, + expectedValue?: string, +): ExpectationResult { + const htmlElement = getElementFromUserInput(actual, toHaveAttribute, this) + const isExpectedValuePresent = expectedValue !== undefined + const hasAttribute = htmlElement.hasAttribute(attribute) + const receivedValue = htmlElement.getAttribute(attribute) + return { + pass: isExpectedValuePresent + ? hasAttribute && this.equals(receivedValue, expectedValue, this.customTesters) + : hasAttribute, + message: () => { + const to = this.isNot ? 'not to' : 'to' + const receivedAttribute = hasAttribute + ? printAttribute(this.utils.stringify, attribute, receivedValue) + : null + const matcher = this.utils.matcherHint( + `${this.isNot ? '.not' : ''}.toHaveAttribute`, + 'element', + this.utils.printExpected(attribute), + { + secondArgument: isExpectedValuePresent + ? this.utils.printExpected(expectedValue) + : undefined, + comment: getAttributeComment( + this.utils.stringify, + attribute, + expectedValue, + ), + }, + ) + return getMessage( + this, + matcher, + `Expected the element ${to} have attribute`, + printAttribute(this.utils.stringify, attribute, expectedValue), + 'Received', + receivedAttribute, + ) + }, + } +} + +function printAttribute(stringify: (obj: unknown) => string, name: string, value: unknown) { + return value === undefined ? name : `${name}=${stringify(value)}` +} + +function getAttributeComment(stringify: (obj: unknown) => string, name: string, value: unknown) { + return value === undefined + ? `element.hasAttribute(${stringify(name)})` + : `element.getAttribute(${stringify(name)}) === ${stringify(value)}` +} diff --git a/packages/browser/src/client/tester/expect/toHaveClass.ts b/packages/browser/src/client/tester/expect/toHaveClass.ts new file mode 100644 index 000000000000..0757dddc0377 --- /dev/null +++ b/packages/browser/src/client/tester/expect/toHaveClass.ts @@ -0,0 +1,137 @@ +/** + * The MIT License (MIT) + * Copyright (c) 2017 Kent C. Dodds + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + */ + +import type { ExpectationResult, MatcherState } from '@vitest/expect' +import type { Locator } from '../locators' +import { getElementFromUserInput, getMessage } from './utils' + +export default function toHaveClass( + this: MatcherState, + actual: Element | Locator, + ...params: (string | RegExp)[] | [string, options?: { exact: boolean }] +): ExpectationResult { + const htmlElement = getElementFromUserInput(actual, toHaveClass, this) + const { expectedClassNames, options } = getExpectedClassNamesAndOptions(params) + + const received = splitClassNames(htmlElement.getAttribute('class')) + const expected = expectedClassNames.reduce( + (acc, className) => { + return acc.concat( + typeof className === 'string' || !className + ? splitClassNames(className) + : className, + ) + }, + [] as (string | RegExp)[], + ) + + const hasRegExp = expected.some(className => className instanceof RegExp) + if (options.exact && hasRegExp) { + throw new Error('Exact option does not support RegExp expected class names') + } + + if (options.exact) { + return { + pass: isSubset(expected, received) && expected.length === received.length, + message: () => { + const to = this.isNot ? 'not to' : 'to' + return getMessage( + this, + this.utils.matcherHint( + `${this.isNot ? '.not' : ''}.toHaveClass`, + 'element', + this.utils.printExpected(expected.join(' ')), + ), + `Expected the element ${to} have EXACTLY defined classes`, + expected.join(' '), + 'Received', + received.join(' '), + ) + }, + } + } + + return expected.length > 0 + ? { + pass: isSubset(expected, received), + message: () => { + const to = this.isNot ? 'not to' : 'to' + return getMessage( + this, + this.utils.matcherHint( + `${this.isNot ? '.not' : ''}.toHaveClass`, + 'element', + this.utils.printExpected(expected.join(' ')), + ), + `Expected the element ${to} have class`, + expected.join(' '), + 'Received', + received.join(' '), + ) + }, + } + : { + pass: this.isNot ? received.length > 0 : false, + message: () => + this.isNot + ? getMessage( + this, + this.utils.matcherHint('.not.toHaveClass', 'element', ''), + 'Expected the element to have classes', + '(none)', + 'Received', + received.join(' '), + ) + : [ + this.utils.matcherHint(`.toHaveClass`, 'element'), + 'At least one expected class must be provided.', + ].join('\n'), + } +} + +function getExpectedClassNamesAndOptions( + params: (string | RegExp)[] | [string, options?: { exact: boolean }], +): { + expectedClassNames: (string | RegExp)[] + options: { exact: boolean } + } { + const lastParam = params.pop() + let expectedClassNames, options + + if (typeof lastParam === 'object' && !(lastParam instanceof RegExp)) { + expectedClassNames = params + options = lastParam + } + else { + expectedClassNames = params.concat(lastParam) + options = { exact: false } + } + return { expectedClassNames: expectedClassNames as string[], options } +} + +function splitClassNames(str: string | undefined | null): string[] { + if (!str) { + return [] + } + return str.split(/\s+/).filter(s => s.length > 0) +} + +function isSubset(subset: (string | RegExp)[], superset: string[]) { + return subset.every(strOrRegexp => + typeof strOrRegexp === 'string' + ? superset.includes(strOrRegexp) + : superset.some(className => strOrRegexp.test(className)), + ) +} diff --git a/packages/browser/src/client/tester/expect/toHaveDisplayValue.ts b/packages/browser/src/client/tester/expect/toHaveDisplayValue.ts new file mode 100644 index 000000000000..66c8d96cbdb9 --- /dev/null +++ b/packages/browser/src/client/tester/expect/toHaveDisplayValue.ts @@ -0,0 +1,82 @@ +/** + * The MIT License (MIT) + * Copyright (c) 2017 Kent C. Dodds + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + */ + +import type { ExpectationResult, MatcherState } from '@vitest/expect' +import type { Locator } from '../locators' +import { getElementFromUserInput, getMessage, getTag, isInputElement } from './utils' + +export default function toHaveDisplayValue( + this: MatcherState, + actual: Element | Locator, + expectedValue: string | RegExp | Array, +): ExpectationResult { + const htmlElement = getElementFromUserInput(actual, toHaveDisplayValue, this) + const tagName = getTag(htmlElement) + + if (!['SELECT', 'INPUT', 'TEXTAREA'].includes(tagName)) { + throw new Error( + '.toHaveDisplayValue() currently supports only input, textarea or select elements, try with another matcher instead.', + ) + } + + if (isInputElement(htmlElement) && ['radio', 'checkbox'].includes(htmlElement.type)) { + throw new Error( + `.toHaveDisplayValue() currently does not support input[type="${htmlElement.type}"], try with another matcher instead.`, + ) + } + + const values = getValues(tagName, htmlElement) + const expectedValues = getExpectedValues(expectedValue) + const numberOfMatchesWithValues = expectedValues.filter(expected => + values.some(value => + expected instanceof RegExp + ? expected.test(value) + : this.equals(value, String(expected), this.customTesters), + ), + ).length + + const matchedWithAllValues = numberOfMatchesWithValues === values.length + const matchedWithAllExpectedValues + = numberOfMatchesWithValues === expectedValues.length + + return { + pass: matchedWithAllValues && matchedWithAllExpectedValues, + message: () => + getMessage( + this, + this.utils.matcherHint( + `${this.isNot ? '.not' : ''}.toHaveDisplayValue`, + 'element', + '', + ), + `Expected element ${this.isNot ? 'not ' : ''}to have display value`, + expectedValue, + 'Received', + values, + ), + } +} + +function getValues(tagName: string, htmlElement: HTMLElement | SVGElement) { + return tagName === 'SELECT' + ? Array.from(htmlElement as HTMLSelectElement) + .filter(option => (option as HTMLOptionElement).selected) + .map(option => option.textContent || '') + : [(htmlElement as HTMLInputElement).value] +} + +function getExpectedValues(expectedValue: string | RegExp | Array): Array { + return Array.isArray(expectedValue) ? expectedValue : [expectedValue] +} diff --git a/packages/browser/src/client/tester/expect/toHaveFocus.ts b/packages/browser/src/client/tester/expect/toHaveFocus.ts new file mode 100644 index 000000000000..eeee69f4b443 --- /dev/null +++ b/packages/browser/src/client/tester/expect/toHaveFocus.ts @@ -0,0 +1,52 @@ +/** + * The MIT License (MIT) + * Copyright (c) 2017 Kent C. Dodds + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + */ + +import type { ExpectationResult, MatcherState } from '@vitest/expect' +import type { Locator } from '../locators' +import { getElementFromUserInput } from './utils' + +export default function toHaveFocus( + this: MatcherState, + actual: Element | Locator, +): ExpectationResult { + const htmlElement = getElementFromUserInput(actual, toHaveFocus, this) + + return { + pass: htmlElement.ownerDocument.activeElement === htmlElement, + message: () => { + return [ + this.utils.matcherHint( + `${this.isNot ? '.not' : ''}.toHaveFocus`, + 'element', + '', + ), + '', + ...(this.isNot + ? [ + 'Received element is focused:', + ` ${this.utils.printReceived(htmlElement)}`, + ] + : [ + 'Expected element with focus:', + ` ${this.utils.printExpected(htmlElement)}`, + 'Received element with focus:', + ` ${this.utils.printReceived( + htmlElement.ownerDocument.activeElement, + )}`, + ]), + ].join('\n') + }, + } +} diff --git a/packages/browser/src/client/tester/expect/toHaveFormValues.ts b/packages/browser/src/client/tester/expect/toHaveFormValues.ts new file mode 100644 index 000000000000..a98e35ba236c --- /dev/null +++ b/packages/browser/src/client/tester/expect/toHaveFormValues.ts @@ -0,0 +1,119 @@ +/** + * The MIT License (MIT) + * Copyright (c) 2017 Kent C. Dodds + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + */ + +import type { ExpectationResult, MatcherState } from '@vitest/expect' +import type { Locator } from '../locators' +import { cssEscape } from 'ivya/utils' +import { arrayAsSetComparison, getElementFromUserInput, getSingleElementValue, getTag } from './utils' + +export default function toHaveFormValues( + this: MatcherState, + actual: Element | Locator, + expectedValues: Record, +): ExpectationResult { + const formElement = getElementFromUserInput(actual, toHaveFormValues, this) + + if (!(formElement instanceof HTMLFieldSetElement) && !(formElement instanceof HTMLFormElement)) { + throw new TypeError(`toHaveFormValues must be called on a form or a fieldset, instead got ${getTag(formElement)}`) + } + + if (!expectedValues || typeof expectedValues !== 'object') { + throw new TypeError( + `toHaveFormValues must be called with an object of expected form values. Got ${expectedValues}`, + ) + } + + const formValues = getAllFormValues(formElement) + return { + pass: Object.entries(expectedValues).every(([name, expectedValue]) => + this.equals(formValues[name], expectedValue, [arrayAsSetComparison, ...this.customTesters]), + ), + message: () => { + const to = this.isNot ? 'not to' : 'to' + const matcher = `${this.isNot ? '.not' : ''}.toHaveFormValues` + + const commonKeyValues: Record = {} + for (const key in formValues) { + if (!Object.hasOwn(expectedValues, key)) { + continue + } + commonKeyValues[key] = formValues[key] + } + + return [ + this.utils.matcherHint(matcher, 'element', ''), + `Expected the element ${to} have form values`, + this.utils.diff(expectedValues, commonKeyValues), + ].join('\n\n') + }, + } +} + +// Returns the combined value of several elements that have the same name +// e.g. radio buttons or groups of checkboxes +function getMultiElementValue(elements: HTMLInputElement[]) { + const types = [...new Set(elements.map(element => element.type))] + if (types.length !== 1) { + throw new Error( + 'Multiple form elements with the same name must be of the same type', + ) + } + switch (types[0]) { + case 'radio': { + const selected = elements.find(radio => radio.checked) + return selected ? selected.value : undefined + } + case 'checkbox': + return elements + .filter(checkbox => checkbox.checked) + .map(checkbox => checkbox.value) + default: + // NOTE: Not even sure this is a valid use case, but just in case... + return elements.map(element => element.value) + } +} + +function getFormValue(container: HTMLFormElement | HTMLFieldSetElement, name: string) { + const elements = [...container.querySelectorAll(`[name="${cssEscape(name)}"]`)] + /* istanbul ignore if */ + if (elements.length === 0) { + return undefined // shouldn't happen, but just in case + } + switch (elements.length) { + case 1: + return getSingleElementValue(elements[0]) + default: + return getMultiElementValue(elements as HTMLInputElement[]) + } +} + +// Strips the `[]` suffix off a form value name +function getPureName(name: string) { + return /\[\]$/.test(name) ? name.slice(0, -2) : name +} + +function getAllFormValues(container: HTMLFormElement | HTMLFieldSetElement) { + const values: Record = {} + + for (const element of container.elements) { + if (!('name' in element)) { + continue + } + const name = element.name as string + values[getPureName(name)] = getFormValue(container, name) + } + + return values +} diff --git a/packages/browser/src/client/tester/expect/toHaveRole.ts b/packages/browser/src/client/tester/expect/toHaveRole.ts new file mode 100644 index 000000000000..0f04ec9215e2 --- /dev/null +++ b/packages/browser/src/client/tester/expect/toHaveRole.ts @@ -0,0 +1,48 @@ +/** + * The MIT License (MIT) + * Copyright (c) 2017 Kent C. Dodds + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + */ + +import type { ExpectationResult, MatcherState } from '@vitest/expect' +import type { Locator } from '../locators' +import { beginAriaCaches, endAriaCaches, getAriaRole } from 'ivya/utils' +import { getElementFromUserInput, getMessage } from './utils' + +export default function toHaveRole( + this: MatcherState, + actual: Element | Locator, + expectedRole: string, +): ExpectationResult { + const htmlElement = getElementFromUserInput(actual, toHaveRole, this) + beginAriaCaches() + const actualRole = getAriaRole(htmlElement) + endAriaCaches() + return { + pass: actualRole === expectedRole, + message: () => { + const to = this.isNot ? 'not to' : 'to' + return getMessage( + this, + this.utils.matcherHint( + `${this.isNot ? '.not' : ''}.toHaveRole`, + 'element', + '', + ), + `Expected element ${to} have role`, + expectedRole, + 'Received', + actualRole, + ) + }, + } +} diff --git a/packages/browser/src/client/tester/expect/toHaveSelection.ts b/packages/browser/src/client/tester/expect/toHaveSelection.ts new file mode 100644 index 000000000000..f27dd77cfb62 --- /dev/null +++ b/packages/browser/src/client/tester/expect/toHaveSelection.ts @@ -0,0 +1,134 @@ +/** + * The MIT License (MIT) + * Copyright (c) 2017 Kent C. Dodds + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + */ + +import type { ExpectationResult, MatcherState } from '@vitest/expect' +import type { Locator } from '../locators' +import { arrayAsSetComparison, getElementFromUserInput, getMessage, getTag } from './utils' + +export default function toHaveSelection( + this: MatcherState, + element: HTMLElement | SVGElement | Locator, + expectedSelection: string, +): ExpectationResult { + const htmlElement = getElementFromUserInput(element, toHaveSelection, this) + + const expectsSelection = expectedSelection !== undefined + + if (expectsSelection && typeof expectedSelection !== 'string') { + throw new Error(`expected selection must be a string or undefined`) + } + + const receivedSelection = getSelection(htmlElement) + + return { + pass: expectsSelection + ? this.equals(receivedSelection, expectedSelection, [arrayAsSetComparison, ...this.customTesters]) + : Boolean(receivedSelection), + message: () => { + const to = this.isNot ? 'not to' : 'to' + const matcher = this.utils.matcherHint( + `${this.isNot ? '.not' : ''}.toHaveSelection`, + 'element', + expectedSelection, + ) + return getMessage( + this, + matcher, + `Expected the element ${to} have selection`, + expectsSelection ? expectedSelection : '(any)', + 'Received', + receivedSelection, + ) + }, + } +} + +function getSelection(element: HTMLElement | SVGElement): string { + const selection = element.ownerDocument.getSelection() + + if (!selection) { + return '' + } + + if (['INPUT', 'TEXTAREA'].includes(getTag(element))) { + const input = element as HTMLInputElement | HTMLTextAreaElement + if (['radio', 'checkbox'].includes(input.type)) { + return '' + } + if (input.selectionStart == null || input.selectionEnd == null) { + return '' + } + return input.value + .toString() + .substring(input.selectionStart, input.selectionEnd) + } + + if (selection.anchorNode === null || selection.focusNode === null) { + // No selection + return '' + } + + const originalRange = selection.getRangeAt(0) + const temporaryRange = element.ownerDocument.createRange() + + if (selection.containsNode(element, false)) { + // Whole element is inside selection + temporaryRange.selectNodeContents(element) + selection.removeAllRanges() + selection.addRange(temporaryRange) + } + else if ( + element.contains(selection.anchorNode) + && element.contains(selection.focusNode) + ) { + // Element contains selection, nothing to do + } + else { + // Element is partially selected + const selectionStartsWithinElement + = element === originalRange.startContainer + || element.contains(originalRange.startContainer) + const selectionEndsWithinElement + = element === originalRange.endContainer + || element.contains(originalRange.endContainer) + selection.removeAllRanges() + + if (selectionStartsWithinElement || selectionEndsWithinElement) { + temporaryRange.selectNodeContents(element) + + if (selectionStartsWithinElement) { + temporaryRange.setStart( + originalRange.startContainer, + originalRange.startOffset, + ) + } + if (selectionEndsWithinElement) { + temporaryRange.setEnd( + originalRange.endContainer, + originalRange.endOffset, + ) + } + + selection.addRange(temporaryRange) + } + } + + const result = selection.toString() + + selection.removeAllRanges() + selection.addRange(originalRange) + + return result +} diff --git a/packages/browser/src/client/tester/expect/toHaveStyle.ts b/packages/browser/src/client/tester/expect/toHaveStyle.ts new file mode 100644 index 000000000000..df7e28ade92c --- /dev/null +++ b/packages/browser/src/client/tester/expect/toHaveStyle.ts @@ -0,0 +1,178 @@ +import type { ExpectationResult, MatcherState } from '@vitest/expect' +import type { Locator } from '../locators' +import { server } from '@vitest/browser/context' +import { getElementFromUserInput } from './utils' + +const browser = server.config.browser.name + +// these values should keep the `style` attribute instead of computing px to be consistent with jsdom +// https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_cascade/used_value#difference_from_computed_value +const usedValuesProps = new Set([ + 'backgroundPosition', + 'background-position', + 'bottom', + 'left', + 'right', + 'top', + 'height', + 'width', + 'margin-bottom', + 'marginBottom', + 'margin-left', + 'marginLeft', + 'margin-right', + 'marginRight', + 'margin-top', + 'marginTop', + 'min-height', + 'minHeight', + 'min-width', + 'minWidth', + 'padding-bottom', + 'padding-left', + 'padding-right', + 'padding-top', + 'text-indent', + 'paddingBottom', + 'paddingLeft', + 'paddingRight', + 'paddingTop', + 'textIndent', +]) + +export default function toHaveStyle( + this: MatcherState, + actual: Element | Locator, + css: string | Record, +): ExpectationResult { + const htmlElement = getElementFromUserInput(actual, toHaveStyle, this) + const { getComputedStyle } = htmlElement.ownerDocument.defaultView! + + const expected = typeof css === 'object' + ? getStyleFromObjectCSS(css) + : computeCSSStyleDeclaration(css) + const received = getComputedStyle(htmlElement) + const receivedCustomKeys = new Set(Array.from(htmlElement.style)) + + return { + pass: isSubset(expected, htmlElement, received, receivedCustomKeys), + message: () => { + const matcher = `${this.isNot ? '.not' : ''}.toHaveStyle` + const expectedKeys = new Set(Object.keys(expected)) + const receivedObject = Array.from(received) + .filter(prop => expectedKeys.has(prop)) + .reduce( + (obj, prop) => { + const styleSheet = receivedCustomKeys.has(prop) && usedValuesProps.has(prop) + ? htmlElement.style + : received + obj[prop] = styleSheet[prop as 'color'] + return obj + }, + {} as Record, + ) + const receivedString = printoutObjectStyles(receivedObject) + const diff = receivedString === '' + ? 'Expected styles could not be parsed by the browser. Did you make a typo?' + : this.utils.diff( + printoutObjectStyles(expected), + receivedString, + ) + return [ + this.utils.matcherHint(matcher, 'element', ''), + diff, + ].join('\n\n') + }, + } +} + +function getStyleFromObjectCSS(css: Record): Record { + const doc = browser === 'chrome' || browser === 'chromium' + ? document + : document.implementation.createHTMLDocument('') + + const copy = doc.createElement('div') + doc.body.appendChild(copy) + const keys = Object.keys(css) + + keys.forEach((property) => { + copy.style[property as 'color'] = css[property] as string + }) + + const styles: Record = {} + // to get normalized colors (blue -> rgb(0, 0, 255)) + const computedStyles = window.getComputedStyle(copy) + keys.forEach((property) => { + const styleSheet = usedValuesProps.has(property) ? copy.style : computedStyles + const value = styleSheet[property as 'color'] + // ignore invalid keys + if (value != null) { + styles[property] = value + } + }) + copy.remove() + + return styles +} + +function computeCSSStyleDeclaration(css: string): Record { + // on chromium for styles to be computed, they need to be inserted into the actual document + // webkit will also not compute _some_ style like transform if it's not in the document + const doc = browser === 'chrome' || browser === 'chromium' || browser === 'webkit' + ? document + : document.implementation.createHTMLDocument('') + + const rootElement = doc.createElement('div') + rootElement.setAttribute('style', css.replace(/\n/g, '')) + doc.body.appendChild(rootElement) + + const computedStyle = window.getComputedStyle(rootElement) + + const styleDeclaration = Array.from(rootElement.style).reduce((acc, prop) => { + acc[prop] = usedValuesProps.has(prop) + ? rootElement.style.getPropertyValue(prop) + : computedStyle.getPropertyValue(prop) + return acc + }, {} as Record) + rootElement.remove() + return styleDeclaration +} + +function printoutObjectStyles(styles: Record): string { + return Object.keys(styles) + .sort() + .map(prop => `${prop}: ${styles[prop]};`) + .join('\n') +} + +function isSubset( + styles: Record, + element: HTMLElement | SVGElement, + computedStyle: CSSStyleDeclaration, + receivedCustomKeys: Set, +): boolean { + const keys = Object.keys(styles) + if (!keys.length) { + return false + } + return keys.every((prop) => { + const value = styles[prop as 'color'] + const isCustomProperty = prop.startsWith('--') + const spellingVariants = [prop] + if (!isCustomProperty) { + spellingVariants.push(prop.toLowerCase()) + } + + const pass = spellingVariants.some( + (name) => { + const styleSheet = receivedCustomKeys.has(prop) && usedValuesProps.has(prop) + ? element.style + : computedStyle + return styleSheet[name as 'color'] === value + || styleSheet.getPropertyValue(name) === value + }, + ) + return pass + }, + ) +} diff --git a/packages/browser/src/client/tester/expect/toHaveTextContent.ts b/packages/browser/src/client/tester/expect/toHaveTextContent.ts new file mode 100644 index 000000000000..a991c3262cc6 --- /dev/null +++ b/packages/browser/src/client/tester/expect/toHaveTextContent.ts @@ -0,0 +1,54 @@ +/** + * The MIT License (MIT) + * Copyright (c) 2017 Kent C. Dodds + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + */ + +import type { ExpectationResult, MatcherState } from '@vitest/expect' +import type { Locator } from '../locators' +import { getMessage, getNodeFromUserInput, matches, normalize } from './utils' + +export default function toHaveTextContent( + this: MatcherState, + actual: Element | Locator, + matcher: string | RegExp, + options: { normalizeWhitespace?: boolean } = { normalizeWhitespace: true }, +): ExpectationResult { + const node = getNodeFromUserInput(actual, toHaveTextContent, this) + + const textContent = options.normalizeWhitespace + ? normalize(node.textContent || '') + : (node.textContent || '').replace(/\u00A0/g, ' ') // Replace   with normal spaces + + const checkingWithEmptyString = textContent !== '' && matcher === '' + + return { + pass: !checkingWithEmptyString && matches(textContent, matcher), + message: () => { + const to = this.isNot ? 'not to' : 'to' + return getMessage( + this, + this.utils.matcherHint( + `${this.isNot ? '.not' : ''}.toHaveTextContent`, + 'element', + '', + ), + checkingWithEmptyString + ? `Checking with empty string will always match, use .toBeEmptyDOMElement() instead` + : `Expected element ${to} have text content`, + matcher, + 'Received', + textContent, + ) + }, + } +} diff --git a/packages/browser/src/client/tester/expect/toHaveValue.ts b/packages/browser/src/client/tester/expect/toHaveValue.ts new file mode 100644 index 000000000000..fb53e2965cfb --- /dev/null +++ b/packages/browser/src/client/tester/expect/toHaveValue.ts @@ -0,0 +1,68 @@ +/** + * The MIT License (MIT) + * Copyright (c) 2017 Kent C. Dodds + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + */ + +import type { ExpectationResult, MatcherState } from '@vitest/expect' +import type { Locator } from '../locators' +import { arrayAsSetComparison, getElementFromUserInput, getMessage, getSingleElementValue, isInputElement } from './utils' + +export default function toHaveValue( + this: MatcherState, + actual: Element | Locator, + expectedValue?: string, +): ExpectationResult { + const htmlElement = getElementFromUserInput(actual, toHaveValue, this) + + if ( + isInputElement(htmlElement) + && ['checkbox', 'radio'].includes(htmlElement.type) + ) { + throw new Error( + 'input with type=checkbox or type=radio cannot be used with .toHaveValue(). Use .toBeChecked() for type=checkbox or .toHaveFormValues() instead', + ) + } + + const receivedValue = getSingleElementValue(htmlElement) + const expectsValue = expectedValue !== undefined + + let expectedTypedValue = expectedValue + let receivedTypedValue = receivedValue + // eslint-disable-next-line eqeqeq + if (expectedValue == receivedValue && expectedValue !== receivedValue) { + expectedTypedValue = `${expectedValue} (${typeof expectedValue})` + receivedTypedValue = `${receivedValue} (${typeof receivedValue})` + } + + return { + pass: expectsValue + ? this.equals(receivedValue, expectedValue, [arrayAsSetComparison, ...this.customTesters]) + : Boolean(receivedValue), + message: () => { + const to = this.isNot ? 'not to' : 'to' + const matcher = this.utils.matcherHint( + `${this.isNot ? '.not' : ''}.toHaveValue`, + 'element', + expectedValue, + ) + return getMessage( + this, + matcher, + `Expected the element ${to} have value`, + expectsValue ? expectedTypedValue : '(any)', + 'Received', + receivedTypedValue, + ) + }, + } +} diff --git a/packages/browser/src/client/tester/expect/utils.ts b/packages/browser/src/client/tester/expect/utils.ts new file mode 100644 index 000000000000..ab8163f4a93f --- /dev/null +++ b/packages/browser/src/client/tester/expect/utils.ts @@ -0,0 +1,281 @@ +/** + * The MIT License (MIT) + * Copyright (c) 2017 Kent C. Dodds + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + */ + +import type { MatcherState } from '@vitest/expect' +import { Locator } from '../locators' + +export function getElementFromUserInput( + elementOrLocator: Element | Locator | null, + // TODO: minifier doesn't keep names, so we need to update this + matcherFn: (...args: any) => any, + context: MatcherState, +): HTMLElement | SVGElement { + if (elementOrLocator instanceof Locator) { + elementOrLocator = elementOrLocator.element() + } + + if ( + elementOrLocator instanceof HTMLElement + || elementOrLocator instanceof SVGElement + ) { + return elementOrLocator + } + + throw new UserInputElementTypeError( + elementOrLocator, + matcherFn, + context, + ) +} + +export function getNodeFromUserInput( + elementOrLocator: Element | Locator, + matcherFn: (...args: any) => any, + context: MatcherState, +): Node { + if (elementOrLocator instanceof Locator) { + elementOrLocator = elementOrLocator.element() + } + + if ( + elementOrLocator instanceof Node + ) { + return elementOrLocator + } + + throw new UserInputNodeTypeError( + elementOrLocator, + matcherFn, + context, + ) +} + +export function getMessage( + context: MatcherState, + matcher: string, + expectedLabel: string, + expectedValue: unknown, + receivedLabel: string, + receivedValue: unknown, +): string { + return [ + `${matcher}\n`, + + `${expectedLabel}:\n${context.utils.EXPECTED_COLOR( + redent(display(context, expectedValue), 2), + )}`, + + `${receivedLabel}:\n${context.utils.RECEIVED_COLOR( + redent(display(context, receivedValue), 2), + )}`, + ].join('\n') +} + +export function redent(string: string, count: number): string { + return indentString(stripIndent(string), count) +} + +function indentString(string: string, count: number) { + const regex = /^(?!\s*$)/gm + + return string.replace(regex, ' '.repeat(count)) +} + +function minIndent(string: string) { + const match = string.match(/^[ \t]*(?=\S)/gm) + + if (!match) { + return 0 + } + + return match.reduce((r, a) => Math.min(r, a.length), Infinity) +} + +function stripIndent(string: string) { + const indent = minIndent(string) + + if (indent === 0) { + return string + } + + const regex = new RegExp(`^[ \\t]{${indent}}`, 'gm') + + return string.replace(regex, '') +} + +function display(context: MatcherState, value: unknown) { + return typeof value === 'string' ? value : context.utils.stringify(value) +} + +interface ToSentenceOptions { + wordConnector?: string + lastWordConnector?: string +} + +export function toSentence( + array: string[], + { wordConnector = ', ', lastWordConnector = ' and ' }: ToSentenceOptions = {}, +): string { + return [array.slice(0, -1).join(wordConnector), array[array.length - 1]].join( + array.length > 1 ? lastWordConnector : '', + ) +} + +class GenericTypeError extends Error { + constructor(expectedString: string, received: unknown, matcherFn: (...args: any) => any, context: MatcherState) { + super() + + if (Error.captureStackTrace) { + Error.captureStackTrace(this, matcherFn) + } + let withType = '' + try { + withType = context.utils.printWithType( + 'Received', + received, + context.utils.printReceived, + ) + } + catch { + // Can throw for Document: + // https://github.com/jsdom/jsdom/issues/2304 + } + this.message = [ + context.utils.matcherHint( + `${context.isNot ? '.not' : ''}.${matcherFn.name}`, + 'received', + '', + ), + '', + + `${context.utils.RECEIVED_COLOR( + 'received', + )} value must ${expectedString} or a Locator that returns ${expectedString}.`, + withType, + ].join('\n') + } +} + +class UserInputElementTypeError extends GenericTypeError { + constructor( + element: unknown, + matcherFn: (...args: any) => any, + context: MatcherState, + ) { + super('an HTMLElement or an SVGElement', element, matcherFn, context) + } +} + +class UserInputNodeTypeError extends GenericTypeError { + constructor( + element: unknown, + matcherFn: (...args: any) => any, + context: MatcherState, + ) { + super('a Node', element, matcherFn, context) + } +} + +export function getTag(element: Element): string { + // Named inputs, e.g. , will be exposed as fields on the parent
+ // and override its properties. + if (element instanceof HTMLFormElement) { + return 'FORM' + } + // Elements from the svg namespace do not have uppercase tagName right away. + return element.tagName.toUpperCase() +} + +export function isInputElement(element: HTMLElement | SVGElement): element is HTMLInputElement { + return getTag(element) === 'INPUT' +} + +type SimpleInputValue = string | number | boolean | null + +export function getSingleElementValue( + element: Element | undefined, +): SimpleInputValue | string[] | undefined { + if (!element) { + return undefined + } + + switch (getTag(element)) { + case 'INPUT': + return getInputValue(element as HTMLInputElement) + case 'SELECT': + return getSelectValue(element as HTMLSelectElement) + default: { + return (element as any).value ?? getAccessibleValue(element) + } + } +} + +function getSelectValue({ multiple, options }: HTMLSelectElement) { + const selectedOptions = [...options].filter(option => option.selected) + + if (multiple) { + return [...selectedOptions].map(opt => opt.value) + } + /* istanbul ignore if */ + if (selectedOptions.length === 0) { + return undefined // Couldn't make this happen, but just in case + } + return selectedOptions[0].value +} + +function getInputValue(inputElement: HTMLInputElement) { + switch (inputElement.type) { + case 'number': + return inputElement.value === '' ? null : Number(inputElement.value) + case 'checkbox': + return inputElement.checked + default: + return inputElement.value + } +} + +const rolesSupportingValues = ['meter', 'progressbar', 'slider', 'spinbutton'] +function getAccessibleValue(element: Element) { + if (!rolesSupportingValues.includes(element.getAttribute('role') || '')) { + return undefined + } + return Number(element.getAttribute('aria-valuenow')) +} + +export function normalize(text: string): string { + return text.replace(/\s+/g, ' ').trim() +} + +export function matches(textToMatch: string, matcher: string | RegExp): boolean { + if (matcher instanceof RegExp) { + return matcher.test(textToMatch) + } + else { + return textToMatch.includes(String(matcher)) + } +} + +export function arrayAsSetComparison(a: unknown, b: unknown): boolean | undefined { + if (Array.isArray(a) && Array.isArray(b)) { + const setB = new Set(b) + for (const item of new Set(a)) { + if (!setB.has(item)) { + return false + } + } + return true + } + return undefined +} diff --git a/packages/browser/src/client/tester/jest-dom.ts b/packages/browser/src/client/tester/jest-dom.ts deleted file mode 100644 index 178952e1ec77..000000000000 --- a/packages/browser/src/client/tester/jest-dom.ts +++ /dev/null @@ -1 +0,0 @@ -export type { default } from '@testing-library/jest-dom/matchers' diff --git a/packages/browser/src/client/tester/locators/preview.ts b/packages/browser/src/client/tester/locators/preview.ts index 3c15f0043b3f..b5ca9727ec48 100644 --- a/packages/browser/src/client/tester/locators/preview.ts +++ b/packages/browser/src/client/tester/locators/preview.ts @@ -84,8 +84,8 @@ class PreviewLocator extends Locator { return userEvent.upload(this.element(), file) } - selectOptions(options_: string | string[] | HTMLElement | HTMLElement[] | Locator | Locator[]): Promise { - return userEvent.selectOptions(this.element(), options_) + selectOptions(options: string | string[] | HTMLElement | HTMLElement[] | Locator | Locator[]): Promise { + return userEvent.selectOptions(this.element(), options) } clear(): Promise { diff --git a/packages/browser/src/client/tester/runner.ts b/packages/browser/src/client/tester/runner.ts index 50ac5636e15f..d8dd797d30de 100644 --- a/packages/browser/src/client/tester/runner.ts +++ b/packages/browser/src/client/tester/runner.ts @@ -171,13 +171,13 @@ export async function initiateRunner( const runner = new BrowserRunner({ config, }) + cachedRunner = runner const [diffOptions] = await Promise.all([ loadDiffConfig(config, executor as unknown as VitestExecutor), loadSnapshotSerializers(config, executor as unknown as VitestExecutor), ]) runner.config.diffOptions = diffOptions - cachedRunner = runner getWorkerState().onFilterStackTrace = (stack: string) => { const stacks = parseStacktrace(stack, { getSourceMap(file) { diff --git a/packages/browser/src/client/tester/tester.ts b/packages/browser/src/client/tester/tester.ts index df9b218ef789..dd891e15eedd 100644 --- a/packages/browser/src/client/tester/tester.ts +++ b/packages/browser/src/client/tester/tester.ts @@ -1,9 +1,15 @@ import { channel, client, onCancel } from '@vitest/browser/client' import { page, server, userEvent } from '@vitest/browser/context' -import { collectTests, setupCommonEnv, SpyModule, startCoverageInsideWorker, startTests, stopCoverageInsideWorker } from 'vitest/browser' +import { + collectTests, + setupCommonEnv, + SpyModule, + startCoverageInsideWorker, + startTests, + stopCoverageInsideWorker, +} from 'vitest/browser' import { executor, getBrowserState, getConfig, getWorkerState } from '../utils' import { setupDialogsSpy } from './dialog' -import { setupExpectDom } from './expect-element' import { setupConsoleLogSpy } from './logger' import { VitestBrowserClientMocker } from './mocker' import { createModuleMockerInterceptor } from './msw' @@ -52,7 +58,6 @@ async function prepareTestEnvironment(files: string[]) { setupConsoleLogSpy() setupDialogsSpy() - setupExpectDom() const runner = await initiateRunner(state, mocker, config) diff --git a/packages/browser/src/node/plugin.ts b/packages/browser/src/node/plugin.ts index 7439c0aedb61..9257a95839c0 100644 --- a/packages/browser/src/node/plugin.ts +++ b/packages/browser/src/node/plugin.ts @@ -505,6 +505,14 @@ body { }, injectTo: 'head' as const, }, + { + tag: 'script', + attrs: { + type: 'module', + src: parentServer.matchersUrl, + }, + injectTo: 'head' as const, + }, parentServer.locatorsUrl ? { tag: 'script', diff --git a/packages/browser/src/node/project.ts b/packages/browser/src/node/project.ts index 0dcfbe1f8657..8e3f4deeda12 100644 --- a/packages/browser/src/node/project.ts +++ b/packages/browser/src/node/project.ts @@ -19,7 +19,6 @@ import { getBrowserProvider } from './utils' export class ProjectBrowser implements IProjectBrowser { public testerHtml: Promise | string public testerFilepath: string - public locatorsUrl: string | undefined public provider!: BrowserProvider public vitest: Vitest diff --git a/packages/browser/src/node/projectParent.ts b/packages/browser/src/node/projectParent.ts index a3e1986d695e..703c88f17f1c 100644 --- a/packages/browser/src/node/projectParent.ts +++ b/packages/browser/src/node/projectParent.ts @@ -35,6 +35,7 @@ export class ParentBrowserProject { public injectorJs: Promise | string public errorCatcherUrl: string public locatorsUrl: string | undefined + public matchersUrl: string public stateJs: Promise | string public commands: Record> = {} @@ -131,6 +132,7 @@ export class ParentBrowserProject { if (builtinProviders.includes(providerName)) { this.locatorsUrl = join('/@fs/', distRoot, 'locators', `${providerName}.js`) } + this.matchersUrl = join('/@fs/', distRoot, 'expect-element.js') this.stateJs = readFile( resolve(distRoot, 'state.js'), 'utf-8', diff --git a/packages/expect/src/jest-matcher-utils.ts b/packages/expect/src/jest-matcher-utils.ts index 18fa5620f181..98480ceb6114 100644 --- a/packages/expect/src/jest-matcher-utils.ts +++ b/packages/expect/src/jest-matcher-utils.ts @@ -107,6 +107,7 @@ export function getMatcherUtils(): { printReceived: typeof printReceived printExpected: typeof printExpected printDiffOrStringify: typeof printDiffOrStringify + printWithType: typeof printWithType } { return { EXPECTED_COLOR, @@ -120,9 +121,24 @@ export function getMatcherUtils(): { printReceived, printExpected, printDiffOrStringify, + printWithType, } } +export function printWithType( + name: string, + value: T, + print: (value: T) => string, +): string { + const type = getType(value) + const hasType + = type !== 'null' && type !== 'undefined' + ? `${name} has type: ${type}\n` + : '' + const hasValue = `${name} has value: ${print(value)}` + return hasType + hasValue +} + export function addCustomEqualityTesters(newTesters: Array): void { if (!Array.isArray(newTesters)) { throw new TypeError( diff --git a/packages/vitest/src/node/project.ts b/packages/vitest/src/node/project.ts index 3a41fb743d22..09de2a38b26c 100644 --- a/packages/vitest/src/node/project.ts +++ b/packages/vitest/src/node/project.ts @@ -521,7 +521,7 @@ export class TestProject { [ ...MocksPlugins({ filter(id) { - if (id.includes(distRoot)) { + if (id.includes(distRoot) || id.includes(browser.vite.config.cacheDir)) { return false } return true diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f525db68e8fb..d2adc6e28c2f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -502,9 +502,6 @@ importers: specifier: 'catalog:' version: 8.18.1 devDependencies: - '@testing-library/jest-dom': - specifier: ^6.6.3 - version: 6.6.3 '@types/ws': specifier: 'catalog:' version: 8.18.0 @@ -530,8 +527,8 @@ importers: specifier: 'catalog:' version: 3.3.3 ivya: - specifier: ^1.5.1 - version: 1.5.1 + specifier: ^1.6.0 + version: 1.6.0 mime: specifier: ^4.0.6 version: 4.0.6 @@ -5513,10 +5510,6 @@ packages: es-module-lexer@1.6.0: resolution: {integrity: sha512-qqnD1yMU6tk/jnaMosogGySTZP8YtUgAffA9nMN+E/rjxcfRQ6IEk7IiozUjgxKoFHBGjTLnrHB/YC45r/59EQ==} - es-object-atoms@1.0.0: - resolution: {integrity: sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==} - engines: {node: '>= 0.4'} - es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} @@ -6593,8 +6586,8 @@ packages: resolution: {integrity: sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==} engines: {node: '>=8'} - ivya@1.5.1: - resolution: {integrity: sha512-WUDzzuxLS01qb4BujtMmqmoSxrG/EsNZjLI7+23xHf28NjlCD0KWHmeoHtaq21mUNrWxqxmgxwxxNDbuE7WOwA==} + ivya@1.6.0: + resolution: {integrity: sha512-MVindQ7lUYqEmW6K5o/ZTrLfRmOqhl0GEo/MekwiUm+WfDv8wOB7FRy1bGprz2hxvvrOHrDmgF8KIPw8N+/ONw==} jackspeak@3.4.0: resolution: {integrity: sha512-JVYhQnN59LVPFCEcVa2C3CrEKYacvjRfqIQl+h8oi91aLYQVWRYbxjPcv1bUiUy/kLmQaANrYfNMCO3kuEDHfw==} @@ -12598,7 +12591,7 @@ snapshots: define-properties: 1.2.1 es-abstract: 1.23.3 es-errors: 1.3.0 - get-intrinsic: 1.2.4 + get-intrinsic: 1.3.0 is-array-buffer: 3.0.4 is-shared-array-buffer: 1.0.3 @@ -13522,19 +13515,19 @@ snapshots: data-view-buffer: 1.0.1 data-view-byte-length: 1.0.1 data-view-byte-offset: 1.0.0 - es-define-property: 1.0.0 + es-define-property: 1.0.1 es-errors: 1.3.0 - es-object-atoms: 1.0.0 + es-object-atoms: 1.1.1 es-set-tostringtag: 2.0.3 es-to-primitive: 1.2.1 function.prototype.name: 1.1.6 - get-intrinsic: 1.2.4 + get-intrinsic: 1.3.0 get-symbol-description: 1.0.2 globalthis: 1.0.4 - gopd: 1.0.1 + gopd: 1.2.0 has-property-descriptors: 1.0.2 has-proto: 1.0.3 - has-symbols: 1.0.3 + has-symbols: 1.1.0 hasown: 2.0.2 internal-slot: 1.0.7 is-array-buffer: 3.0.4 @@ -13546,7 +13539,7 @@ snapshots: is-string: 1.0.7 is-typed-array: 1.1.13 is-weakref: 1.0.2 - object-inspect: 1.13.2 + object-inspect: 1.13.4 object-keys: 1.1.1 object.assign: 4.1.5 regexp.prototype.flags: 1.5.3 @@ -13584,17 +13577,13 @@ snapshots: es-module-lexer@1.6.0: {} - es-object-atoms@1.0.0: - dependencies: - es-errors: 1.3.0 - es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 es-set-tostringtag@2.0.3: dependencies: - get-intrinsic: 1.2.4 + get-intrinsic: 1.3.0 has-tostringtag: 1.0.2 hasown: 2.0.2 @@ -14403,7 +14392,7 @@ snapshots: dependencies: call-bind: 1.0.7 es-errors: 1.3.0 - get-intrinsic: 1.2.4 + get-intrinsic: 1.3.0 get-tsconfig@4.10.0: dependencies: @@ -14487,7 +14476,7 @@ snapshots: globalthis@1.0.4: dependencies: define-properties: 1.2.1 - gopd: 1.0.1 + gopd: 1.2.0 globalyzer@0.1.0: {} @@ -14923,7 +14912,7 @@ snapshots: html-escaper: 2.0.2 istanbul-lib-report: 3.0.1 - ivya@1.5.1: {} + ivya@1.6.0: {} jackspeak@3.4.0: dependencies: @@ -16534,8 +16523,8 @@ snapshots: safe-array-concat@1.1.2: dependencies: call-bind: 1.0.7 - get-intrinsic: 1.2.4 - has-symbols: 1.0.3 + get-intrinsic: 1.3.0 + has-symbols: 1.1.0 isarray: 2.0.5 safe-buffer@5.1.2: {} @@ -16927,33 +16916,33 @@ snapshots: define-properties: 1.2.1 es-abstract: 1.23.3 es-errors: 1.3.0 - es-object-atoms: 1.0.0 - get-intrinsic: 1.2.4 - gopd: 1.0.1 - has-symbols: 1.0.3 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-symbols: 1.1.0 internal-slot: 1.0.7 regexp.prototype.flags: 1.5.3 set-function-name: 2.0.2 - side-channel: 1.0.6 + side-channel: 1.1.0 string.prototype.trim@1.2.9: dependencies: call-bind: 1.0.7 define-properties: 1.2.1 es-abstract: 1.23.3 - es-object-atoms: 1.0.0 + es-object-atoms: 1.1.1 string.prototype.trimend@1.0.8: dependencies: call-bind: 1.0.7 define-properties: 1.2.1 - es-object-atoms: 1.0.0 + es-object-atoms: 1.1.1 string.prototype.trimstart@1.0.8: dependencies: call-bind: 1.0.7 define-properties: 1.2.1 - es-object-atoms: 1.0.0 + es-object-atoms: 1.1.1 string_decoder@1.1.1: dependencies: @@ -17332,7 +17321,7 @@ snapshots: dependencies: call-bind: 1.0.7 for-each: 0.3.3 - gopd: 1.0.1 + gopd: 1.2.0 has-proto: 1.0.3 is-typed-array: 1.1.13 @@ -17341,7 +17330,7 @@ snapshots: available-typed-arrays: 1.0.7 call-bind: 1.0.7 for-each: 0.3.3 - gopd: 1.0.1 + gopd: 1.2.0 has-proto: 1.0.3 is-typed-array: 1.1.13 @@ -17349,7 +17338,7 @@ snapshots: dependencies: call-bind: 1.0.7 for-each: 0.3.3 - gopd: 1.0.1 + gopd: 1.2.0 has-proto: 1.0.3 is-typed-array: 1.1.13 possible-typed-array-names: 1.0.0 @@ -17364,7 +17353,7 @@ snapshots: dependencies: call-bind: 1.0.7 has-bigints: 1.0.2 - has-symbols: 1.0.3 + has-symbols: 1.1.0 which-boxed-primitive: 1.0.2 unconfig@0.3.11: diff --git a/test/browser/fixtures/expect-dom/setup.ts b/test/browser/fixtures/expect-dom/setup.ts new file mode 100644 index 000000000000..7e26f463dc0a --- /dev/null +++ b/test/browser/fixtures/expect-dom/setup.ts @@ -0,0 +1,21 @@ +import { expect } from 'vitest'; + +// Valid string terminator sequences are BEL, ESC\, and 0x9c +const ST = '(?:\\u0007|\\u001B\\u005C|\\u009C)'; +const pattern = [ + `[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]+)*|[a-zA-Z\\d]+(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?${ST})`, + '(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]))', +].join('|'); + +const ansiRegexp = new RegExp(pattern, 'g'); + +function stripAnsi(string: string) { + return string.replace(ansiRegexp, ''); +} + +expect.addSnapshotSerializer({ + test: (val) => typeof val === 'string' || val instanceof Error, + print: (val: string | Error) => typeof val === 'string' + ? stripAnsi(val) + : `[${val.name}: ${stripAnsi(val.message)}]`, +}) diff --git a/test/browser/fixtures/expect-dom/toBeChecked.test.ts b/test/browser/fixtures/expect-dom/toBeChecked.test.ts new file mode 100644 index 000000000000..b2b743bc80ea --- /dev/null +++ b/test/browser/fixtures/expect-dom/toBeChecked.test.ts @@ -0,0 +1,207 @@ +import { describe, expect, test } from 'vitest' +import { render } from './utils' + +describe('.toBeChecked', () => { + test('handles checkbox input', () => { + const {queryByTestId} = render(` + + + `) + + expect(queryByTestId('input-checkbox-checked')).toBeChecked() + expect(queryByTestId('input-checkbox-unchecked')).not.toBeChecked() + }) + + test('handles radio input', () => { + const {queryByTestId} = render(` + + + `) + + expect(queryByTestId('input-radio-checked')).toBeChecked() + expect(queryByTestId('input-radio-unchecked')).not.toBeChecked() + }) + + test('handles element with role="checkbox"', () => { + const {queryByTestId} = render(` +
+
+ `) + + expect(queryByTestId('aria-checkbox-checked')).toBeChecked() + expect(queryByTestId('aria-checkbox-unchecked')).not.toBeChecked() + }) + + test('handles element with role="radio"', () => { + const {queryByTestId} = render(` +
+
+ `) + + expect(queryByTestId('aria-radio-checked')).toBeChecked() + expect(queryByTestId('aria-radio-unchecked')).not.toBeChecked() + }) + + test('handles element with role="switch"', () => { + const {queryByTestId} = render(` +
+
+ `) + + expect(queryByTestId('aria-switch-checked')).toBeChecked() + expect(queryByTestId('aria-switch-unchecked')).not.toBeChecked() + }) + + test('handles element with role="menuitemcheckbox"', () => { + const {queryByTestId} = render(` +
+
+ `) + + expect(queryByTestId('aria-menuitemcheckbox-checked')).toBeChecked() + expect(queryByTestId('aria-menuitemcheckbox-unchecked')).not.toBeChecked() + }) + + test('throws when checkbox input is checked but expected not to be', () => { + const {queryByTestId} = render( + ``, + ) + + expect(() => + expect(queryByTestId('input-checked')).not.toBeChecked(), + ).toThrowError() + }) + + test('throws when input checkbox is not checked but expected to be', () => { + const {queryByTestId} = render( + ``, + ) + + expect(() => + expect(queryByTestId('input-empty')).toBeChecked(), + ).toThrowError() + }) + + test('throws when element with role="checkbox" is checked but expected not to be', () => { + const {queryByTestId} = render( + `
`, + ) + + expect(() => + expect(queryByTestId('aria-checkbox-checked')).not.toBeChecked(), + ).toThrowError() + }) + + test('throws when element with role="checkbox" is not checked but expected to be', () => { + const {queryByTestId} = render( + `
`, + ) + + expect(() => + expect(queryByTestId('aria-checkbox-unchecked')).toBeChecked(), + ).toThrowError() + }) + + test('throws when radio input is checked but expected not to be', () => { + const {queryByTestId} = render( + ``, + ) + + expect(() => + expect(queryByTestId('input-radio-checked')).not.toBeChecked(), + ).toThrowError() + }) + + test('throws when input radio is not checked but expected to be', () => { + const {queryByTestId} = render( + ``, + ) + + expect(() => + expect(queryByTestId('input-radio-unchecked')).toBeChecked(), + ).toThrowError() + }) + + test('throws when element with role="radio" is checked but expected not to be', () => { + const {queryByTestId} = render( + `
`, + ) + + expect(() => + expect(queryByTestId('aria-radio-checked')).not.toBeChecked(), + ).toThrowError() + }) + + test('throws when element with role="radio" is not checked but expected to be', () => { + const {queryByTestId} = render( + `
`, + ) + + expect(() => + expect(queryByTestId('aria-checkbox-unchecked')).toBeChecked(), + ).toThrowError() + }) + + test('throws when element with role="switch" is checked but expected not to be', () => { + const {queryByTestId} = render( + `
`, + ) + + expect(() => + expect(queryByTestId('aria-switch-checked')).not.toBeChecked(), + ).toThrowError() + }) + + test('throws when element with role="switch" is not checked but expected to be', () => { + const {queryByTestId} = render( + `
`, + ) + + expect(() => + expect(queryByTestId('aria-switch-unchecked')).toBeChecked(), + ).toThrowError() + }) + + test('throws when element with role="checkbox" has an invalid aria-checked attribute', () => { + const {queryByTestId} = render( + `
`, + ) + + expect(() => + expect(queryByTestId('aria-checkbox-invalid')).toBeChecked(), + ).toThrowError( + /only inputs with .* a valid aria-checked attribute can be used/, + ) + }) + + test('throws when element with role="radio" has an invalid aria-checked attribute', () => { + const {queryByTestId} = render( + `
`, + ) + + expect(() => + expect(queryByTestId('aria-radio-invalid')).toBeChecked(), + ).toThrowError( + /only inputs with .* a valid aria-checked attribute can be used/, + ) + }) + + test('throws when element with role="switch" has an invalid aria-checked attribute', () => { + const {queryByTestId} = render( + `
`, + ) + + expect(() => + expect(queryByTestId('aria-switch-invalid')).toBeChecked(), + ).toThrowError( + /only inputs with .* a valid aria-checked attribute can be used/, + ) + }) + + test('throws when the element is not an input', () => { + const {queryByTestId} = render(``) + expect(() => expect(queryByTestId('select')).toBeChecked()).toThrowError( + /only inputs with type="checkbox" or type="radio" or elements with.* role="checkbox".* role="menuitemcheckbox".* role="option".* role="radio".* role="switch".* role="menuitemradio".* role="treeitem" .* can be used/, + ) + }) +}) \ No newline at end of file diff --git a/test/browser/fixtures/expect-dom/toBeDisabled.test.ts b/test/browser/fixtures/expect-dom/toBeDisabled.test.ts new file mode 100644 index 000000000000..00cae7d6207e --- /dev/null +++ b/test/browser/fixtures/expect-dom/toBeDisabled.test.ts @@ -0,0 +1,291 @@ +import { expect, test } from 'vitest' +import { render } from './utils' + +const window = document.defaultView + +window.customElements.define( + 'custom-element', + class extends window.HTMLElement {}, +) + +test('.toBeDisabled', () => { + const {queryByTestId} = render(` +
+ + + + +
+ +
+ +
+ +
+ +
+
+ + + +
+ x +
+ + x +
+ `) + + expect(queryByTestId('button-element')).toBeDisabled() + expect(() => + expect(queryByTestId('button-element')).not.toBeDisabled(), + ).toThrowError() + expect(queryByTestId('textarea-element')).toBeDisabled() + expect(queryByTestId('input-element')).toBeDisabled() + + // technically, everything inside a disabled fieldset is disabled, + // but the fieldset itself is not considered disabled, because its + // native tag is not part of + // https://www.w3.org/TR/html-aam-1.0/#html-attribute-state-and-property-mappings + // NOTE: this is different from jest-dom, but closer to how PW works + expect(queryByTestId('fieldset-element')).not.toBeDisabled() + expect(queryByTestId('fieldset-child-element')).toBeDisabled() + + expect(queryByTestId('div-element')).not.toBeDisabled() + expect(queryByTestId('div-child-element')).not.toBeDisabled() + + expect(queryByTestId('nested-form-element')).toBeDisabled() + expect(queryByTestId('deep-select-element')).toBeDisabled() + expect(queryByTestId('deep-optgroup-element')).toBeDisabled() + expect(queryByTestId('deep-option-element')).toBeDisabled() + + expect(queryByTestId('a-element')).not.toBeDisabled() + expect(queryByTestId('deep-a-element')).not.toBeDisabled() + expect(() => expect(queryByTestId('a-element')).toBeDisabled()).toThrowError() + expect(() => + expect(queryByTestId('deep-a-element')).toBeDisabled(), + ).toThrowError() +}) + +test('.toBeDisabled fieldset>legend', () => { + const {queryByTestId} = render(` +
+
+ +
+ +
+ + + +
+ +
+ +
+ +
+
+
+ +
+
+ + + + + + +
+ +
+
+ + + +
+
+
+ `) + + expect(queryByTestId('inherited-element')).toBeDisabled() + expect(queryByTestId('inside-legend-element')).not.toBeDisabled() + expect(queryByTestId('nested-inside-legend-element')).not.toBeDisabled() + + expect(queryByTestId('first-legend-element')).not.toBeDisabled() + expect(queryByTestId('second-legend-element')).toBeDisabled() + + expect(queryByTestId('outer-fieldset-element')).toBeDisabled() +}) + +test('.toBeDisabled custom element', () => { + const {queryByTestId} = render(` + + + `) + + expect(queryByTestId('disabled-custom-element')).toBeDisabled() + expect(() => { + expect(queryByTestId('disabled-custom-element')).not.toBeDisabled() + }).toThrowError('element is disabled') + + expect(queryByTestId('enabled-custom-element')).not.toBeDisabled() + expect(() => { + expect(queryByTestId('enabled-custom-element')).toBeDisabled() + }).toThrowError('element is not disabled') +}) + +test('.toBeEnabled', () => { + const {queryByTestId} = render(` +
+ `) + + expect(() => { + expect(queryByTestId('button-element')).toBeEnabled() + }).toThrowError() + expect(queryByTestId('button-element')).not.toBeEnabled() + expect(() => { + expect(queryByTestId('textarea-element')).toBeEnabled() + }).toThrowError() + expect(() => { + expect(queryByTestId('input-element')).toBeEnabled() + }).toThrowError() + + expect(() => { + // fieldset elements can't be considered disabled, only their children + expect(queryByTestId('fieldset-element')).toBeDisabled() + }).toThrowError() + expect(() => { + expect(queryByTestId('fieldset-child-element')).toBeEnabled() + }).toThrowError() + + expect(queryByTestId('div-element')).toBeEnabled() + expect(queryByTestId('div-child-element')).toBeEnabled() + + expect(() => { + expect(queryByTestId('nested-form-element')).toBeEnabled() + }).toThrowError() + expect(() => { + expect(queryByTestId('deep-select-element')).toBeEnabled() + }).toThrowError() + expect(() => { + expect(queryByTestId('deep-optgroup-element')).toBeEnabled() + }).toThrowError() + expect(() => { + expect(queryByTestId('deep-option-element')).toBeEnabled() + }).toThrowError() + + expect(queryByTestId('a-element')).toBeEnabled() + expect(() => + expect(queryByTestId('a-element')).not.toBeEnabled(), + ).toThrowError() + expect(queryByTestId('deep-a-element')).toBeEnabled() + expect(() => + expect(queryByTestId('deep-a-element')).not.toBeEnabled(), + ).toThrowError() +}) + +test('.toBeEnabled fieldset>legend', () => { + const {queryByTestId} = render(` +
+
+ +
+ +
+ + + +
+ +
+ +
+ +
+
+
+ +
+
+ + + + + + +
+ +
+
+ + + +
+
+
+ `) + + expect(() => { + expect(queryByTestId('inherited-element')).toBeEnabled() + }).toThrowError() + expect(queryByTestId('inside-legend-element')).toBeEnabled() + expect(queryByTestId('nested-inside-legend-element')).toBeEnabled() + + expect(queryByTestId('first-legend-element')).toBeEnabled() + expect(() => { + expect(queryByTestId('second-legend-element')).toBeEnabled() + }).toThrowError() + + expect(() => { + expect(queryByTestId('outer-fieldset-element')).toBeEnabled() + }).toThrowError() +}) + +test('.toBeEnabled custom element', () => { + const {queryByTestId} = render(` + + + `) + + expect(queryByTestId('disabled-custom-element')).not.toBeEnabled() + expect(() => { + expect(queryByTestId('disabled-custom-element')).toBeEnabled() + }).toThrowError('element is not enabled') + + expect(queryByTestId('enabled-custom-element')).toBeEnabled() + expect(() => { + expect(queryByTestId('enabled-custom-element')).not.toBeEnabled() + }).toThrowError('element is enabled') +}) \ No newline at end of file diff --git a/test/browser/fixtures/expect-dom/toBeEmptyDOMElement.test.ts b/test/browser/fixtures/expect-dom/toBeEmptyDOMElement.test.ts new file mode 100644 index 000000000000..16a6ece6cd3b --- /dev/null +++ b/test/browser/fixtures/expect-dom/toBeEmptyDOMElement.test.ts @@ -0,0 +1,63 @@ +import { expect, test } from 'vitest' +import { render } from './utils' + +test('.toBeEmptyDOMElement', () => { + const {queryByTestId} = render(` + + + + + + + + + + Text`) + + const empty = queryByTestId('empty') + const notEmpty = queryByTestId('not-empty') + const svgEmpty = queryByTestId('svg-empty') + const withComment = queryByTestId('with-comment') + const withMultipleComments = queryByTestId('with-multiple-comments') + const withElement = queryByTestId('with-element') + const withElementAndComment = queryByTestId('with-element-and-comment') + const withWhitespace = queryByTestId('with-whitespace') + const withText = queryByTestId('with-whitespace') + const nonExistantElement = queryByTestId('not-exists') + const fakeElement = {thisIsNot: 'an html element'} + + expect(empty).toBeEmptyDOMElement() + expect(svgEmpty).toBeEmptyDOMElement() + expect(notEmpty).not.toBeEmptyDOMElement() + expect(withComment).toBeEmptyDOMElement() + expect(withMultipleComments).toBeEmptyDOMElement() + expect(withElement).not.toBeEmptyDOMElement() + expect(withElementAndComment).not.toBeEmptyDOMElement() + expect(withWhitespace).not.toBeEmptyDOMElement() + expect(withText).not.toBeEmptyDOMElement() + + // negative test cases wrapped in throwError assertions for coverage. + expect(() => expect(empty).not.toBeEmptyDOMElement()).toThrowError() + + expect(() => expect(svgEmpty).not.toBeEmptyDOMElement()).toThrowError() + + expect(() => expect(notEmpty).toBeEmptyDOMElement()).toThrowError() + + expect(() => expect(withComment).not.toBeEmptyDOMElement()).toThrowError() + + expect(() => expect(withMultipleComments).not.toBeEmptyDOMElement()).toThrowError() + + expect(() => expect(withElement).toBeEmptyDOMElement()).toThrowError() + + expect(() => expect(withElementAndComment).toBeEmptyDOMElement()).toThrowError() + + expect(() => expect(withWhitespace).toBeEmptyDOMElement()).toThrowError() + + expect(() => expect(withText).toBeEmptyDOMElement()).toThrowError() + + expect(() => expect(fakeElement).toBeEmptyDOMElement()).toThrowError() + + expect(() => { + expect(nonExistantElement).toBeEmptyDOMElement() + }).toThrowError() +}) \ No newline at end of file diff --git a/test/browser/fixtures/expect-dom/toBeInTheDocument.test.ts b/test/browser/fixtures/expect-dom/toBeInTheDocument.test.ts new file mode 100644 index 000000000000..a8e58365948c --- /dev/null +++ b/test/browser/fixtures/expect-dom/toBeInTheDocument.test.ts @@ -0,0 +1,63 @@ +import { expect, test } from 'vitest' + +test('.toBeInTheDocument', () => { + const window = document.defaultView + + window.customElements.define( + 'custom-element-document', + class extends window.HTMLElement { + constructor() { + super() + this.attachShadow({mode: 'open'}).innerHTML = + '
' + } + }, + ) + + document.body.innerHTML = ` + Html Element + + ` + + const htmlElement = document.querySelector('[data-testid="html-element"]') + const svgElement = document.querySelector('[data-testid="svg-element"]') + const customElementChild = document + .querySelector('[data-testid="custom-element"]') + .shadowRoot.querySelector('[data-testid="custom-element-child"]') + const detachedElement = document.createElement('div') + const fakeElement = {thisIsNot: 'an html element'} + const undefinedElement = undefined + const nullElement = null + + expect(htmlElement).toBeInTheDocument() + expect(svgElement).toBeInTheDocument() + expect(customElementChild).toBeInTheDocument() + expect(detachedElement).not.toBeInTheDocument() + expect(nullElement).not.toBeInTheDocument() + + // negative test cases wrapped in throwError assertions for coverage. + const expectToBe = /expect.*\.toBeInTheDocument/ + const expectNotToBe = /expect.*not\.toBeInTheDocument/ + const userInputNode = /an HTMLElement or an SVGElement/ + expect(() => expect(htmlElement).not.toBeInTheDocument()).toThrowError( + expectNotToBe, + ) + expect(() => expect(svgElement).not.toBeInTheDocument()).toThrowError( + expectNotToBe, + ) + expect(() => expect(detachedElement).toBeInTheDocument()).toThrowError( + expectToBe, + ) + expect(() => expect(fakeElement).toBeInTheDocument()).toThrowError( + userInputNode, + ) + expect(() => expect(nullElement).toBeInTheDocument()).toThrowError( + userInputNode, + ) + expect(() => expect(undefinedElement).toBeInTheDocument()).toThrowError( + userInputNode, + ) + expect(() => expect(undefinedElement).not.toBeInTheDocument()).toThrowError( + userInputNode, + ) +}) \ No newline at end of file diff --git a/test/browser/fixtures/expect-dom/toBeInvalid.test.ts b/test/browser/fixtures/expect-dom/toBeInvalid.test.ts new file mode 100644 index 000000000000..9c2f4ae650b9 --- /dev/null +++ b/test/browser/fixtures/expect-dom/toBeInvalid.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, test } from 'vitest' +import { render } from './utils' + +function getDOMElement(htmlString: string, selector: string) { + const doc = document.implementation.createHTMLDocument('') + doc.body.innerHTML = htmlString + return doc.querySelector(selector) +} + +// A required field without a value is invalid +const invalidInputHtml = `` + +const invalidInputNode = getDOMElement(invalidInputHtml, 'input') + +// A form is invalid if it contains an invalid input +const invalidFormHtml = `${invalidInputHtml}` + +const invalidFormNode = getDOMElement(invalidFormHtml, 'form') + +describe('.toBeInvalid', () => { + test('handles ', () => { + const {queryByTestId} = render(` +
+ + + + +
+ `) + + expect(queryByTestId('no-aria-invalid')).not.toBeInvalid() + expect(queryByTestId('aria-invalid')).toBeInvalid() + expect(queryByTestId('aria-invalid-value')).toBeInvalid() + expect(queryByTestId('aria-invalid-false')).not.toBeInvalid() + expect(invalidInputNode).toBeInvalid() + + // negative test cases wrapped in throwError assertions for coverage. + expect(() => + expect(queryByTestId('no-aria-invalid')).toBeInvalid(), + ).toThrowError() + expect(() => + expect(queryByTestId('aria-invalid')).not.toBeInvalid(), + ).toThrowError() + expect(() => + expect(queryByTestId('aria-invalid-value')).not.toBeInvalid(), + ).toThrowError() + expect(() => + expect(queryByTestId('aria-invalid-false')).toBeInvalid(), + ).toThrowError() + expect(() => expect(invalidInputNode).not.toBeInvalid()).toThrowError() + }) + + test('handles
', () => { + const {queryByTestId} = render(` + + +
+ `) + + expect(queryByTestId('valid')).not.toBeInvalid() + expect(invalidFormNode).toBeInvalid() + + // negative test cases wrapped in throwError assertions for coverage. + expect(() => expect(queryByTestId('valid')).toBeInvalid()).toThrowError() + expect(() => expect(invalidFormNode).not.toBeInvalid()).toThrowError() + }) + + test('handles any element', () => { + const {queryByTestId} = render(` +
    +
  1. +
  2. +
  3. +
  4. +
+ `) + + expect(queryByTestId('valid')).not.toBeInvalid() + expect(queryByTestId('no-aria-invalid')).not.toBeInvalid() + expect(queryByTestId('aria-invalid')).toBeInvalid() + expect(queryByTestId('aria-invalid-value')).toBeInvalid() + expect(queryByTestId('aria-invalid-false')).not.toBeInvalid() + + // negative test cases wrapped in throwError assertions for coverage. + expect(() => expect(queryByTestId('valid')).toBeInvalid()).toThrowError() + expect(() => + expect(queryByTestId('no-aria-invalid')).toBeInvalid(), + ).toThrowError() + expect(() => + expect(queryByTestId('aria-invalid')).not.toBeInvalid(), + ).toThrowError() + expect(() => + expect(queryByTestId('aria-invalid-value')).not.toBeInvalid(), + ).toThrowError() + expect(() => + expect(queryByTestId('aria-invalid-false')).toBeInvalid(), + ).toThrowError() + }) +}) + +describe('.toBeValid', () => { + test('handles ', () => { + const {queryByTestId} = render(` +
+ + + + +
+ `) + + expect(queryByTestId('no-aria-invalid')).toBeValid() + expect(queryByTestId('aria-invalid')).not.toBeValid() + expect(queryByTestId('aria-invalid-value')).not.toBeValid() + expect(queryByTestId('aria-invalid-false')).toBeValid() + expect(invalidInputNode).not.toBeValid() + + // negative test cases wrapped in throwError assertions for coverage. + expect(() => + expect(queryByTestId('no-aria-invalid')).not.toBeValid(), + ).toThrowError() + expect(() => + expect(queryByTestId('aria-invalid')).toBeValid(), + ).toThrowError() + expect(() => + expect(queryByTestId('aria-invalid-value')).toBeValid(), + ).toThrowError() + expect(() => + expect(queryByTestId('aria-invalid-false')).not.toBeValid(), + ).toThrowError() + expect(() => expect(invalidInputNode).toBeValid()).toThrowError() + }) + + test('handles
', () => { + const {queryByTestId} = render(` + + +
+ `) + + expect(queryByTestId('valid')).toBeValid() + expect(invalidFormNode).not.toBeValid() + + // negative test cases wrapped in throwError assertions for coverage. + expect(() => expect(queryByTestId('valid')).not.toBeValid()).toThrowError() + expect(() => expect(invalidFormNode).toBeValid()).toThrowError() + }) + + test('handles any element', () => { + const {queryByTestId} = render(` +
    +
  1. +
  2. +
  3. +
  4. +
+ `) + + expect(queryByTestId('valid')).toBeValid() + expect(queryByTestId('no-aria-invalid')).toBeValid() + expect(queryByTestId('aria-invalid')).not.toBeValid() + expect(queryByTestId('aria-invalid-value')).not.toBeValid() + expect(queryByTestId('aria-invalid-false')).toBeValid() + + // negative test cases wrapped in throwError assertions for coverage. + expect(() => expect(queryByTestId('valid')).not.toBeValid()).toThrowError() + expect(() => + expect(queryByTestId('no-aria-invalid')).not.toBeValid(), + ).toThrowError() + expect(() => + expect(queryByTestId('aria-invalid')).toBeValid(), + ).toThrowError() + expect(() => + expect(queryByTestId('aria-invalid-value')).toBeValid(), + ).toThrowError() + expect(() => + expect(queryByTestId('aria-invalid-false')).not.toBeValid(), + ).toThrowError() + }) +}) \ No newline at end of file diff --git a/test/browser/fixtures/expect-dom/toBePartiallyChecked.test.ts b/test/browser/fixtures/expect-dom/toBePartiallyChecked.test.ts new file mode 100644 index 000000000000..08247bb6df76 --- /dev/null +++ b/test/browser/fixtures/expect-dom/toBePartiallyChecked.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, test } from 'vitest' +import { render } from './utils' + +// FIXME +// playwright prioritizes native checked to aria-checked for "checkbox" elements +// jest-dom checks aria-checked="mixed" anyway +describe('.toBePartiallyChecked', () => { + test('handles input checkbox with aria-checked', () => { + const {queryByTestId} = render(` + + + + `) + + expect(queryByTestId('checkbox-mixed')).toBePartiallyChecked() + expect(queryByTestId('checkbox-checked')).not.toBePartiallyChecked() + expect(queryByTestId('checkbox-unchecked')).not.toBePartiallyChecked() + }) + + test('handles input checkbox set as indeterminate', () => { + const {queryByTestId} = render(` + + + + `) + + ;(queryByTestId('checkbox-mixed') as HTMLInputElement).indeterminate = true + + expect(queryByTestId('checkbox-mixed')).toBePartiallyChecked() + expect(queryByTestId('checkbox-checked')).not.toBePartiallyChecked() + expect(queryByTestId('checkbox-unchecked')).not.toBePartiallyChecked() + }) + + test('handles element with role="checkbox"', () => { + const {queryByTestId} = render(` +
+
+
+ `) + + expect(queryByTestId('aria-checkbox-mixed')).toBePartiallyChecked() + expect(queryByTestId('aria-checkbox-checked')).not.toBePartiallyChecked() + expect(queryByTestId('aria-checkbox-unchecked')).not.toBePartiallyChecked() + }) + + test('throws when input checkbox is mixed but expected not to be', () => { + const {queryByTestId} = render( + ``, + ) + + expect(() => + expect(queryByTestId('checkbox-mixed')).not.toBePartiallyChecked(), + ).toThrowError() + }) + + test('throws when input checkbox is indeterminate but expected not to be', () => { + const {queryByTestId} = render( + ``, + ) + + ;(queryByTestId('checkbox-mixed') as HTMLInputElement).indeterminate = true + + expect(() => + expect(queryByTestId('input-mixed')).not.toBePartiallyChecked(), + ).toThrowError() + }) + + test('throws when input checkbox is not checked but expected to be', () => { + const {queryByTestId} = render( + ``, + ) + + expect(() => + expect(queryByTestId('checkbox-empty')).toBePartiallyChecked(), + ).toThrowError() + }) + + test('throws when element with role="checkbox" is partially checked but expected not to be', () => { + const {queryByTestId} = render( + `
`, + ) + + expect(() => + expect(queryByTestId('aria-checkbox-mixed')).not.toBePartiallyChecked(), + ).toThrowError() + }) + + test('throws when element with role="checkbox" is checked but expected to be partially checked', () => { + const {queryByTestId} = render( + `
`, + ) + + expect(() => + expect(queryByTestId('aria-checkbox-checked')).toBePartiallyChecked(), + ).toThrowError() + }) + + test('throws when element with role="checkbox" is not checked but expected to be', () => { + const {queryByTestId} = render( + `
`, + ) + + expect(() => + expect(queryByTestId('aria-checkbox')).toBePartiallyChecked(), + ).toThrowError() + }) + + test('throws when element with role="checkbox" has an invalid aria-checked attribute', () => { + const {queryByTestId} = render( + `
`, + ) + + expect(() => + expect(queryByTestId('aria-checkbox-invalid')).toBePartiallyChecked(), + ).toThrowError() + }) + + test('throws when the element is not a checkbox', () => { + const {queryByTestId} = render(``) + expect(() => + expect(queryByTestId('select')).toBePartiallyChecked(), + ).toThrowError( + 'only inputs with type="checkbox" or elements with role="checkbox" and a valid aria-checked attribute can be used with .toBePartiallyChecked(). Use .toHaveValue() instead', + ) + }) +}) \ No newline at end of file diff --git a/test/browser/fixtures/expect-dom/toBeRequired.test.ts b/test/browser/fixtures/expect-dom/toBeRequired.test.ts new file mode 100644 index 000000000000..b68482b14ebf --- /dev/null +++ b/test/browser/fixtures/expect-dom/toBeRequired.test.ts @@ -0,0 +1,62 @@ +import { expect, test } from 'vitest' +import { render } from './utils' + +test('.toBeRequired', () => { + const {queryByTestId} = render(` +
+ + + + + + + + +
+
+
+ `) + + expect(queryByTestId('required-input')).toBeRequired() + expect(queryByTestId('aria-required-input')).toBeRequired() + expect(queryByTestId('conflicted-input')).toBeRequired() + expect(queryByTestId('not-required-input')).not.toBeRequired() + expect(queryByTestId('basic-input')).not.toBeRequired() + expect(queryByTestId('unsupported-type')).not.toBeRequired() + expect(queryByTestId('select')).toBeRequired() + expect(queryByTestId('textarea')).toBeRequired() + expect(queryByTestId('supported-role')).not.toBeRequired() + expect(queryByTestId('supported-role-aria')).toBeRequired() + + // negative test cases wrapped in throwError assertions for coverage. + expect(() => + expect(queryByTestId('required-input')).not.toBeRequired(), + ).toThrowError() + expect(() => + expect(queryByTestId('aria-required-input')).not.toBeRequired(), + ).toThrowError() + expect(() => + expect(queryByTestId('conflicted-input')).not.toBeRequired(), + ).toThrowError() + expect(() => + expect(queryByTestId('not-required-input')).toBeRequired(), + ).toThrowError() + expect(() => + expect(queryByTestId('basic-input')).toBeRequired(), + ).toThrowError() + expect(() => + expect(queryByTestId('unsupported-type')).toBeRequired(), + ).toThrowError() + expect(() => + expect(queryByTestId('select')).not.toBeRequired(), + ).toThrowError() + expect(() => + expect(queryByTestId('textarea')).not.toBeRequired(), + ).toThrowError() + expect(() => + expect(queryByTestId('supported-role')).toBeRequired(), + ).toThrowError() + expect(() => + expect(queryByTestId('supported-role-aria')).not.toBeRequired(), + ).toThrowError() +}) \ No newline at end of file diff --git a/test/browser/fixtures/expect-dom/toBeVisible.test.ts b/test/browser/fixtures/expect-dom/toBeVisible.test.ts new file mode 100644 index 000000000000..f9f2afb555cc --- /dev/null +++ b/test/browser/fixtures/expect-dom/toBeVisible.test.ts @@ -0,0 +1,427 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { render } from './utils' + +describe('.toBeVisible', () => { + it('returns the visibility of an element', () => { + const {container} = render(` +
+
+

Main title

+

Secondary title

+

Secondary title

+

Secondary title

+
Secondary title
+
+ +
+

Hello World

+
+
+ `) + + expect(container.querySelector('header')).toBeVisible() + expect(container.querySelector('h1')).not.toBeVisible() + expect(container.querySelector('h2')).not.toBeVisible() + expect(container.querySelector('h3')).not.toBeVisible() + expect(container.querySelector('h4')).toBeVisible() // element.checkVisibility() returns true for opacity: 0 + expect(container.querySelector('h5')).toBeVisible() + expect(container.querySelector('button')).not.toBeVisible() + expect(container.querySelector('strong')).not.toBeVisible() + + expect(() => + expect(container.querySelector('header')).not.toBeVisible(), + ).toThrowError() + expect(() => + expect(container.querySelector('p')).toBeVisible(), + ).toThrowError() + }) + + it('detached element is not visible', () => { + const subject = document.createElement('div') + expect(subject).not.toBeVisible() + expect(() => expect(subject).toBeVisible()).toThrowError() + }) + + describe('with a
element', () => { + let subject + + afterEach(() => { + subject = undefined + }) + + describe('when the details is opened', () => { + beforeEach(() => { + subject = render(` +
+ Title of visible +
Visible details
+
+ `) + }) + + it('returns true to the details content', () => { + expect(subject.container.querySelector('div')).toBeVisible() + }) + + it('returns true to the most inner details content', () => { + expect(subject.container.querySelector('small')).toBeVisible() + }) + + it('returns true to the details summary', () => { + expect(subject.container.querySelector('summary')).toBeVisible() + }) + + describe('when the user clicks on the summary', () => { + beforeEach(() => subject.container.querySelector('summary').click()) + + it('returns false to the details content', () => { + expect(subject.container.querySelector('div')).not.toBeVisible() + }) + + it('returns true to the details summary', () => { + expect(subject.container.querySelector('summary')).toBeVisible() + }) + }) + }) + + describe('when the details is not opened', () => { + beforeEach(() => { + subject = render(` +
+ Title of hidden +
Hidden details
+
+ `) + }) + + it('returns false to the details content', () => { + expect(subject.container.querySelector('div')).not.toBeVisible() + }) + + it('returns true to the summary content', () => { + expect(subject.container.querySelector('summary')).toBeVisible() + }) + + describe('when the user clicks on the summary', () => { + beforeEach(() => subject.container.querySelector('summary').click()) + + it('returns true to the details content', () => { + expect(subject.container.querySelector('div')).toBeVisible() + }) + + it('returns true to the details summary', () => { + expect(subject.container.querySelector('summary')).toBeVisible() + }) + }) + }) + + describe('when the details is opened but it is hidden', () => { + beforeEach(() => { + subject = render(` + + `) + }) + + it('returns false to the details content', () => { + expect(subject.container.querySelector('div')).not.toBeVisible() + }) + + it('returns false to the details summary', () => { + expect(subject.container.querySelector('summary')).not.toBeVisible() + }) + }) + + describe('when the
inner text does not have an enclosing element', () => { + describe('when the details is not opened', () => { + beforeEach(() => { + subject = render(` +
+ Title of hidden innerText + hidden innerText +
+ `) + }) + + it('returns true to the details content', () => { + expect(subject.container.querySelector('details')).toBeVisible() + }) + + it('returns true to the details summary', () => { + expect(subject.container.querySelector('summary')).toBeVisible() + }) + + describe('when the user clicks on the summary', () => { + beforeEach(() => subject.container.querySelector('summary').click()) + + it('returns true to the details content', () => { + expect(subject.container.querySelector('details')).toBeVisible() + }) + + it('returns true to the details summary', () => { + expect(subject.container.querySelector('summary')).toBeVisible() + }) + }) + }) + + describe('when the details is opened', () => { + beforeEach(() => { + subject = render(` +
+ Title of visible innerText + visible innerText +
+ `) + }) + + it('returns true to the details content', () => { + expect(subject.container.querySelector('details')).toBeVisible() + }) + + it('returns true to inner small content', () => { + expect(subject.container.querySelector('small')).toBeVisible() + }) + + describe('when the user clicks on the summary', () => { + beforeEach(() => subject.container.querySelector('summary').click()) + + it('returns true to the details content', () => { + expect(subject.container.querySelector('details')).toBeVisible() + }) + + it('returns false to the inner small content', () => { + expect(subject.container.querySelector('small')).not.toBeVisible() + }) + + it('returns true to the details summary', () => { + expect(subject.container.querySelector('summary')).toBeVisible() + }) + }) + }) + }) + + describe('with a nested
element', () => { + describe('when the nested
is opened', () => { + beforeEach(() => { + subject = render(` +
+ Title of visible +
Outer content
+
+ Title of nested details +
Inner content
+
+
+ `) + }) + + it('returns true to the nested details content', () => { + expect( + subject.container.querySelector('details > details > div'), + ).toBeVisible() + }) + + it('returns true to the nested details summary', () => { + expect( + subject.container.querySelector('details > details > summary'), + ).toBeVisible() + }) + + it('returns true to the outer details content', () => { + expect(subject.container.querySelector('details > div')).toBeVisible() + }) + + it('returns true to the outer details summary', () => { + expect( + subject.container.querySelector('details > summary'), + ).toBeVisible() + }) + }) + + describe('when the nested
is not opened', () => { + beforeEach(() => { + subject = render(` +
+ Title of visible +
Outer content
+
+ Title of nested details +
Inner content
+
+
+ `) + }) + + it('returns false to the nested details content', () => { + expect( + subject.container.querySelector('details > details > div'), + ).not.toBeVisible() + }) + + it('returns true to the nested details summary', () => { + expect( + subject.container.querySelector('details > details > summary'), + ).toBeVisible() + }) + + it('returns true to the outer details content', () => { + expect(subject.container.querySelector('details > div')).toBeVisible() + }) + + it('returns true to the outer details summary', () => { + expect( + subject.container.querySelector('details > summary'), + ).toBeVisible() + }) + }) + + describe('when the outer
is not opened and the nested one is opened', () => { + beforeEach(() => { + subject = render(` +
+ Title of visible +
Outer content
+
+ Title of nested details +
Inner content
+
+
+ `) + }) + + it('returns false to the nested details content', () => { + expect( + subject.container.querySelector('details > details > div'), + ).not.toBeVisible() + }) + + it('returns false to the nested details summary', () => { + expect( + subject.container.querySelector('details > details > summary'), + ).not.toBeVisible() + }) + + it('returns false to the outer details content', () => { + expect( + subject.container.querySelector('details > div'), + ).not.toBeVisible() + }) + + it('returns true to the outer details summary', () => { + expect( + subject.container.querySelector('details > summary'), + ).toBeVisible() + }) + }) + + describe('with nested details (unenclosed outer, enclosed inner)', () => { + describe('when both outer and inner are opened', () => { + beforeEach(() => { + subject = render(` +
+ Title of outer unenclosed + Unenclosed innerText +
+ Title of inner enclosed +
Enclosed innerText
+
+
+ `) + }) + + it('returns true to outer unenclosed innerText', () => { + expect(subject.container.querySelector('details')).toBeVisible() + }) + + it('returns true to outer summary', () => { + expect(subject.container.querySelector('summary')).toBeVisible() + }) + + it('returns true to inner enclosed innerText', () => { + expect( + subject.container.querySelector('details > details > div'), + ).toBeVisible() + }) + + it('returns true to inner summary', () => { + expect( + subject.container.querySelector('details > details > summary'), + ).toBeVisible() + }) + }) + + describe('when outer is opened and inner is not opened', () => { + beforeEach(() => { + subject = render(` +
+ Title of outer unenclosed + Unenclosed innerText +
+ Title of inner enclosed +
Enclosed innerText
+
+
+ `) + }) + + it('returns true to outer unenclosed innerText', () => { + expect(subject.container.querySelector('details')).toBeVisible() + }) + + it('returns true to outer summary', () => { + expect(subject.container.querySelector('summary')).toBeVisible() + }) + + it('returns false to inner enclosed innerText', () => { + expect( + subject.container.querySelector('details > details > div'), + ).not.toBeVisible() + }) + + it('returns true to inner summary', () => { + expect( + subject.container.querySelector('details > details > summary'), + ).toBeVisible() + }) + }) + + describe('when outer is not opened and inner is opened', () => { + beforeEach(() => { + subject = render(` +
+ Title of outer unenclosed + Unenclosed innerText +
+ Title of inner enclosed +
Enclosed innerText
+
+
+ `) + }) + + it('returns false to outer unenclosed innerText', () => { + expect(subject.container.querySelector('details')).toBeVisible() + }) + + it('returns true to outer summary', () => { + expect(subject.container.querySelector('summary')).toBeVisible() + }) + + it('returns false to inner enclosed innerText', () => { + expect( + subject.container.querySelector('details > details > div'), + ).not.toBeVisible() + }) + + it('returns true to inner summary', () => { + expect( + subject.container.querySelector('details > details > summary'), + ).not.toBeVisible() + }) + }) + }) + }) + }) +}) \ No newline at end of file diff --git a/test/browser/fixtures/expect-dom/toContainElement.test.ts b/test/browser/fixtures/expect-dom/toContainElement.test.ts new file mode 100644 index 000000000000..70aa59f29b10 --- /dev/null +++ b/test/browser/fixtures/expect-dom/toContainElement.test.ts @@ -0,0 +1,69 @@ +import { expect, test } from 'vitest' +import { render } from './utils' + +const {queryByTestId} = render(` + + + + + + +`) + +const grandparent = queryByTestId('grandparent') +const parent = queryByTestId('parent') +const child = queryByTestId('child') +const svgElement = queryByTestId('svg-element') +const nonExistantElement = queryByTestId('not-exists') +const fakeElement = {thisIsNot: 'an html element'} + +test('.toContainElement positive test cases', () => { + expect(grandparent).toContainElement(parent) + expect(grandparent).toContainElement(child) + expect(grandparent).toContainElement(svgElement) + expect(parent).toContainElement(child) + expect(parent).not.toContainElement(grandparent) + expect(parent).not.toContainElement(svgElement) + expect(child).not.toContainElement(parent) + expect(child).not.toContainElement(grandparent) + expect(child).not.toContainElement(svgElement) + expect(grandparent).not.toContainElement(nonExistantElement) +}) + +test('.toContainElement negative test cases', () => { + expect(() => + expect(nonExistantElement).not.toContainElement(child), + ).toThrowError() + expect(() => expect(parent).toContainElement(grandparent)).toThrowError() + expect(() => + expect(nonExistantElement).toContainElement(grandparent), + ).toThrowError() + expect(() => + expect(grandparent).toContainElement(nonExistantElement), + ).toThrowError() + expect(() => + expect(nonExistantElement).toContainElement(nonExistantElement), + ).toThrowError() + expect(() => + // @ts-expect-error testing invalid assertion + expect(nonExistantElement).toContainElement(fakeElement), + ).toThrowError() + expect(() => + expect(fakeElement).toContainElement(nonExistantElement), + ).toThrowError() + expect(() => + expect(fakeElement).not.toContainElement(nonExistantElement), + ).toThrowError() + expect(() => expect(fakeElement).toContainElement(grandparent)).toThrowError() + // @ts-expect-error testing invalid assertion + expect(() => expect(grandparent).toContainElement(fakeElement)).toThrowError() + // @ts-expect-error testing invalid assertion + expect(() => expect(fakeElement).toContainElement(fakeElement)).toThrowError() + expect(() => expect(grandparent).not.toContainElement(child)).toThrowError() + expect(() => + expect(grandparent).not.toContainElement(svgElement), + ).toThrowError() + expect(() => + expect(grandparent).not.toContainElement(undefined), + ).toThrowError() +}) \ No newline at end of file diff --git a/test/browser/fixtures/expect-dom/toContainHTML.test.ts b/test/browser/fixtures/expect-dom/toContainHTML.test.ts new file mode 100644 index 000000000000..a24d575ce872 --- /dev/null +++ b/test/browser/fixtures/expect-dom/toContainHTML.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, test } from 'vitest' +import { render } from './utils' + +/* eslint-disable max-statements */ +describe('.toContainHTML', () => { + test('handles positive and negative cases', () => { + const {queryByTestId} = render(` + + + + + + + `) + + const grandparent = queryByTestId('grandparent') + const parent = queryByTestId('parent') + const child = queryByTestId('child') + const nonExistantElement = queryByTestId('not-exists') + const fakeElement = {thisIsNot: 'an html element'} + const stringChildElement = '' + const stringChildElementSelfClosing = '' + const incorrectStringHtml = '
' + const nonExistantString = ' Does not exists ' + const svgElement = queryByTestId('svg-element') + + expect(grandparent).toContainHTML(stringChildElement) + expect(parent).toContainHTML(stringChildElement) + expect(child).toContainHTML(stringChildElement) + expect(child).toContainHTML(stringChildElementSelfClosing) + expect(grandparent).not.toContainHTML(nonExistantString) + expect(parent).not.toContainHTML(nonExistantString) + expect(child).not.toContainHTML(nonExistantString) + expect(child).not.toContainHTML(nonExistantString) + expect(grandparent).toContainHTML(incorrectStringHtml) + expect(parent).toContainHTML(incorrectStringHtml) + expect(child).toContainHTML(incorrectStringHtml) + + // negative test cases wrapped in throwError assertions for coverage. + expect(() => + expect(nonExistantElement).not.toContainHTML(stringChildElement), + ).toThrowError() + expect(() => + // @ts-expect-error testing invalid input + expect(nonExistantElement).not.toContainHTML(nonExistantElement), + ).toThrowError() + expect(() => + // @ts-expect-error testing invalid input + expect(stringChildElement).not.toContainHTML(fakeElement), + ).toThrowError() + expect(() => + expect(svgElement).toContainHTML(stringChildElement), + ).toThrowError() + expect(() => + expect(grandparent).not.toContainHTML(stringChildElement), + ).toThrowError() + expect(() => + expect(parent).not.toContainHTML(stringChildElement), + ).toThrowError() + expect(() => + expect(child).not.toContainHTML(stringChildElement), + ).toThrowError() + expect(() => + expect(child).not.toContainHTML(stringChildElement), + ).toThrowError() + expect(() => + expect(child).not.toContainHTML(stringChildElementSelfClosing), + ).toThrowError() + expect(() => expect(child).toContainHTML(nonExistantString)).toThrowError() + expect(() => expect(parent).toContainHTML(nonExistantString)).toThrowError() + expect(() => + expect(grandparent).toContainHTML(nonExistantString), + ).toThrowError() + // @ts-expect-error testing invalid input + expect(() => expect(child).toContainHTML(nonExistantElement)).toThrowError() + expect(() => + // @ts-expect-error testing invalid input + expect(parent).toContainHTML(nonExistantElement), + ).toThrowError() + expect(() => + // @ts-expect-error testing invalid input + expect(grandparent).toContainHTML(nonExistantElement), + ).toThrowError() + expect(() => + expect(nonExistantElement).not.toContainHTML(incorrectStringHtml), + ).toThrowError() + expect(() => + expect(grandparent).not.toContainHTML(incorrectStringHtml), + ).toThrowError() + expect(() => + expect(child).not.toContainHTML(incorrectStringHtml), + ).toThrowError() + expect(() => + expect(parent).not.toContainHTML(incorrectStringHtml), + ).toThrowError() + }) + + test('throws with an expected text', async () => { + const {queryByTestId} = render('') + const htmlElement = queryByTestId('child') + const nonExistantString = '
non-existant element
' + + let errorMessage + try { + expect(htmlElement).toContainHTML(nonExistantString) + } catch (error) { + errorMessage = error.message + } + + expect(errorMessage).toMatchInlineSnapshot(` + expect(element).toContainHTML() + Expected: +
non-existant element
+ Received: + + `) + }) +}) diff --git a/test/browser/fixtures/expect-dom/toHaveAccessibleDescription.test.ts b/test/browser/fixtures/expect-dom/toHaveAccessibleDescription.test.ts new file mode 100644 index 000000000000..f753f4337165 --- /dev/null +++ b/test/browser/fixtures/expect-dom/toHaveAccessibleDescription.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from 'vitest' +import { render } from './utils' + +describe('.toHaveAccessibleDescription', () => { + it('works with the link title attribute', () => { + const {queryByTestId} = render(` +
+ Start + About +
+ `) + + const link = queryByTestId('link') + expect(link).toHaveAccessibleDescription() + expect(link).toHaveAccessibleDescription('A link to start over') + expect(link).not.toHaveAccessibleDescription('Home page') + expect(() => { + expect(link).toHaveAccessibleDescription('Invalid description') + }).toThrow(/expected element to have accessible description/i) + expect(() => { + expect(link).not.toHaveAccessibleDescription() + }).toThrow(/expected element not to have accessible description/i) + + const extraLink = queryByTestId('extra-link') + expect(extraLink).not.toHaveAccessibleDescription() + expect(() => { + expect(extraLink).toHaveAccessibleDescription() + }).toThrow(/expected element to have accessible description/i) + }) + + it('works with aria-describedby attributes', () => { + const {queryByTestId} = render(` +
+ User profile pic + Company logo + The logo of Our Company +
+ `) + + const avatar = queryByTestId('avatar') + expect(avatar).not.toHaveAccessibleDescription() + expect(() => { + expect(avatar).toHaveAccessibleDescription('User profile pic') + }).toThrow(/expected element to have accessible description/i) + + const logo = queryByTestId('logo') + expect(logo).not.toHaveAccessibleDescription('Company logo') + expect(logo).toHaveAccessibleDescription('The logo of Our Company') + expect(logo).toHaveAccessibleDescription(/logo of our company/i) + expect(logo).toHaveAccessibleDescription( + expect.stringContaining('logo of Our Company'), + ) + expect(() => { + expect(logo).toHaveAccessibleDescription("Our company's logo") + }).toThrow(/expected element to have accessible description/i) + expect(() => { + expect(logo).not.toHaveAccessibleDescription('The logo of Our Company') + }).toThrow(/expected element not to have accessible description/i) + }) + + it('works with aria-description attribute', () => { + const {queryByTestId} = render(` + Company logo + `) + + const logo = queryByTestId('logo') + expect(logo).not.toHaveAccessibleDescription('Company logo') + expect(logo).toHaveAccessibleDescription('The logo of Our Company') + expect(logo).toHaveAccessibleDescription(/logo of our company/i) + expect(logo).toHaveAccessibleDescription( + expect.stringContaining('logo of Our Company'), + ) + expect(() => { + expect(logo).toHaveAccessibleDescription("Our company's logo") + }).toThrow(/expected element to have accessible description/i) + expect(() => { + expect(logo).not.toHaveAccessibleDescription('The logo of Our Company') + }).toThrow(/expected element not to have accessible description/i) + }) + + it('handles multiple ids', () => { + const {queryByTestId} = render(` +
+
First description
+
Second description
+
Third description
+ +
+
+ `) + + expect(queryByTestId('multiple')).toHaveAccessibleDescription( + 'First description Second description Third description', + ) + expect(queryByTestId('multiple')).toHaveAccessibleDescription( + /Second description Third/, + ) + expect(queryByTestId('multiple')).toHaveAccessibleDescription( + expect.stringContaining('Second description Third'), + ) + expect(queryByTestId('multiple')).toHaveAccessibleDescription( + expect.stringMatching(/Second description Third/), + ) + expect(queryByTestId('multiple')).not.toHaveAccessibleDescription( + 'Something else', + ) + expect(queryByTestId('multiple')).not.toHaveAccessibleDescription('First') + }) + + it('normalizes whitespace', () => { + const {queryByTestId} = render(` +
+ Step + 1 + of + 4 +
+
+ And + extra + description +
+
+ `) + + expect(queryByTestId('target')).toHaveAccessibleDescription( + 'Step 1 of 4 And extra description', + ) + }) +}) \ No newline at end of file diff --git a/test/browser/fixtures/expect-dom/toHaveAccessibleErrorMessage.test.ts b/test/browser/fixtures/expect-dom/toHaveAccessibleErrorMessage.test.ts new file mode 100644 index 000000000000..e359ee914f84 --- /dev/null +++ b/test/browser/fixtures/expect-dom/toHaveAccessibleErrorMessage.test.ts @@ -0,0 +1,237 @@ +import { describe, expect, it } from 'vitest' +import { render } from './utils' + +describe('.toHaveAccessibleErrorMessage', () => { + const input = 'input' + const errorId = 'error-id' + const error = 'This field is invalid' + const strings = {true: String(true), false: String(false)} + + describe('Positive Test Cases', () => { + it("Fails the test if an invalid `id` is provided for the target element's `aria-errormessage`", () => { + const secondId = 'id2' + const secondError = 'LISTEN TO ME!!!' + + const {queryByTestId} = render(` +
+ <${input} data-testid="${input}" aria-invalid="${strings.true}" aria-errormessage="${errorId} ${secondId}" /> + + +
+ `) + + const field = queryByTestId('input') + expect(field).toHaveAccessibleErrorMessage() + expect(field).toHaveAccessibleErrorMessage(new RegExp(error[0])) + + expect(field).toHaveAccessibleErrorMessage(error + ' ' + secondError) + + expect(() => expect(field).toHaveAccessibleErrorMessage(error)).toThrow() + expect(() => + expect(field).toHaveAccessibleErrorMessage(secondError), + ).toThrow() + + expect(field).toHaveAccessibleErrorMessage(new RegExp(secondError[0])) + }) + + it('Fails the test if the target element is valid according to the WAI-ARIA spec', () => { + const noAriaInvalidAttribute = 'no-aria-invalid-attribute' + const validFieldState = 'false' + const invalidFieldStates = [ + 'true', + // difference with jest-dom + // https://www.w3.org/TR/wai-aria-1.2/#aria-invalid + // If the attribute is not present, or its value is false, or its value + // is an EMPTY STRING, the default value of false applies. + // '', + 'grammar', + 'spelling', + 'asfdafbasdfasa', + ] + + function renderFieldWithState(state) { + return render(` +
+ <${input} data-testid="${input}" aria-invalid="${state}" aria-errormessage="${errorId}" /> + + +
+ `) + } + + // Success Cases + invalidFieldStates.forEach(invalidState => { + const {queryByTestId} = renderFieldWithState(invalidState) + const field = queryByTestId('input') + + expect(field).toHaveAccessibleErrorMessage() + expect(field).toHaveAccessibleErrorMessage(error) + }) + + // Failure Case + const {queryByTestId} = renderFieldWithState(validFieldState) + const field = queryByTestId('input') + const fieldWithoutAttribute = queryByTestId(noAriaInvalidAttribute) + + expect(() => expect(fieldWithoutAttribute).toHaveAccessibleErrorMessage()) + .toThrowErrorMatchingInlineSnapshot(` + [Error: expect(element).toHaveAccessibleErrorMessage() + + Expected element to have accessible error message, but got nothing] + `) + + expect(() => expect(field).toHaveAccessibleErrorMessage()) + .toThrowErrorMatchingInlineSnapshot(` + [Error: expect(element).toHaveAccessibleErrorMessage() + + Expected element to have accessible error message, but got nothing] + `) + + // Assume the remaining error messages are the EXACT same as above + expect(() => expect(field).toHaveAccessibleErrorMessage(error)).toThrow() + expect(() => + expect(field).toHaveAccessibleErrorMessage(new RegExp(error, 'i')), + ).toThrow() + }) + + it('Passes the test if the target element has ANY recognized, non-empty error message', () => { + const {queryByTestId} = render(` +
+ <${input} data-testid="${input}" aria-invalid="${strings.true}" aria-errormessage="${errorId}" /> + +
+ `) + + const field = queryByTestId(input) + expect(field).toHaveAccessibleErrorMessage() + }) + + it('Fails the test if NO recognized, non-empty error message was found for the target element', () => { + const empty = 'empty' + const emptyErrorId = 'empty-error' + const missing = 'missing' + + const {queryByTestId} = render(` +
+ + + + +
+ `) + + const fieldWithEmptyError = queryByTestId(empty) + const fieldMissingError = queryByTestId(missing) + + expect(() => expect(fieldWithEmptyError).toHaveAccessibleErrorMessage()) + .toThrowErrorMatchingInlineSnapshot(` + [Error: expect(element).toHaveAccessibleErrorMessage() + + Expected element to have accessible error message, but got nothing] + `) + + expect(() => expect(fieldMissingError).toHaveAccessibleErrorMessage()) + .toThrowErrorMatchingInlineSnapshot(` + [Error: expect(element).toHaveAccessibleErrorMessage() + + Expected element to have accessible error message, but got nothing] + `) + }) + + it('Passes the test if the target element has the error message that was SPECIFIED', () => { + const {queryByTestId} = render(` +
+ <${input} data-testid="${input}" aria-invalid="${strings.true}" aria-errormessage="${errorId}" /> + +
+ `) + + const field = queryByTestId(input) + const halfOfError = error.slice(0, Math.floor(error.length * 0.5)) + + expect(field).toHaveAccessibleErrorMessage(error) + expect(field).toHaveAccessibleErrorMessage(new RegExp(halfOfError, 'i')) + expect(field).toHaveAccessibleErrorMessage( + expect.stringContaining(halfOfError), + ) + expect(field).toHaveAccessibleErrorMessage( + expect.stringMatching(new RegExp(halfOfError, 'i')), + ) + }) + + it('Fails the test if the target element DOES NOT have the error message that was SPECIFIED', () => { + const {queryByTestId} = render(` +
+ <${input} data-testid="${input}" aria-invalid="${strings.true}" aria-errormessage="${errorId}" /> + +
+ `) + + const field = queryByTestId(input) + const msg = 'asdflkje2984fguyvb bnafdsasfa;lj' + + expect(() => expect(field).toHaveAccessibleErrorMessage('')) + .toThrowErrorMatchingInlineSnapshot(` + [Error: expect(element).toHaveAccessibleErrorMessage() + + Expected element to have accessible error message: + + Received: + This field is invalid] + `) + + // Assume this error is SIMILAR to the error above + expect(() => expect(field).toHaveAccessibleErrorMessage(msg)).toThrow() + expect(() => + expect(field).toHaveAccessibleErrorMessage( + error.slice(0, Math.floor(error.length * 0.5)), + ), + ).toThrow() + + expect(() => + expect(field).toHaveAccessibleErrorMessage(new RegExp(msg, 'i')), + ).toThrowErrorMatchingInlineSnapshot(` + [Error: expect(element).toHaveAccessibleErrorMessage() + + Expected element to have accessible error message: + /asdflkje2984fguyvb bnafdsasfa;lj/i + Received: + This field is invalid] + `) + }) + + it('Normalizes the whitespace of the received error message', () => { + const {queryByTestId} = render(` +
+ <${input} data-testid="${input}" aria-invalid="${strings.true}" aria-errormessage="${errorId}" /> + +
+ `) + + const field = queryByTestId(input) + expect(field).toHaveAccessibleErrorMessage('Step 1 of 9000') + }) + }) + + // These tests for the `.not` use cases will help us cover our bases and complete test coverage + describe('Negated Test Cases', () => { + it('Passes the test if the target element is valid according to the WAI-ARIA spec', () => { + const {queryByTestId} = render(` +
+ <${input} data-testid="${input}" aria-errormessage="${errorId}" /> + +
+ `) + + const field = queryByTestId(input) + expect(field).not.toHaveAccessibleErrorMessage() + expect(field).not.toHaveAccessibleErrorMessage(error) + expect(field).not.toHaveAccessibleErrorMessage(new RegExp(error[0])) + }) + }) +}) \ No newline at end of file diff --git a/test/browser/fixtures/expect-dom/toHaveAttribute.test.ts b/test/browser/fixtures/expect-dom/toHaveAttribute.test.ts new file mode 100644 index 000000000000..4d03c765b137 --- /dev/null +++ b/test/browser/fixtures/expect-dom/toHaveAttribute.test.ts @@ -0,0 +1,64 @@ +import { expect, test } from 'vitest' +import { render } from './utils' + +test('.toHaveAttribute', () => { + const {queryByTestId} = render(` + + + `) + + expect(queryByTestId('ok-button')).toHaveAttribute('disabled') + expect(queryByTestId('ok-button')).toHaveAttribute('type') + expect(queryByTestId('ok-button')).not.toHaveAttribute('class') + expect(queryByTestId('ok-button')).toHaveAttribute('type', 'submit') + expect(queryByTestId('ok-button')).not.toHaveAttribute('type', 'button') + expect(queryByTestId('svg-element')).toHaveAttribute('width') + expect(queryByTestId('svg-element')).toHaveAttribute('width', '12') + expect(queryByTestId('ok-button')).not.toHaveAttribute('height') + + expect(() => + expect(queryByTestId('ok-button')).not.toHaveAttribute('disabled'), + ).toThrowError() + expect(() => + expect(queryByTestId('ok-button')).not.toHaveAttribute('type'), + ).toThrowError() + expect(() => + expect(queryByTestId('ok-button')).toHaveAttribute('class'), + ).toThrowError() + expect(() => + expect(queryByTestId('ok-button')).not.toHaveAttribute('type', 'submit'), + ).toThrowError() + expect(() => + expect(queryByTestId('ok-button')).toHaveAttribute('type', 'button'), + ).toThrowError() + expect(() => + expect(queryByTestId('svg-element')).not.toHaveAttribute('width'), + ).toThrowError() + expect(() => + expect(queryByTestId('svg-element')).not.toHaveAttribute('width', '12'), + ).toThrowError() + expect(() => + // @ts-expect-error invalid signature + expect({thisIsNot: 'an html element'}).not.toHaveAttribute(), + ).toThrowError() + + // Asymmetric matchers + expect(queryByTestId('ok-button')).toHaveAttribute( + 'type', + expect.stringContaining('sub'), + ) + expect(queryByTestId('ok-button')).toHaveAttribute( + 'type', + expect.stringMatching(/sub*/), + ) + expect(queryByTestId('ok-button')).toHaveAttribute('type', expect.anything()) + + expect(() => + expect(queryByTestId('ok-button')).toHaveAttribute( + 'type', + expect.not.stringContaining('sub'), + ), + ).toThrowError() +}) \ No newline at end of file diff --git a/test/browser/fixtures/expect-dom/toHaveClass.test.ts b/test/browser/fixtures/expect-dom/toHaveClass.test.ts new file mode 100644 index 000000000000..8f0caf484cc9 --- /dev/null +++ b/test/browser/fixtures/expect-dom/toHaveClass.test.ts @@ -0,0 +1,233 @@ +import { expect, test } from 'vitest' +import { render } from './utils' + +const renderElementWithClasses = () => + render(` +
+ + + + + +
+
+
+`) + +test('.toHaveClass', () => { + const {queryByTestId} = renderElementWithClasses() + + expect(queryByTestId('delete-button')).toHaveClass('btn') + expect(queryByTestId('delete-button')).toHaveClass('btn-danger') + expect(queryByTestId('delete-button')).toHaveClass('extra') + expect(queryByTestId('delete-button')).not.toHaveClass('xtra') + expect(queryByTestId('delete-button')).not.toHaveClass('btn xtra') + expect(queryByTestId('delete-button')).not.toHaveClass('btn', 'xtra') + expect(queryByTestId('delete-button')).not.toHaveClass('btn', 'extra xtra') + expect(queryByTestId('delete-button')).toHaveClass('btn btn-danger') + expect(queryByTestId('delete-button')).toHaveClass('btn', 'btn-danger') + expect(queryByTestId('delete-button')).toHaveClass( + 'btn extra', + 'btn-danger extra', + ) + expect(queryByTestId('delete-button')).not.toHaveClass('btn-link') + expect(queryByTestId('cancel-button')).not.toHaveClass('btn-danger') + expect(queryByTestId('svg-spinner')).toHaveClass('spinner') + expect(queryByTestId('svg-spinner')).toHaveClass('clockwise') + expect(queryByTestId('svg-spinner')).not.toHaveClass('wise') + expect(queryByTestId('no-classes')).not.toHaveClass() + expect(queryByTestId('no-classes')).not.toHaveClass(' ') + + expect(() => + expect(queryByTestId('delete-button')).not.toHaveClass('btn'), + ).toThrowError() + expect(() => + expect(queryByTestId('delete-button')).not.toHaveClass('btn-danger'), + ).toThrowError() + expect(() => + expect(queryByTestId('delete-button')).not.toHaveClass('extra'), + ).toThrowError() + expect(() => + expect(queryByTestId('delete-button')).toHaveClass('xtra'), + ).toThrowError() + expect(() => + expect(queryByTestId('delete-button')).toHaveClass('btn', 'extra xtra'), + ).toThrowError() + expect(() => + expect(queryByTestId('delete-button')).not.toHaveClass('btn btn-danger'), + ).toThrowError() + expect(() => + expect(queryByTestId('delete-button')).not.toHaveClass('btn', 'btn-danger'), + ).toThrowError() + expect(() => + expect(queryByTestId('delete-button')).toHaveClass('btn-link'), + ).toThrowError() + expect(() => + expect(queryByTestId('cancel-button')).toHaveClass('btn-danger'), + ).toThrowError() + expect(() => + expect(queryByTestId('svg-spinner')).not.toHaveClass('spinner'), + ).toThrowError() + expect(() => + expect(queryByTestId('svg-spinner')).toHaveClass('wise'), + ).toThrowError() + expect(() => + expect(queryByTestId('delete-button')).toHaveClass(), + ).toThrowError(/At least one expected class must be provided/) + expect(() => + expect(queryByTestId('delete-button')).toHaveClass(''), + ).toThrowError(/At least one expected class must be provided/) + expect(() => expect(queryByTestId('no-classes')).toHaveClass()).toThrowError( + /At least one expected class must be provided/, + ) + expect(() => + expect(queryByTestId('delete-button')).not.toHaveClass(), + ).toThrowError(/(none)/) + expect(() => + expect(queryByTestId('delete-button')).not.toHaveClass(' '), + ).toThrowError(/(none)/) +}) + +test('.toHaveClass with regular expressions', () => { + const {queryByTestId} = renderElementWithClasses() + + expect(queryByTestId('delete-button')).toHaveClass(/btn/) + expect(queryByTestId('delete-button')).toHaveClass(/danger/) + expect(queryByTestId('delete-button')).toHaveClass( + /-danger$/, + 'extra', + /^btn-[a-z]+$/, + /\bbtn/, + ) + + // It does not match with "btn extra", even though it is a substring of the + // class "btn extra btn-danger". This is because the regular expression is + // matched against each class individually. + expect(queryByTestId('delete-button')).not.toHaveClass(/btn extra/) + + expect(() => + expect(queryByTestId('delete-button')).not.toHaveClass(/danger/), + ).toThrowError() + + expect(() => + expect(queryByTestId('delete-button')).toHaveClass(/dangerous/), + ).toThrowError() +}) + +test('.toHaveClass with exact mode option', () => { + const {queryByTestId} = renderElementWithClasses() + + expect(queryByTestId('delete-button')).toHaveClass('btn extra btn-danger', { + exact: true, + }) + expect(queryByTestId('delete-button')).not.toHaveClass('btn extra', { + exact: true, + }) + expect(queryByTestId('delete-button')).not.toHaveClass( + 'btn extra btn-danger foo', + {exact: true}, + ) + + expect(queryByTestId('delete-button')).toHaveClass('btn extra btn-danger', { + exact: false, + }) + expect(queryByTestId('delete-button')).toHaveClass('btn extra', { + exact: false, + }) + expect(queryByTestId('delete-button')).not.toHaveClass( + 'btn extra btn-danger foo', + {exact: false}, + ) + + expect(queryByTestId('delete-button')).toHaveClass( + 'btn', + 'extra', + 'btn-danger', + {exact: true}, + ) + expect(queryByTestId('delete-button')).not.toHaveClass('btn', 'extra', { + exact: true, + }) + expect(queryByTestId('delete-button')).not.toHaveClass( + 'btn', + 'extra', + 'btn-danger', + 'foo', + {exact: true}, + ) + + expect(queryByTestId('delete-button')).toHaveClass( + 'btn', + 'extra', + 'btn-danger', + {exact: false}, + ) + expect(queryByTestId('delete-button')).toHaveClass('btn', 'extra', { + exact: false, + }) + expect(queryByTestId('delete-button')).not.toHaveClass( + 'btn', + 'extra', + 'btn-danger', + 'foo', + {exact: false}, + ) + + expect(queryByTestId('only-one-class')).toHaveClass('alone', {exact: true}) + expect(queryByTestId('only-one-class')).not.toHaveClass('alone foo', { + exact: true, + }) + expect(queryByTestId('only-one-class')).not.toHaveClass('alone', 'foo', { + exact: true, + }) + + expect(queryByTestId('only-one-class')).toHaveClass('alone', {exact: false}) + expect(queryByTestId('only-one-class')).not.toHaveClass('alone foo', { + exact: false, + }) + expect(queryByTestId('only-one-class')).not.toHaveClass('alone', 'foo', { + exact: false, + }) + + expect(() => + expect(queryByTestId('only-one-class')).not.toHaveClass('alone', { + exact: true, + }), + ).toThrowError(/Expected the element not to have EXACTLY defined classes/) + + expect(() => + expect(queryByTestId('only-one-class')).toHaveClass('alone', 'foo', { + exact: true, + }), + ).toThrowError(/Expected the element to have EXACTLY defined classes/) +}) + +test('.toHaveClass combining {exact:true} and regular expressions throws an error', () => { + const {queryByTestId} = renderElementWithClasses() + + expect(() => + // @ts-expect-error regexp is not supported with exact + expect(queryByTestId('delete-button')).not.toHaveClass(/btn/, { + exact: true, + }), + ).toThrowError() + + expect(() => + expect(queryByTestId('delete-button')).not.toHaveClass( + // @ts-expect-error regexp is not supported with exact + /-danger$/, + 'extra', + /\bbtn/, + {exact: true}, + ), + ).toThrowError() + + expect(() => + // @ts-expect-error regexp is not supported with exact + expect(queryByTestId('delete-button')).toHaveClass(/danger/, {exact: true}), + ).toThrowError() +}) \ No newline at end of file diff --git a/test/browser/fixtures/expect-dom/toHaveDisplayValue.test.ts b/test/browser/fixtures/expect-dom/toHaveDisplayValue.test.ts new file mode 100644 index 000000000000..0ab46f34e923 --- /dev/null +++ b/test/browser/fixtures/expect-dom/toHaveDisplayValue.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it } from 'vitest' +import { render } from './utils' + +it('should work as expected', () => { + const {getInputByTestId} = render(` + + `) + + expect(getInputByTestId('select')).toHaveDisplayValue('Select a fruit...') + expect(getInputByTestId('select')).not.toHaveDisplayValue('Select') + expect(getInputByTestId('select')).not.toHaveDisplayValue('Banana') + expect(() => + expect(getInputByTestId('select')).not.toHaveDisplayValue('Select a fruit...'), + ).toThrow() + expect(() => + expect(getInputByTestId('select')).toHaveDisplayValue('Ananas'), + ).toThrow() + + getInputByTestId('select').value = 'banana' + expect(getInputByTestId('select')).toHaveDisplayValue('Banana') + expect(getInputByTestId('select')).toHaveDisplayValue(/[bB]ana/) +}) + +describe('with multiple select', () => { + function mount() { + return render(` + + `) + } + + it('matches only when all the multiple selected values are equal to all the expected values', () => { + const subject = mount() + expect(subject.queryByTestId('select')).toHaveDisplayValue([ + 'Ananas', + 'Avocado', + ]) + expect(() => + expect(subject.queryByTestId('select')).not.toHaveDisplayValue([ + 'Ananas', + 'Avocado', + ]), + ).toThrow() + expect(subject.queryByTestId('select')).not.toHaveDisplayValue([ + 'Ananas', + 'Avocado', + 'Orange', + ]) + expect(subject.queryByTestId('select')).not.toHaveDisplayValue('Ananas') + expect(() => + expect(subject.queryByTestId('select')).toHaveDisplayValue('Ananas'), + ).toThrow() + + Array.from((subject.queryByTestId('select') as HTMLSelectElement).options).forEach(option => { + option.selected = ['ananas', 'banana'].includes(option.value) + }) + + expect(subject.queryByTestId('select')).toHaveDisplayValue([ + 'Ananas', + 'Banana', + ]) + }) + + it('matches even when the expected values are unordered', () => { + const subject = mount() + expect(subject.queryByTestId('select')).toHaveDisplayValue([ + 'Avocado', + 'Ananas', + ]) + }) + + it('matches with regex expected values', () => { + const subject = mount() + expect(subject.queryByTestId('select')).toHaveDisplayValue([ + /[Aa]nanas/, + 'Avocado', + ]) + }) +}) + +it('should work with input elements', () => { + const {getInputByTestId} = render(` + + `) + + expect(getInputByTestId('input')).toHaveDisplayValue('Luca') + expect(getInputByTestId('input')).toHaveDisplayValue(/Luc/) + + getInputByTestId('input').value = 'Piero' + expect(getInputByTestId('input')).toHaveDisplayValue('Piero') +}) + +it('should work with textarea elements', () => { + const {getInputByTestId} = render( + '', + ) + + expect(getInputByTestId('textarea-example')).toHaveDisplayValue( + 'An example description here.', + ) + expect(getInputByTestId('textarea-example')).toHaveDisplayValue(/example/) + + getInputByTestId('textarea-example').value = 'Another example' + expect(getInputByTestId('textarea-example')).toHaveDisplayValue( + 'Another example', + ) +}) + +it('should throw if element is not valid', () => { + const {queryByTestId} = render(` +
Banana
+ + + `) + + let errorMessage + try { + expect(queryByTestId('div')).toHaveDisplayValue('Banana') + } catch (err) { + errorMessage = err.message + } + + expect(errorMessage).toMatchInlineSnapshot( + `.toHaveDisplayValue() currently supports only input, textarea or select elements, try with another matcher instead.`, + ) + + try { + expect(queryByTestId('radio')).toHaveDisplayValue('Something') + } catch (err) { + errorMessage = err.message + } + + expect(errorMessage).toMatchInlineSnapshot( + `.toHaveDisplayValue() currently does not support input[type="radio"], try with another matcher instead.`, + ) + + try { + // @ts-expect-error + expect(queryByTestId('checkbox')).toHaveDisplayValue(true) + } catch (err) { + errorMessage = err.message + } + + expect(errorMessage).toMatchInlineSnapshot( + `.toHaveDisplayValue() currently does not support input[type="checkbox"], try with another matcher instead.`, + ) +}) + +it('should work with numbers', () => { + const {queryByTestId} = render(` + + `) + + expect(queryByTestId('select')).toHaveDisplayValue(1) +}) \ No newline at end of file diff --git a/test/browser/fixtures/expect-dom/toHaveFocus.test.ts b/test/browser/fixtures/expect-dom/toHaveFocus.test.ts new file mode 100644 index 000000000000..286088c99ed2 --- /dev/null +++ b/test/browser/fixtures/expect-dom/toHaveFocus.test.ts @@ -0,0 +1,23 @@ +import { expect, test } from 'vitest' +import { render } from './utils' + +test('.toHaveFocus', () => { + const {container} = render(` +
+ + + +
`) + + const focused = container.querySelector('#focused') as HTMLInputElement + const notFocused = container.querySelector('#not-focused') + + document.body.appendChild(container) + focused.focus() + + expect(focused).toHaveFocus() + expect(notFocused).not.toHaveFocus() + + expect(() => expect(focused).not.toHaveFocus()).toThrowError() + expect(() => expect(notFocused).toHaveFocus()).toThrowError() +}) \ No newline at end of file diff --git a/test/browser/fixtures/expect-dom/toHaveFormValues.test.ts b/test/browser/fixtures/expect-dom/toHaveFormValues.test.ts new file mode 100644 index 000000000000..0648ca6a2005 --- /dev/null +++ b/test/browser/fixtures/expect-dom/toHaveFormValues.test.ts @@ -0,0 +1,352 @@ +import { describe, expect, it } from 'vitest' +import { render } from './utils' + +const categories = [ + {value: '', label: '–'}, + {value: 'design', label: 'Design'}, + {value: 'ux', label: 'User Experience'}, + {value: 'programming', label: 'Programming'}, +] + +const skills = [ + {value: 'c-sharp', label: 'C#'}, + {value: 'graphql', label: 'GraphQl'}, + {value: 'javascript', label: 'JavaScript'}, + {value: 'ruby-on-rails', label: 'Ruby on Rails'}, + {value: 'python', label: 'Python'}, +] + +const defaultValues = { + title: 'Full-stack developer', + salary: 12345, + category: 'programming', + skills: ['javascript', 'ruby-on-rails'], + description: 'You need to know your stuff', + remote: true, + freelancing: false, + 'is%Private^': true, + 'benefits[0]': 'Fruit & free drinks everyday', + 'benefits[1]': 'Multicultural environment', +} + +function renderForm({ + selectSingle = renderSelectSingle, + selectMultiple = renderSelectMultiple, + values: valueOverrides = {}, +} = {}) { + const values = { + ...defaultValues, + ...valueOverrides, + } + const {container} = render(` +
+ + + + + + + + + + + + + + + +
+ Benefits + + +
+ + + + + ${selectSingle('category', 'Category', categories, values.category)} + ${selectMultiple('skills', 'Skills', skills, values.skills)} +
+ `) + return container.querySelector('form') +} + +describe('.toHaveFormValues', () => { + it('works as expected', () => { + expect(renderForm()).toHaveFormValues(defaultValues) + }) + + it('allows to match partially', () => { + expect(renderForm()).toHaveFormValues({ + category: 'programming', + salary: 12345, + }) + }) + + it('supports checkboxes for multiple selection', () => { + expect(renderForm({selectMultiple: renderCheckboxes})).toHaveFormValues({ + skills: ['javascript', 'ruby-on-rails'], + }) + }) + + it('supports radio-buttons for single selection', () => { + expect(renderForm({selectSingle: renderRadioButtons})).toHaveFormValues({ + category: 'programming', + }) + }) + + it('matches sets of selected values regardless of the order', () => { + const form = renderForm() + expect(form).toHaveFormValues({ + skills: ['ruby-on-rails', 'javascript'], + }) + expect(form).toHaveFormValues({ + skills: ['javascript', 'ruby-on-rails'], + }) + }) + + it('correctly handles empty values', () => { + expect( + renderForm({ + values: { + title: '', + salary: null, + category: null, + skills: [], + description: '', + }, + }), + ).toHaveFormValues({ + title: '', + salary: null, + category: '', + skills: [], + description: '', + }) + }) + + it('handles values correctly', () => { + expect(renderForm({values: {salary: 123.456}})).toHaveFormValues({ + salary: 123.456, + }) + expect(renderForm({values: {salary: '1e5'}})).toHaveFormValues({ + salary: 1e5, + }) + expect(renderForm({values: {salary: '1.35e5'}})).toHaveFormValues({ + salary: 135000, + }) + expect(renderForm({values: {salary: '-5.9'}})).toHaveFormValues({ + salary: -5.9, + }) + }) + + describe('edge cases', () => { + // This is also to ensure 100% code coverage for edge cases + it('detects multiple elements with the same name but different type', () => { + const {container} = render(` +
+ + +
+ `) + const form = container.querySelector('form') + expect(() => { + expect(form).toHaveFormValues({}) + }).toThrowError(/must be of the same type/) + }) + + it('detects multiple elements with the same type and name', () => { + const {container} = render(` +
+ + +
+ `) + const form = container.querySelector('form') + expect(form).toHaveFormValues({ + title: ['one', 'two'], + }) + }) + + it('supports radio buttons with none selected', () => { + expect( + renderForm({ + selectSingle: renderRadioButtons, + values: {category: undefined}, + }), + ).toHaveFormValues({ + category: undefined, + }) + }) + + it('supports being called only on form and fieldset elements', () => { + const expectedValues = {title: 'one', description: 'two'} + const {container} = render(` +
+ + +
+ `) + const form = container.querySelector('form') + expect(() => { + expect(container).toHaveFormValues(expectedValues) + }).toThrowError(/a form or a fieldset/) + expect(() => { + expect(form).toHaveFormValues(expectedValues) + }).not.toThrowError() + }) + + it('matches change in selected value of select', () => { + const oldValue = '' + const newValue = 'design' + + const {container} = render(` +
+ ${renderSelectSingle('category', 'Category', categories, oldValue)} +
+ `) + + const form = container.querySelector('form') + const select = container.querySelector('select') + expect(form).toHaveFormValues({category: oldValue}) + + select.value = newValue + expect(form).toHaveFormValues({category: newValue}) + }) + }) + + describe('failed assertions', () => { + it('work as expected', () => { + expect(() => { + expect(renderForm()).not.toHaveFormValues(defaultValues) + }).toThrowError(/Expected the element not to have form values/) + expect(() => { + expect(renderForm()).toHaveFormValues({something: 'missing'}) + }).toThrowError(/Expected the element to have form values/) + }) + }) +}) + +// Form control renderers + +function isSelected(value, option) { + return Array.isArray(value) && value.indexOf(option.value) >= 0 +} + +function renderCheckboxes(name, label, options, value = []) { + return ` +
+ ${label} + ${renderList( + options, + option => ` +
+ + +
+ `, + )} +
+ ` +} + +function renderRadioButtons(name, label, options, value = undefined) { + return ` +
+ ${label} + ${renderList( + options, + option => ` +
+ + +
+ `, + )} +
+ ` +} + +function renderSelect(name, label, options, value, multiple) { + return ` + + + ` +} + +function renderSelectSingle(name, label, options, value = undefined) { + return renderSelect( + name, + label, + options, + value === undefined || value === null ? [] : [value], + false, + ) +} + +function renderSelectMultiple(name, label, options, value = []) { + return renderSelect(name, label, options, value, true) +} + +function renderList(items, mapper) { + return items.map(mapper).join('') +} \ No newline at end of file diff --git a/test/browser/fixtures/expect-dom/toHaveRole.test.ts b/test/browser/fixtures/expect-dom/toHaveRole.test.ts new file mode 100644 index 000000000000..e7611b23f2af --- /dev/null +++ b/test/browser/fixtures/expect-dom/toHaveRole.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest' +import { render } from './utils' + +describe('.toHaveRole', () => { + it('matches implicit role', () => { + const {queryByTestId} = render(` +
+ +
+ `) + + const continueButton = queryByTestId('continue-button') + + expect(continueButton).not.toHaveRole('listitem') + expect(continueButton).toHaveRole('button') + + expect(() => { + expect(continueButton).toHaveRole('listitem') + }).toThrow(/expected element to have role/i) + expect(() => { + expect(continueButton).not.toHaveRole('button') + }).toThrow(/expected element not to have role/i) + }) + + it('matches explicit role', () => { + const {queryByTestId} = render(` +
+
Continue
+
+ `) + + const continueButton = queryByTestId('continue-button') + + expect(continueButton).not.toHaveRole('listitem') + expect(continueButton).toHaveRole('button') + + expect(() => { + expect(continueButton).toHaveRole('listitem') + }).toThrow(/expected element to have role/i) + expect(() => { + expect(continueButton).not.toHaveRole('button') + }).toThrow(/expected element not to have role/i) + }) + + it('matches multiple explicit roles', () => { + const {queryByTestId} = render(` +
+
Continue
+
+ `) + + const continueButton = queryByTestId('continue-button') + + expect(continueButton).not.toHaveRole('listitem') + expect(continueButton).toHaveRole('button') + expect(continueButton).not.toHaveRole('switch') + + expect(() => { + expect(continueButton).toHaveRole('listitem') + }).toThrow(/expected element to have role/i) + expect(() => { + expect(continueButton).not.toHaveRole('button') + }).toThrow(/expected element not to have role/i) + }) +}) diff --git a/test/browser/fixtures/expect-dom/toHaveSelection.test.ts b/test/browser/fixtures/expect-dom/toHaveSelection.test.ts new file mode 100644 index 000000000000..2687030c2052 --- /dev/null +++ b/test/browser/fixtures/expect-dom/toHaveSelection.test.ts @@ -0,0 +1,183 @@ +import { describe, expect, test } from 'vitest' +import { render } from './utils' + +describe('.toHaveSelection', () => { + test.each(['text', 'password', 'textarea'])( + 'handles selection within form elements', + testId => { + const {getInputByTestId} = render(` + + + + `) + + getInputByTestId(testId).setSelectionRange(5, 13) + expect(getInputByTestId(testId)).toHaveSelection('selected') + + getInputByTestId(testId).select() + expect(getInputByTestId(testId)).toHaveSelection('text selected text') + }, + ) + + test.each(['checkbox', 'radio'])( + 'returns empty string for form elements without text', + testId => { + const {getInputByTestId} = render(` + + + `) + + getInputByTestId(testId).select() + expect(getInputByTestId(testId)).toHaveSelection('') + }, + ) + + test('does not match subset string', () => { + const {getInputByTestId} = render(` + + `) + + getInputByTestId('text').setSelectionRange(5, 13) + expect(getInputByTestId('text')).not.toHaveSelection('select') + expect(getInputByTestId('text')).toHaveSelection('selected') + }) + + test('accepts any selection when expected selection is missing', () => { + const {getInputByTestId} = render(` + + `) + + expect(getInputByTestId('text')).not.toHaveSelection() + + getInputByTestId('text').setSelectionRange(5, 13) + + expect(getInputByTestId('text')).toHaveSelection() + }) + + test('throws when form element is not selected', () => { + const {queryByTestId} = render(` + + `) + + expect(() => + expect(queryByTestId('text')).toHaveSelection(), + ).toThrowErrorMatchingInlineSnapshot(` + [Error: expect(element).toHaveSelection(expected) + + Expected the element to have selection: + (any) + Received: + ] + `) + }) + + test('throws when form element is selected', () => { + const {getInputByTestId} = render(` + + `) + getInputByTestId('text').setSelectionRange(5, 13) + + expect(() => + expect(getInputByTestId('text')).not.toHaveSelection(), + ).toThrowErrorMatchingInlineSnapshot(` + [Error: expect(element).not.toHaveSelection(expected) + + Expected the element not to have selection: + (any) + Received: + selected] + `) + }) + + test('throws when element is not selected', () => { + const {queryByTestId} = render(` +
text
+ `) + + expect(() => + expect(queryByTestId('text')).toHaveSelection(), + ).toThrowErrorMatchingInlineSnapshot(` + [Error: expect(element).toHaveSelection(expected) + + Expected the element to have selection: + (any) + Received: + ] + `) + }) + + test('throws when element selection does not match', () => { + const {getInputByTestId} = render(` + + `) + getInputByTestId('text').setSelectionRange(0, 4) + + expect(() => + expect(getInputByTestId('text')).toHaveSelection('no match'), + ).toThrowErrorMatchingInlineSnapshot(` + [Error: expect(element).toHaveSelection(no match) + + Expected the element to have selection: + no match + Received: + text] + `) + }) + + test('handles selection within text nodes', () => { + const {queryByTestId} = render(` +
+
prev
+
text selected text
+
next
+
+ `) + + const selection = queryByTestId('child').ownerDocument.getSelection() + const range = queryByTestId('child').ownerDocument.createRange() + selection.removeAllRanges() + selection.empty() + selection.addRange(range) + + range.selectNodeContents(queryByTestId('child')) + + expect(queryByTestId('child')).toHaveSelection('selected') + expect(queryByTestId('parent')).toHaveSelection('selected') + + range.selectNodeContents(queryByTestId('parent')) + + expect(queryByTestId('child')).toHaveSelection('selected') + expect(queryByTestId('parent')).toHaveSelection('text selected text') + + range.setStart(queryByTestId('prev'), 0) + range.setEnd(queryByTestId('child').childNodes[0], 3) + + expect(queryByTestId('prev')).toHaveSelection('prev') + expect(queryByTestId('child')).toHaveSelection('sel') + expect(queryByTestId('parent')).toHaveSelection('text sel') + expect(queryByTestId('next')).not.toHaveSelection() + + range.setStart(queryByTestId('child').childNodes[0], 3) + range.setEnd(queryByTestId('next').childNodes[0], 2) + + expect(queryByTestId('child')).toHaveSelection('ected') + expect(queryByTestId('parent')).toHaveSelection('ected text') + expect(queryByTestId('prev')).not.toHaveSelection() + expect(queryByTestId('next')).toHaveSelection('ne') + }) + + test('throws with information when the expected selection is not string', () => { + const {container} = render(`
1
`) + const element = container.firstChild + const range = element.ownerDocument.createRange() + range.selectNodeContents(element) + element.ownerDocument.getSelection().addRange(range) + + expect(() => + // @ts-expect-error wrong type + expect(element).toHaveSelection(1), + ).toThrowErrorMatchingInlineSnapshot( + `[Error: expected selection must be a string or undefined]` + ) + }) +}) \ No newline at end of file diff --git a/test/browser/fixtures/expect-dom/toHaveStyle.test.ts b/test/browser/fixtures/expect-dom/toHaveStyle.test.ts new file mode 100644 index 000000000000..410f0e1a1525 --- /dev/null +++ b/test/browser/fixtures/expect-dom/toHaveStyle.test.ts @@ -0,0 +1,283 @@ +import { describe, expect, test } from 'vitest' +import { render } from './utils' + +describe('.toHaveStyle', () => { + test('handles positive test cases', () => { + const { container } = render(` +
+ Hello World +
+ `) + + const style = document.createElement('style') + style.innerHTML = ` + .label { + align-items: center; + background-color: black; + color: white; + float: left; + transition: opacity 0.2s ease-out, top 0.3s cubic-bezier(0.1, 0.7, 1.0, 0.1); + transform: translateX(0px); + } + ` + document.body.appendChild(style) + document.body.appendChild(container) + + // border: fakefake doesn't exist + expect(() => { + expect(container.querySelector('.label')).toHaveStyle('border: fakefake') + }).toThrowError() + + expect(container.querySelector('.label')).toHaveStyle(` + height: 100%; + color: white; + background-color: blue; + `) + + expect(container.querySelector('.label')).toHaveStyle(` + background-color: blue; + color: white; + `) + + expect(container.querySelector('.label')).toHaveStyle( + 'transition: opacity 0.2s ease-out, top 0.3s cubic-bezier(0.1, 0.7, 1.0, 0.1)', + ) + + expect(container.querySelector('.label')).toHaveStyle( + 'background-color:blue;color:white', + ) + + expect(container.querySelector('.label')).not.toHaveStyle(` + color: white; + font-weight: bold; + `) + + expect(container.querySelector('.label')).toHaveStyle(` + Align-items: center; + `) + + expect(container.querySelector('.label')).toHaveStyle(` + transform: translateX(0px); + `) + }) + + test('handles negative test cases', () => { + const { container } = render(` +
+ Hello World +
+ `) + + const style = document.createElement('style') + style.innerHTML = ` + .label { + background-color: black; + color: white; + float: left; + --var-name: 0px; + transition: opacity 0.2s ease-out, top 0.3s cubic-bezier(1.175, 0.885, 0.32, 1.275); + } + ` + document.body.appendChild(style) + document.body.appendChild(container) + + // CSS parser is forgiving, it doesn't throw + // expect(() => + // expect(container.querySelector('.label')).not.toHaveStyle( + // 'font-weight bold', + // ), + // ).toThrowError() + + expect(() => + expect(container.querySelector('.label')).toHaveStyle( + 'font-weight: bold', + ), + ).toThrowError() + + expect(() => + expect(container.querySelector('.label')).not.toHaveStyle('color: white'), + ).toThrowError() + + expect(() => + expect(container.querySelector('.label')).toHaveStyle( + 'transition: all 0.7s ease, width 1.0s cubic-bezier(3, 4, 5, 6);', + ), + ).toThrowError() + + // Custom property names are case sensitive + expect(() => + expect(container.querySelector('.label')).toHaveStyle('--VAR-NAME: 0px;'), + ).toThrowError() + + expect(() => + expect(container.querySelector('.label')).toHaveStyle('color white'), + ).toThrowError() + + expect(() => + expect(container.querySelector('.label')).toHaveStyle('--color: black'), + ).toThrowError() + document.body.removeChild(style) + document.body.removeChild(container) + }) + + test('properly normalizes colors', () => { + const { queryByTestId } = render(` + Hello World + `) + expect(queryByTestId('color-example')).toHaveStyle( + 'background-color: #123456', + ) + }) + + test('handles inline custom properties (with uppercase letters)', () => { + const { queryByTestId } = render(` + Hello World + `) + expect(queryByTestId('color-example')).toHaveStyle('--accentColor: blue') + }) + + test('handles global custom properties', () => { + const style = document.createElement('style') + style.innerHTML = ` + div { + --color: blue; + } + ` + + const { container } = render(` +
+ Hello world +
+ `) + + document.body.appendChild(style) + document.body.appendChild(container) + + expect(container).toHaveStyle(`--color: blue`) + }) + + test('properly normalizes colors for border', () => { + const { queryByTestId } = render(` + Hello World + `) + expect(queryByTestId('color-example')).toHaveStyle('border: 1px solid #fff') + }) + + test('handles different color declaration formats', () => { + const { queryByTestId } = render(` + Hello World + `) + + expect(queryByTestId('color-example')).toHaveStyle('color: #000000') + expect(queryByTestId('color-example')).toHaveStyle( + 'background-color: rgba(0, 0, 0, 1)', + ) + }) + + test('handles nonexistent styles', () => { + const { container } = render(` +
+ Hello World +
+ `) + + expect(container.querySelector('.label')).not.toHaveStyle( + 'whatever: anything', + ) + }) + + describe('object syntax', () => { + test('handles styles as object', () => { + const { container } = render(` +
+ Hello World +
+ `) + + expect(container.querySelector('.label')).toHaveStyle({ + backgroundColor: 'blue', + }) + expect(container.querySelector('.label')).toHaveStyle({ + backgroundColor: 'blue', + height: '100%', + }) + expect(container.querySelector('.label')).not.toHaveStyle({ + backgroundColor: 'red', + height: '100%', + }) + expect(container.querySelector('.label')).not.toHaveStyle({ + whatever: 'anything', + }) + }) + + // https://github.com/testing-library/jest-dom/issues/350 + test('Uses correct computed values', () => { + const { container } = render(` +
+ Hello World +
+ `) + + const style = document.createElement('style') + style.innerHTML = ` + .label { + color: #fff; + background-color: #000; + } + ` + document.body.appendChild(style) + document.body.appendChild(container) + + expect(container.querySelector('.label')).toHaveStyle('color: #FFF') + }) + + test('Uses px as the default unit', () => { + const { queryByTestId } = render(` + Hello World + `) + expect(queryByTestId('color-example')).toHaveStyle({ + // in jest-dom '12' is converted to 12px + // but in the browser setting the style to 12 wil have no effect, + // so Vitest prioritizes the browser behavior + // fontSize: 12, + fontSize: '12px', + }) + }) + + test('Fails with an invalid unit', () => { + const { queryByTestId } = render(` + Hello World + `) + expect(() => { + expect(queryByTestId('color-example')).toHaveStyle({ fontSize: '12px' }) + }).toThrowError() + }) + + test('supports dash-cased property names', () => { + const { container } = render(` +
+ Hello World +
+ `) + expect(container.querySelector('.label')).toHaveStyle({ + 'background-color': 'blue', + }) + }) + + test('requires strict empty properties matching', () => { + const { container } = render(` +
+ Hello World +
+ `) + expect(container.querySelector('.label')).not.toHaveStyle({ + width: '100%', + height: '', + }) + expect(container.querySelector('.label')).not.toHaveStyle({ + width: '', + height: '', + }) + }) + }) +}) diff --git a/test/browser/fixtures/expect-dom/toHaveTextContent.test.ts b/test/browser/fixtures/expect-dom/toHaveTextContent.test.ts new file mode 100644 index 000000000000..2d0dbf6ee9ea --- /dev/null +++ b/test/browser/fixtures/expect-dom/toHaveTextContent.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, test } from 'vitest' +import { render } from './utils' + +describe('.toHaveTextContent', () => { + test('handles positive test cases', () => { + const {queryByTestId} = render(`2`) + + expect(queryByTestId('count-value')).toHaveTextContent('2') + expect(queryByTestId('count-value')).toHaveTextContent(2) + expect(queryByTestId('count-value')).toHaveTextContent(/2/) + expect(queryByTestId('count-value')).not.toHaveTextContent('21') + }) + + test('handles text nodes', () => { + const {container} = render(`example`) + + expect(container.querySelector('span').firstChild).toHaveTextContent( + 'example', + ) + }) + + test('handles fragments', () => { + const {asFragment} = render(`example`) + + expect(asFragment()).toHaveTextContent('example') + }) + + test('handles negative test cases', () => { + const {queryByTestId} = render(`2`) + + expect(() => + expect(queryByTestId('count-value2')).toHaveTextContent('2'), + ).toThrowError() + + expect(() => + expect(queryByTestId('count-value')).toHaveTextContent('3'), + ).toThrowError() + expect(() => + expect(queryByTestId('count-value')).not.toHaveTextContent('2'), + ).toThrowError() + }) + + test('normalizes whitespace by default', () => { + const {container} = render(` + + Step + 1 + of + 4 + + `) + + expect(container.querySelector('span')).toHaveTextContent('Step 1 of 4') + }) + + test('allows whitespace normalization to be turned off', () => { + const {container} = render(`  Step 1 of 4`) + + expect(container.querySelector('span')).toHaveTextContent(' Step 1 of 4', { + normalizeWhitespace: false, + }) + }) + + test('can handle multiple levels', () => { + const {container} = render(`Step 1 + + of 4`) + + expect(container.querySelector('#parent')).toHaveTextContent('Step 1 of 4') + }) + + test('can handle multiple levels with content spread across decendants', () => { + const {container} = render(` + + Step + 1 + of + + + 4 +
+ `) + + expect(container.querySelector('#parent')).toHaveTextContent('Step 1 of 4') + }) + + test('does not throw error with empty content', () => { + const {container} = render(``) + expect(container.querySelector('span')).toHaveTextContent('') + }) + + test('is case-sensitive', () => { + const {container} = render('Sensitive text') + + expect(container.querySelector('span')).toHaveTextContent('Sensitive text') + expect(container.querySelector('span')).not.toHaveTextContent( + 'sensitive text', + ) + }) + + test('when matching with empty string and element with content, suggest using toBeEmptyDOMElement instead', () => { + // https://github.com/testing-library/jest-dom/issues/104 + const {container} = render('not empty') + + expect(() => + expect(container.querySelector('span')).toHaveTextContent(''), + ).toThrowError(/toBeEmptyDOMElement\(\)/) + }) +}) \ No newline at end of file diff --git a/test/browser/fixtures/expect-dom/toHaveValue.test.ts b/test/browser/fixtures/expect-dom/toHaveValue.test.ts new file mode 100644 index 000000000000..4bdc3dafea0c --- /dev/null +++ b/test/browser/fixtures/expect-dom/toHaveValue.test.ts @@ -0,0 +1,221 @@ +import { describe, expect, test } from 'vitest' +import { render } from './utils' + +describe('.toHaveValue', () => { + test('handles value of text input', () => { + const {queryByTestId, getInputByTestId} = render(` + + + + `) + + expect(queryByTestId('value')).toHaveValue('foo') + expect(queryByTestId('value')).toHaveValue() + expect(queryByTestId('value')).not.toHaveValue('bar') + expect(queryByTestId('value')).not.toHaveValue('') + + expect(queryByTestId('empty')).toHaveValue('') + expect(queryByTestId('empty')).not.toHaveValue() + expect(queryByTestId('empty')).not.toHaveValue('foo') + + expect(queryByTestId('without')).toHaveValue('') + expect(queryByTestId('without')).not.toHaveValue() + expect(queryByTestId('without')).not.toHaveValue('foo') + getInputByTestId('without').value = 'bar' + expect(queryByTestId('without')).toHaveValue('bar') + }) + + test('handles value of number input', () => { + const {queryByTestId, getInputByTestId} = render(` + + + + `) + + expect(queryByTestId('number')).toHaveValue(5) + expect(queryByTestId('number')).toHaveValue() + expect(queryByTestId('number')).not.toHaveValue(4) + expect(queryByTestId('number')).not.toHaveValue('5') + + expect(queryByTestId('empty')).toHaveValue(null) + expect(queryByTestId('empty')).not.toHaveValue() + expect(queryByTestId('empty')).not.toHaveValue('5') + + expect(queryByTestId('without')).toHaveValue(null) + expect(queryByTestId('without')).not.toHaveValue() + expect(queryByTestId('without')).not.toHaveValue('10') + // @ts-expect-error ts doesn't allow value to be a number, but browser will convert it + getInputByTestId('without').value = 10 + expect(queryByTestId('without')).toHaveValue(10) + }) + + test('handles value of select element', () => { + const {queryByTestId} = render(` + + + + + + `) + + expect(queryByTestId('single')).toHaveValue('second') + expect(queryByTestId('single')).toHaveValue() + + expect(queryByTestId('multiple')).toHaveValue(['second', 'third']) + expect(queryByTestId('multiple')).toHaveValue() + + expect(queryByTestId('not-selected')).not.toHaveValue() + expect(queryByTestId('not-selected')).toHaveValue('') + + queryByTestId('single').children[0].setAttribute('selected', 'true') + expect(queryByTestId('single')).toHaveValue('first') + }) + + test('handles value of textarea element', () => { + const {queryByTestId} = render(` + + `) + expect(queryByTestId('textarea')).toHaveValue('text value') + }) + + test('throws when passed checkbox or radio', () => { + const {queryByTestId} = render(` + + + `) + + expect(() => { + expect(queryByTestId('checkbox')).toHaveValue('') + }).toThrow() + + expect(() => { + expect(queryByTestId('radio')).toHaveValue('') + }).toThrow() + }) + + test('throws when the expected input value does not match', () => { + const {container} = render(``) + const input = container.firstChild + let errorMessage + try { + expect(input).toHaveValue('something else') + } catch (error) { + errorMessage = error.message + } + + expect(errorMessage).toMatchInlineSnapshot(` + expect(element).toHaveValue(something else) + + Expected the element to have value: + something else + Received: + foo + `) + }) + + test('throws with type information when the expected text input value has loose equality with received value', () => { + const {container} = render(``) + const input = container.firstChild + let errorMessage + try { + expect(input).toHaveValue(8) + } catch (error) { + errorMessage = error.message + } + + expect(errorMessage).toMatchInlineSnapshot(` + expect(element).toHaveValue(8) + + Expected the element to have value: + 8 (number) + Received: + 8 (string) + `) + }) + + test('throws when using not but the expected input value does match', () => { + const {container} = render(``) + const input = container.firstChild + let errorMessage + + try { + expect(input).not.toHaveValue('foo') + } catch (error) { + errorMessage = error.message + } + expect(errorMessage).toMatchInlineSnapshot(` + expect(element).not.toHaveValue(foo) + + Expected the element not to have value: + foo + Received: + foo + `) + }) + + test('throws when the form has no a value but a value is expected', () => { + const {container} = render(``) + const input = container.firstChild + let errorMessage + + try { + expect(input).toHaveValue() + } catch (error) { + errorMessage = error.message + } + expect(errorMessage).toMatchInlineSnapshot(` + expect(element).toHaveValue(expected) + + Expected the element to have value: + (any) + Received: + `) + }) + + test('throws when the form has a value but none is expected', () => { + const {container} = render(``) + const input = container.firstChild + let errorMessage + + try { + expect(input).not.toHaveValue() + } catch (error) { + errorMessage = error.message + } + expect(errorMessage).toMatchInlineSnapshot(` + expect(element).not.toHaveValue(expected) + + Expected the element not to have value: + (any) + Received: + foo + `) + }) + + test('handles value of aria-valuenow', () => { + const valueToCheck = 70 + const {queryByTestId} = render(` +
+
+ `) + + expect(queryByTestId('meter')).toHaveValue(valueToCheck) + expect(queryByTestId('meter')).not.toHaveValue(valueToCheck + 1) + + // Role that does not support aria-valuenow + expect(queryByTestId('textbox')).not.toHaveValue(70) + }) +}) \ No newline at end of file diff --git a/test/browser/fixtures/expect-dom/utils.ts b/test/browser/fixtures/expect-dom/utils.ts new file mode 100644 index 000000000000..86b74b4d766a --- /dev/null +++ b/test/browser/fixtures/expect-dom/utils.ts @@ -0,0 +1,19 @@ +function render(html: string) { + const container = document.createElement('div') + container.innerHTML = html + const queryByTestId = (testId: string) => + container.querySelector(`[data-testid="${testId}"]`) as HTMLElement | SVGElement | null + // asFragment has been stolen from react-testing-library + const asFragment = () => + document.createRange().createContextualFragment(container.innerHTML) + const getInputByTestId = (testId: string) => queryByTestId(testId) as HTMLInputElement + + // Some tests need to look up global ids with document.getElementById() + // so we need to be inside an actual document. + document.body.innerHTML = '' + document.body.appendChild(container) + + return { container, queryByTestId, asFragment, getInputByTestId } +} + +export { render } diff --git a/test/browser/fixtures/expect-dom/vitest.config.js b/test/browser/fixtures/expect-dom/vitest.config.js new file mode 100644 index 000000000000..ccbaf85a5770 --- /dev/null +++ b/test/browser/fixtures/expect-dom/vitest.config.js @@ -0,0 +1,16 @@ +import { defineConfig } from 'vitest/config' +import { fileURLToPath } from 'node:url' +import { instances, provider } from '../../settings' + +export default defineConfig({ + cacheDir: fileURLToPath(new URL("./node_modules/.vite", import.meta.url)), + test: { + browser: { + enabled: true, + provider, + instances, + isolate: false, + }, + setupFiles: './setup.ts', + }, +}) \ No newline at end of file diff --git a/test/browser/package.json b/test/browser/package.json index baaa4d72f17a..67afee465e1f 100644 --- a/test/browser/package.json +++ b/test/browser/package.json @@ -9,6 +9,7 @@ "test:playwright": "PROVIDER=playwright pnpm run test:unit", "test:safaridriver": "PROVIDER=webdriverio BROWSER=safari pnpm run test:unit", "test-fixtures": "vitest", + "test-expect-dom": "vitest --root ./fixtures/expect-dom", "test-mocking": "vitest --root ./fixtures/mocking", "test-timeout": "vitest --root ./fixtures/timeout", "test-mocking-watch": "vitest --root ./fixtures/mocking-watch", diff --git a/test/browser/specs/expect-dom.test.ts b/test/browser/specs/expect-dom.test.ts new file mode 100644 index 000000000000..d88d2e4c38d1 --- /dev/null +++ b/test/browser/specs/expect-dom.test.ts @@ -0,0 +1,21 @@ +import { expect, test } from 'vitest' +import { instances, runBrowserTests } from './utils' + +const testNames = Object.keys(import.meta.glob('../fixtures/expect-dom/*.test.ts', { + eager: false, +})).map(path => path.slice('../fixtures/expect-dom/'.length)) + +test('expect-dom works correctly', async () => { + const { stderr, stdout } = await runBrowserTests({ + root: './fixtures/expect-dom', + }) + + expect(stderr).toReportNoErrors() + instances.forEach(({ browser }) => { + testNames.forEach((name) => { + expect(stdout).toReportPassedTest(name, browser) + }) + }) + + expect(stdout).toContain(`Test Files ${instances.length * testNames.length} passed`) +})