diff --git a/.changeset/thirty-dingos-feel.md b/.changeset/thirty-dingos-feel.md new file mode 100644 index 0000000000..1180c03e31 --- /dev/null +++ b/.changeset/thirty-dingos-feel.md @@ -0,0 +1,8 @@ +--- +"@lynx-js/react-lynx-testing-library": patch +"@lynx-js/lynx-dom-testing-library": patch +"@lynx-js/lynx-dom-jest-matchers": patch +"@lynx-js/lynx-dom": patch +--- + +Add testing library for ReactLynx diff --git a/.dprint.jsonc b/.dprint.jsonc index ab5a1fe0ca..ce5c1dc4b3 100644 --- a/.dprint.jsonc +++ b/.dprint.jsonc @@ -51,6 +51,8 @@ "**/expected/**", "**/rspack-expected/**", "packages/**/test/**/hotCases/**", + + "packages/testing-library/react-lynx-testing-library/tsconfig.json", ], "plugins": [ "https://plugins.dprint.dev/exec-0.5.0.json@8d9972eee71fa1590e04873540421f3eda7674d0f1aae3d7c788615e7b7413d0", diff --git a/biome.jsonc b/biome.jsonc index a672a9836b..ecfaf0fae4 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -43,6 +43,8 @@ "packages/web-platform/**", "packages/third-party/**", + + "packages/testing-library/**", ], "rules": { // We are migrating from ESLint to Biome diff --git a/eslint.config.js b/eslint.config.js index 1f64dbd5e2..590dc2db65 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -74,6 +74,10 @@ export default tseslint.config( // TODO: enable eslint for web-platform // web-platform 'packages/web-platform/**', + + // TODO: enable eslint for testing-library + // testing-library + 'packages/testing-library/**', ], }, js.configs.recommended, diff --git a/packages/react/runtime/src/lynx.ts b/packages/react/runtime/src/lynx.ts index c9ecc6d3b3..ff07d098a1 100644 --- a/packages/react/runtime/src/lynx.ts +++ b/packages/react/runtime/src/lynx.ts @@ -2,7 +2,6 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. import { options } from 'preact'; -import type { VNode } from 'preact'; // to make sure preact's hooks to register earlier than ours import './hooks/react.js'; @@ -15,7 +14,7 @@ import { setupLynxEnv } from './lynx/env.js'; import { injectLepusMethods } from './lynx/injectLepusMethods.js'; import { initTimingAPI } from './lynx/performance.js'; import { injectTt } from './lynx/tt.js'; -import { COMPONENT, DIFF, DIFFED, FORCE } from './renderToOpcodes/constants.js'; +export { runWithForce } from './lynx/runWithForce.js'; // @ts-expect-error Element implicitly has an 'any' type because type 'typeof globalThis' has no index signature if (__LEPUS__ && typeof globalThis.processEvalResult === 'undefined') { @@ -25,55 +24,6 @@ if (__LEPUS__ && typeof globalThis.processEvalResult === 'undefined') { }; } -export function runWithForce(cb: () => void): void { - // save vnode and its `_component` in WeakMap - const m = new WeakMap(); - - const oldDiff = options[DIFF]; - - options[DIFF] = (vnode: VNode) => { - if (oldDiff) { - oldDiff(vnode); - } - - // when `options[DIFF]` is called, a newVnode is passed in - // so its `vnode[COMPONENT]` should be null, - // but it will be set later - Object.defineProperty(vnode, COMPONENT, { - configurable: true, - set(c) { - m.set(vnode, c); - if (c) { - c[FORCE] = true; - } - }, - get() { - return m.get(vnode); - }, - }); - }; - - const oldDiffed = options[DIFFED]; - - options[DIFFED] = (vnode: VNode) => { - if (oldDiffed) { - oldDiffed(vnode); - } - - // delete is a reverse operation of previous `Object.defineProperty` - delete vnode[COMPONENT]; - // restore - vnode[COMPONENT] = m.get(vnode); - }; - - try { - cb(); - } finally { - options[DIFF] = oldDiff as (vnode: VNode) => void; - options[DIFFED] = oldDiffed as (vnode: VNode) => void; - } -} - if (__LEPUS__) { injectCalledByNative(); injectUpdatePatch(); diff --git a/packages/react/runtime/src/lynx/runWithForce.ts b/packages/react/runtime/src/lynx/runWithForce.ts new file mode 100644 index 0000000000..53d429b5ba --- /dev/null +++ b/packages/react/runtime/src/lynx/runWithForce.ts @@ -0,0 +1,52 @@ +import { options } from 'preact'; +import type { VNode } from 'preact'; +import { COMPONENT, DIFF, DIFFED, FORCE } from '../renderToOpcodes/constants.js'; + +export function runWithForce(cb: () => void): void { + // save vnode and its `_component` in WeakMap + const m = new WeakMap(); + + const oldDiff = options[DIFF]; + + options[DIFF] = (vnode: VNode) => { + if (oldDiff) { + oldDiff(vnode); + } + + // when `options[DIFF]` is called, a newVnode is passed in + // so its `vnode[COMPONENT]` should be null, + // but it will be set later + Object.defineProperty(vnode, COMPONENT, { + configurable: true, + set(c) { + m.set(vnode, c); + if (c) { + c[FORCE] = true; + } + }, + get() { + return m.get(vnode); + }, + }); + }; + + const oldDiffed = options[DIFFED]; + + options[DIFFED] = (vnode: VNode) => { + if (oldDiffed) { + oldDiffed(vnode); + } + + // delete is a reverse operation of previous `Object.defineProperty` + delete vnode[COMPONENT]; + // restore + vnode[COMPONENT] = m.get(vnode); + }; + + try { + cb(); + } finally { + options[DIFF] = oldDiff as (vnode: VNode) => void; + options[DIFFED] = oldDiffed as (vnode: VNode) => void; + } +} diff --git a/packages/react/runtime/src/lynx/tt.ts b/packages/react/runtime/src/lynx/tt.ts index 38e308e1f4..da39daaa60 100644 --- a/packages/react/runtime/src/lynx/tt.ts +++ b/packages/react/runtime/src/lynx/tt.ts @@ -9,12 +9,12 @@ import { BackgroundSnapshotInstance, hydrate } from '../backgroundSnapshot.js'; import { destroyBackground } from '../lifecycle/destroy.js'; import { commitPatchUpdate, genCommitTaskId, globalCommitTaskMap } from '../lifecycle/patchUpdate.js'; import { reloadBackground } from '../lifecycle/reload.js'; -import { runWithForce } from '../lynx.js'; import { CHILDREN } from '../renderToOpcodes/constants.js'; import { __root } from '../root.js'; import { globalRefsToSet, updateBackgroundRefs } from '../snapshot/ref.js'; import { backgroundSnapshotInstanceManager } from '../snapshot.js'; import { destroyWorklet } from '../worklet/jsImpl.js'; +import { runWithForce } from './runWithForce.js'; function injectTt(): void { // @ts-ignore diff --git a/packages/react/runtime/src/worklet/runWorklet.ts b/packages/react/runtime/src/worklet/runWorklet.ts new file mode 100644 index 0000000000..eae841395a --- /dev/null +++ b/packages/react/runtime/src/worklet/runWorklet.ts @@ -0,0 +1,88 @@ +// Copyright 2024 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import type { JsFnHandle, Worklet } from '@lynx-js/react/worklet-runtime/bindings'; +import { WorkletEvents } from '@lynx-js/react/worklet-runtime/bindings'; + +import { onPostWorkletCtx } from './ctx.js'; +import { enableRunOnBackground } from './functionality.js'; +import { lynxWorkletJsImpl } from './jsImpl.js'; +/** + * transform args of `runOnJS()`. + * + * @internal + */ +export function transformToWorklet(obj: Function): JsFnHandle { + const impl = lynxWorkletJsImpl(); + const id = impl ? ++impl._workletJsFnLastId : 0; + if (typeof obj !== 'function') { + // We save the error message in the object, so that we can throw it later when the function is called on the main thread. + return { + _jsFnId: id, + _error: `Argument of runOnBackground should be a function, but got [${typeof obj}] instead`, + }; + } + return { + _jsFnId: id, + _fn: obj, + }; +} + +/** + * `runOnMainThread` allows triggering main thread functions on the main thread asynchronously. + * @param fn - The main thread functions to be called. + * @returns A function. Calling which with the arguments to be passed to the main thread function to trigger it on the main thread. + * @public + */ +export function runOnMainThread any>(fn: Fn): (...args: Parameters) => void { + if (__LEPUS__) { + throw new Error('runOnMainThread can only be used on the background thread.'); + } + const impl = lynxWorkletJsImpl(); + if (!impl) { + throw new Error('runOnMainThread requires Lynx sdk version 2.14.'); + } + return (...params: any[]): void => { + onPostWorkletCtx(fn as any as Worklet); + lynx.getCoreContext!().dispatchEvent({ + type: WorkletEvents.runWorkletCtx, + data: JSON.stringify({ + worklet: fn, + params, + }), + }); + }; +} + +/** + * `runOnBackground` allows triggering js functions on the js context asynchronously. + * @param f - The js function to be called. + * @returns A function. Calling which with the arguments to be passed to the js function to trigger it on the js context. + * @public + */ +export function runOnBackground any>(f: Fn): (...args: Parameters) => void { + if (!enableRunOnBackground()) { + throw new Error('runOnBackground requires Lynx sdk version 2.16.'); + } + if (__JS__) { + throw new Error('runOnBackground can only be used on the main thread.'); + } + const obj = f as any as JsFnHandle; + if (obj._error) { + throw new Error(obj._error); + } + return (...params: any[]): void => { + if (lynx.getJSContext) { + lynx.getJSContext().dispatchEvent({ + type: WorkletEvents.runOnBackground, + data: JSON.stringify({ + obj: { + _jsFnId: obj._jsFnId, + _execId: obj._execId!, + }, + params, + }), + }); + } + }; +} diff --git a/packages/testing-library/README.md b/packages/testing-library/README.md new file mode 100644 index 0000000000..50d804b96d --- /dev/null +++ b/packages/testing-library/README.md @@ -0,0 +1,213 @@ +# lynx-testing-library + +Unit testing library for lynx, same as https://github.com/testing-library. + +## Packages + +| Package | Description | Equivalent | +| ----------------------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------ | +| @lynx-js/lynx-dom | Lynx equivalent of jsdom | [jsdom](https://github.com/jsdom/jsdom) | +| @lynx-js/lynx-dom-jest-matchers | Lynx equivalent of jest-dom | [jest-dom](https://github.com/testing-library/jest-dom) | +| @lynx-js/lynx-dom-testing-library | Lynx equivalent of dom-testing-library | [dom-testing-library](https://github.com/testing-library/dom-testing-library) | +| @lynx-js/lynx-react-testing-library | Lynx equivalent of react-testing-library | [@testing-library/preact](https://github.com/testing-library/preact-testing-library) | + +## Usage Example Compared to Preact Testing Library + +### Basic Usage with render + +#### Preact Testing Library + +```jsx +const WrapperComponent = ({ children }) => ( +
{children}
+); +const { container, getByTestId } = render(
, { + wrapper: WrapperComponent, +}); + +expect(getByTestId('wrapper')).toBeInTheDocument(); +expect(container.firstChild).toMatchInlineSnapshot(` +
+
+
+`); +``` + +#### lynx-testing-library + +```jsx +const WrapperComponent = ({ children }) => ( + {children} +); +const Comp = () => { + return ; +}; +const { container, getByTestId } = render(, { + wrapper: WrapperComponent, +}); +expect(getByTestId('wrapper')).toBeInTheDocument(); +expect(elementTree.root).toMatchInlineSnapshot(` + + + + + + `); +``` + +### Using jest matchers + +#### Preact Testing Library + +```jsx +import '@testing-library/jest-dom/extend-expect'; + +expect(getByTestId('wrapper')).toBeInTheDocument(); +``` + +#### lynx-testing-library + +```jsx +import '@lynx-js/lynx-dom-jest-matchers'; + +expect(getByTestId('wrapper')).toBeInTheDocument(); +``` + +### Fire event + +#### Preact Testing Library + +```jsx +const handler = jest.fn(); + +const { + container: { firstChild: button }, +} = 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(); + + expect(queryByTestId('fieldset-element')).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(` +
+ + + + +
+ +
+ +
+ +
+ +
+
+ + + +
+ x +
+ + x +
+ `); + + 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(() => { + expect(queryByTestId('fieldset-element')).toBeEnabled(); + }).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'); +}); diff --git a/packages/testing-library/lynx-dom-jest-matchers/src/__tests__/to-be-empty-dom-element.js b/packages/testing-library/lynx-dom-jest-matchers/src/__tests__/to-be-empty-dom-element.js new file mode 100644 index 0000000000..b0c5b6c9bf --- /dev/null +++ b/packages/testing-library/lynx-dom-jest-matchers/src/__tests__/to-be-empty-dom-element.js @@ -0,0 +1,64 @@ +import { render } from './helpers/test-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(); +}); diff --git a/packages/testing-library/lynx-dom-jest-matchers/src/__tests__/to-be-empty.js b/packages/testing-library/lynx-dom-jest-matchers/src/__tests__/to-be-empty.js new file mode 100644 index 0000000000..7905340f0b --- /dev/null +++ b/packages/testing-library/lynx-dom-jest-matchers/src/__tests__/to-be-empty.js @@ -0,0 +1,35 @@ +import { render } from './helpers/test-utils'; + +test('.toBeEmpty', () => { + // @deprecated intentionally hiding warnings for test clarity + const spy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const { queryByTestId } = render(` + + + + `); + + const empty = queryByTestId('empty'); + const notEmpty = queryByTestId('not-empty'); + const svgEmpty = queryByTestId('svg-empty'); + const nonExistantElement = queryByTestId('not-exists'); + const fakeElement = { thisIsNot: 'an html element' }; + + expect(empty).toBeEmpty(); + expect(svgEmpty).toBeEmpty(); + expect(notEmpty).not.toBeEmpty(); + + // negative test cases wrapped in throwError assertions for coverage. + expect(() => expect(empty).not.toBeEmpty()).toThrowError(); + + expect(() => expect(svgEmpty).not.toBeEmpty()).toThrowError(); + + expect(() => expect(notEmpty).toBeEmpty()).toThrowError(); + + expect(() => expect(fakeElement).toBeEmpty()).toThrowError(); + + expect(() => { + expect(nonExistantElement).toBeEmpty(); + }).toThrowError(); + spy.mockRestore(); +}); diff --git a/packages/testing-library/lynx-dom-jest-matchers/src/__tests__/to-be-in-the-document.js b/packages/testing-library/lynx-dom-jest-matchers/src/__tests__/to-be-in-the-document.js new file mode 100644 index 0000000000..d2fb3eef04 --- /dev/null +++ b/packages/testing-library/lynx-dom-jest-matchers/src/__tests__/to-be-in-the-document.js @@ -0,0 +1,63 @@ +import { HtmlElementTypeError } from '../utils'; +import document from './helpers/document'; + +test('.toBeInTheDocument', () => { + const window = document.defaultView; + + window.customElements.define( + 'custom-element', + 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/; + 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( + HtmlElementTypeError, + ); + expect(() => expect(nullElement).toBeInTheDocument()).toThrowError( + HtmlElementTypeError, + ); + expect(() => expect(undefinedElement).toBeInTheDocument()).toThrowError( + HtmlElementTypeError, + ); + expect(() => expect(undefinedElement).not.toBeInTheDocument()).toThrowError( + HtmlElementTypeError, + ); +}); diff --git a/packages/testing-library/lynx-dom-jest-matchers/src/__tests__/to-be-in-the-dom.js b/packages/testing-library/lynx-dom-jest-matchers/src/__tests__/to-be-in-the-dom.js new file mode 100644 index 0000000000..a609605a02 --- /dev/null +++ b/packages/testing-library/lynx-dom-jest-matchers/src/__tests__/to-be-in-the-dom.js @@ -0,0 +1,55 @@ +import { render } from './helpers/test-utils'; + +test('.toBeInTheDOM', () => { + // @deprecated intentionally hiding warnings for test clarity + const spy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + + const { queryByTestId } = render(` + + + + `); + + const containerElement = queryByTestId('count-container'); + const valueElement = queryByTestId('count-value'); + const nonExistantElement = queryByTestId('not-exists'); + const svgElement = queryByTestId('svg-element'); + const fakeElement = { thisIsNot: 'an html element' }; + + // Testing toBeInTheDOM without container + expect(valueElement).toBeInTheDOM(); + expect(svgElement).toBeInTheDOM(); + expect(nonExistantElement).not.toBeInTheDOM(); + + // negative test cases wrapped in throwError assertions for coverage. + expect(() => expect(valueElement).not.toBeInTheDOM()).toThrowError(); + + expect(() => expect(svgElement).not.toBeInTheDOM()).toThrowError(); + + expect(() => expect(nonExistantElement).toBeInTheDOM()).toThrowError(); + + expect(() => expect(fakeElement).toBeInTheDOM()).toThrowError(); + + // Testing toBeInTheDOM with container + expect(valueElement).toBeInTheDOM(containerElement); + expect(svgElement).toBeInTheDOM(containerElement); + expect(containerElement).not.toBeInTheDOM(valueElement); + + expect(() => expect(valueElement).not.toBeInTheDOM(containerElement)) + .toThrowError(); + + expect(() => expect(svgElement).not.toBeInTheDOM(containerElement)) + .toThrowError(); + + expect(() => expect(nonExistantElement).toBeInTheDOM(containerElement)) + .toThrowError(); + + expect(() => expect(fakeElement).toBeInTheDOM(containerElement)) + .toThrowError(); + + expect(() => { + expect(valueElement).toBeInTheDOM(fakeElement); + }).toThrowError(); + + spy.mockRestore(); +}); diff --git a/packages/testing-library/lynx-dom-jest-matchers/src/__tests__/to-be-invalid.js b/packages/testing-library/lynx-dom-jest-matchers/src/__tests__/to-be-invalid.js new file mode 100644 index 0000000000..ef9598408f --- /dev/null +++ b/packages/testing-library/lynx-dom-jest-matchers/src/__tests__/to-be-invalid.js @@ -0,0 +1,180 @@ +import { JSDOM } from 'jsdom'; +import { render } from './helpers/test-utils'; + +/* + * This function is being used to test if `.toBeInvalid` and `.toBeValid` + * are correctly triggered by the DOM Node method `.checkValidity()`, part + * of the Web API. + * + * For this check, we are using the `jsdom` library to return a DOM Node + * sending the good information to our test. + * + * We are using this library because without it `.checkValidity()` returns + * always `true` when using `yarn test` in a terminal. + * + * Please consult the PR 110 to get more information: + * https://github.com/testing-library/jest-dom/pull/110 + * + * @link https://github.com/testing-library/jest-dom/pull/110 + * @link https://developer.mozilla.org/en-US/docs/Web/Guide/HTML/HTML5/Constraint_validation + * @link https://github.com/jsdom/jsdom + */ +function getDOMElement(htmlString, selector) { + return new JSDOM(htmlString).window.document.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(); + }); +}); diff --git a/packages/testing-library/lynx-dom-jest-matchers/src/__tests__/to-be-partially-checked.js b/packages/testing-library/lynx-dom-jest-matchers/src/__tests__/to-be-partially-checked.js new file mode 100644 index 0000000000..db0bf815fc --- /dev/null +++ b/packages/testing-library/lynx-dom-jest-matchers/src/__tests__/to-be-partially-checked.js @@ -0,0 +1,119 @@ +import { render } from './helpers/test-utils'; + +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').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').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', + ); + }); +}); diff --git a/packages/testing-library/lynx-dom-jest-matchers/src/__tests__/to-be-required.js b/packages/testing-library/lynx-dom-jest-matchers/src/__tests__/to-be-required.js new file mode 100644 index 0000000000..5a7415db57 --- /dev/null +++ b/packages/testing-library/lynx-dom-jest-matchers/src/__tests__/to-be-required.js @@ -0,0 +1,51 @@ +import { render } from './helpers/test-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(); +}); diff --git a/packages/testing-library/lynx-dom-jest-matchers/src/__tests__/to-be-visible.js b/packages/testing-library/lynx-dom-jest-matchers/src/__tests__/to-be-visible.js new file mode 100644 index 0000000000..de5b392b97 --- /dev/null +++ b/packages/testing-library/lynx-dom-jest-matchers/src/__tests__/to-be-visible.js @@ -0,0 +1,429 @@ +import { render } from './helpers/test-utils'; +import document from './helpers/document'; + +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')).not.toBeVisible(); + 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(); + }); + + test('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 false to the details content', () => { + expect(subject.container.querySelector('details')).not.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 false to the details content', () => { + expect(subject.container.querySelector('details')).not + .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 true to outer unenclosed innerText', () => { + expect(subject.container.querySelector('details')).not + .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(); + }); + }); + }); + }); + }); +}); diff --git a/packages/testing-library/lynx-dom-jest-matchers/src/__tests__/to-contain-element.js b/packages/testing-library/lynx-dom-jest-matchers/src/__tests__/to-contain-element.js new file mode 100644 index 0000000000..3e3ed0dc5b --- /dev/null +++ b/packages/testing-library/lynx-dom-jest-matchers/src/__tests__/to-contain-element.js @@ -0,0 +1,59 @@ +import { render } from './helpers/test-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(() => expect(nonExistantElement).toContainElement(fakeElement)) + .toThrowError(); + expect(() => expect(fakeElement).toContainElement(nonExistantElement)) + .toThrowError(); + expect(() => expect(fakeElement).not.toContainElement(nonExistantElement)) + .toThrowError(); + expect(() => expect(fakeElement).toContainElement(grandparent)) + .toThrowError(); + expect(() => expect(grandparent).toContainElement(fakeElement)) + .toThrowError(); + 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(); +}); diff --git a/packages/testing-library/lynx-dom-jest-matchers/src/__tests__/to-contain-html.js b/packages/testing-library/lynx-dom-jest-matchers/src/__tests__/to-contain-html.js new file mode 100644 index 0000000000..2da88b62ed --- /dev/null +++ b/packages/testing-library/lynx-dom-jest-matchers/src/__tests__/to-contain-html.js @@ -0,0 +1,101 @@ +import { render } from './helpers/test-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(() => + expect(nonExistantElement).not.toContainHTML(nonExistantElement) + ).toThrowError(); + expect(() => 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(); + expect(() => expect(child).toContainHTML(nonExistantElement)) + .toThrowError(); + expect(() => expect(parent).toContainHTML(nonExistantElement)) + .toThrowError(); + expect(() => 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', () => { + 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/packages/testing-library/lynx-dom-jest-matchers/src/__tests__/to-have-accessible-description.js b/packages/testing-library/lynx-dom-jest-matchers/src/__tests__/to-have-accessible-description.js new file mode 100644 index 0000000000..85058c122b --- /dev/null +++ b/packages/testing-library/lynx-dom-jest-matchers/src/__tests__/to-have-accessible-description.js @@ -0,0 +1,129 @@ +import { render } from './helpers/test-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', + ); + }); +}); diff --git a/packages/testing-library/lynx-dom-jest-matchers/src/__tests__/to-have-accessible-errormessage.js b/packages/testing-library/lynx-dom-jest-matchers/src/__tests__/to-have-accessible-errormessage.js new file mode 100644 index 0000000000..f48d054fe5 --- /dev/null +++ b/packages/testing-library/lynx-dom-jest-matchers/src/__tests__/to-have-accessible-errormessage.js @@ -0,0 +1,278 @@ +import { render } from './helpers/test-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(() => expect(field).toHaveAccessibleErrorMessage()) + .toThrowErrorMatchingInlineSnapshot(` + expect(element).toHaveAccessibleErrorMessage(expected) + + Expected element's \`aria-errormessage\` attribute to be empty or a single, valid ID: + + Received: + aria-errormessage="error-id id2" + `); + + // Assume the remaining error messages are the EXACT same as above + expect(() => + expect(field).toHaveAccessibleErrorMessage(new RegExp(error[0])) + ).toThrow(); + + expect(() => expect(field).toHaveAccessibleErrorMessage(error)).toThrow(); + expect(() => expect(field).toHaveAccessibleErrorMessage(secondError)) + .toThrow(); + + expect(() => + expect(field).toHaveAccessibleErrorMessage(new RegExp(secondError[0])) + ).toThrow(); + }); + + 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', + '', + '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(` + expect(element).toHaveAccessibleErrorMessage(expected) + + Expected element to be marked as invalid with attribute: + aria-invalid="true" + Received: + null + `); + + expect(() => expect(field).toHaveAccessibleErrorMessage()) + .toThrowErrorMatchingInlineSnapshot(` + expect(element).toHaveAccessibleErrorMessage(expected) + + Expected element to be marked as invalid with attribute: + aria-invalid="true" + Received: + aria-invalid="false + `); + + // 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(` + expect(element).toHaveAccessibleErrorMessage(expected) + + Expected element to have accessible error message: + + Received: + + `); + + expect(() => expect(fieldMissingError).toHaveAccessibleErrorMessage()) + .toThrowErrorMatchingInlineSnapshot(` + expect(element).toHaveAccessibleErrorMessage(expected) + + Expected element to have accessible error message: + + Received: + + `); + }); + + 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(` + expect(element).toHaveAccessibleErrorMessage(expected) + + 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(` + expect(element).toHaveAccessibleErrorMessage(expected) + + Expected element to have accessible error message: + /asdflkje2984fguyvb bnafdsasfa;lj/ + 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 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).not.toHaveAccessibleErrorMessage(); + expect(field).not.toHaveAccessibleErrorMessage(error); + expect(field).not.toHaveAccessibleErrorMessage(new RegExp(error[0])); + expect(field).not.toHaveAccessibleErrorMessage(secondError); + expect(field).not.toHaveAccessibleErrorMessage( + new RegExp(secondError[0]), + ); + }); + + 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])); + }); + }); +}); diff --git a/packages/testing-library/lynx-dom-jest-matchers/src/__tests__/to-have-accessible-name.js b/packages/testing-library/lynx-dom-jest-matchers/src/__tests__/to-have-accessible-name.js new file mode 100644 index 0000000000..9d02b670c0 --- /dev/null +++ b/packages/testing-library/lynx-dom-jest-matchers/src/__tests__/to-have-accessible-name.js @@ -0,0 +1,322 @@ +import { render } from './helpers/test-utils'; + +describe('.toHaveAccessibleName', () => { + it('recognizes an element\'s content as its label when appropriate', () => { + const { queryByTestId } = render(` +
+
    +
  • First element
  • +
  • Second element
  • +
+ + +
+ `); + + const list = queryByTestId('my-list'); + expect(list).not.toHaveAccessibleName(); + expect(() => { + expect(list).toHaveAccessibleName(); + }).toThrow(/expected element to have accessible name/i); + + expect(queryByTestId('first')).toHaveAccessibleName('First element'); + expect(queryByTestId('second')).toHaveAccessibleName('Second element'); + + const button = queryByTestId('my-button'); + expect(button).toHaveAccessibleName(); + expect(button).toHaveAccessibleName('Continue to the next step'); + expect(button).toHaveAccessibleName(/continue to the next step/i); + expect(button).toHaveAccessibleName( + expect.stringContaining('Continue to the next'), + ); + expect(button).not.toHaveAccessibleName('Next step'); + expect(() => { + expect(button).toHaveAccessibleName('Next step'); + }).toThrow(/expected element to have accessible name/i); + expect(() => { + expect(button).not.toHaveAccessibleName('Continue to the next step'); + }).toThrow(/expected element not to have accessible name/i); + expect(() => { + expect(button).not.toHaveAccessibleName(); + }).toThrow(/expected element not to have accessible name/i); + }); + + it('works with label elements', () => { + const { queryByTestId } = render(` +
+ + + + +
+ `); + + const firstNameField = queryByTestId('first-name-field'); + expect(firstNameField).toHaveAccessibleName('First name'); + expect(queryByTestId('first-name-field')).toHaveAccessibleName( + /first name/i, + ); + expect(firstNameField).toHaveAccessibleName( + expect.stringContaining('First'), + ); + expect(() => { + expect(firstNameField).toHaveAccessibleName('Last name'); + }).toThrow(/expected element to have accessible name/i); + expect(() => { + expect(firstNameField).not.toHaveAccessibleName('First name'); + }).toThrow(/expected element not to have accessible name/i); + + const checkboxField = queryByTestId('checkbox-field'); + expect(checkboxField).toHaveAccessibleName('Accept terms and conditions'); + expect(checkboxField).toHaveAccessibleName(/accept terms/i); + expect(checkboxField).toHaveAccessibleName( + expect.stringContaining('Accept terms'), + ); + expect(() => { + expect(checkboxField).toHaveAccessibleName( + 'Accept our terms and conditions', + ); + }).toThrow(/expected element to have accessible name/i); + expect(() => { + expect(checkboxField).not.toHaveAccessibleName( + 'Accept terms and conditions', + ); + }).toThrow(/expected element not to have accessible name/i); + }); + + it('works with aria-label attributes', () => { + const { queryByTestId } = render(` +
+ + + + + + +
+ `); + + const firstNameField = queryByTestId('first-name-field'); + expect(firstNameField).not.toHaveAccessibleName('First name'); + expect(firstNameField).toHaveAccessibleName('Enter your name'); + expect(firstNameField).toHaveAccessibleName(/enter your name/i); + expect(firstNameField).toHaveAccessibleName( + expect.stringContaining('your name'), + ); + expect(() => { + expect(firstNameField).toHaveAccessibleName('First name'); + }).toThrow(/expected element to have accessible name/i); + expect(() => { + expect(firstNameField).not.toHaveAccessibleName('Enter your name'); + }).toThrow(/expected element not to have accessible name/i); + + const checkboxField = queryByTestId('checkbox-field'); + expect(checkboxField).not.toHaveAccessibleName( + 'Accept terms and conditions', + ); + expect(checkboxField).toHaveAccessibleName( + 'Accept our terms and conditions', + ); + expect(checkboxField).toHaveAccessibleName(/accept our terms/i); + expect(checkboxField).toHaveAccessibleName( + expect.stringContaining('terms'), + ); + expect(() => { + expect(checkboxField).toHaveAccessibleName('Accept terms and conditions'); + }).toThrow(/expected element to have accessible name/i); + expect(() => { + expect(checkboxField).not.toHaveAccessibleName( + 'Accept our terms and conditions', + ); + }).toThrow(/expected element not to have accessible name/i); + + const submitButton = queryByTestId('submit-button'); + expect(submitButton).not.toHaveAccessibleName('Continue'); + expect(submitButton).toHaveAccessibleName('Submit this form'); + expect(submitButton).toHaveAccessibleName(/submit this form/i); + expect(submitButton).toHaveAccessibleName(expect.stringContaining('form')); + expect(() => { + expect(submitButton).toHaveAccessibleName('Continue'); + }).toThrow(/expected element to have accessible name/i); + expect(() => { + expect(submitButton).not.toHaveAccessibleName('Submit this form'); + }).toThrow(/expected element not to have accessible name/i); + }); + + it('works with aria-labelledby attributes', () => { + const { queryByTestId } = render(` +
+ + +

Enter your name

+ + +

Accept our terms and conditions

+ + +

Submit this form

+
+ `); + + const firstNameField = queryByTestId('first-name-field'); + expect(firstNameField).not.toHaveAccessibleName('First name'); + expect(firstNameField).toHaveAccessibleName('Enter your name'); + expect(firstNameField).toHaveAccessibleName(/enter your name/i); + expect(firstNameField).toHaveAccessibleName( + expect.stringContaining('your name'), + ); + expect(() => { + expect(firstNameField).toHaveAccessibleName('First name'); + }).toThrow(/expected element to have accessible name/i); + expect(() => { + expect(firstNameField).not.toHaveAccessibleName('Enter your name'); + }).toThrow(/expected element not to have accessible name/i); + + const checkboxField = queryByTestId('checkbox-field'); + expect(checkboxField).not.toHaveAccessibleName( + 'Accept terms and conditions', + ); + expect(checkboxField).toHaveAccessibleName( + 'Accept our terms and conditions', + ); + expect(checkboxField).toHaveAccessibleName(/accept our terms/i); + expect(checkboxField).toHaveAccessibleName( + expect.stringContaining('terms'), + ); + expect(() => { + expect(checkboxField).toHaveAccessibleName('Accept terms and conditions'); + }).toThrow(/expected element to have accessible name/i); + expect(() => { + expect(checkboxField).not.toHaveAccessibleName( + 'Accept our terms and conditions', + ); + }).toThrow(/expected element not to have accessible name/i); + + const submitButton = queryByTestId('submit-button'); + expect(submitButton).not.toHaveAccessibleName('Continue'); + expect(submitButton).toHaveAccessibleName('Submit this form'); + expect(submitButton).toHaveAccessibleName(/submit this form/i); + expect(submitButton).toHaveAccessibleName(expect.stringContaining('form')); + expect(() => { + expect(submitButton).toHaveAccessibleName('Continue'); + }).toThrow(/expected element to have accessible name/i); + expect(() => { + expect(submitButton).not.toHaveAccessibleName('Submit this form'); + }).toThrow(/expected element not to have accessible name/i); + }); + + it('works with image alt attributes', () => { + const { queryByTestId } = render(` +
+ Company logo + +
+ `); + + const logoImage = queryByTestId('logo-img'); + expect(logoImage).toHaveAccessibleName('Company logo'); + expect(logoImage).toHaveAccessibleName(/company logo/i); + expect(logoImage).toHaveAccessibleName(expect.stringContaining('logo')); + expect(() => { + expect(logoImage).toHaveAccessibleName('Our company logo'); + }).toThrow(/expected element to have accessible name/i); + expect(() => { + expect(logoImage).not.toHaveAccessibleName('Company logo'); + }).toThrow(/expected element not to have accessible name/i); + + const closeButton = queryByTestId('close-button'); + expect(closeButton).toHaveAccessibleName('Close modal'); + expect(closeButton).toHaveAccessibleName(/close modal/i); + expect(closeButton).toHaveAccessibleName(expect.stringContaining('modal')); + expect(() => { + expect(closeButton).toHaveAccessibleName('Close window'); + }).toThrow(/expected element to have accessible name/i); + expect(() => { + expect(closeButton).not.toHaveAccessibleName('Close modal'); + }).toThrow(/expected element not to have accessible name/i); + }); + + it('works with svg title attributes', () => { + const { queryByTestId } = render(` + Test title + `); + + const svgElement = queryByTestId('svg-title'); + expect(svgElement).toHaveAccessibleName('Test title'); + expect(svgElement).toHaveAccessibleName(/test title/i); + expect(svgElement).toHaveAccessibleName(expect.stringContaining('Test')); + expect(() => { + expect(svgElement).toHaveAccessibleName('Another title'); + }).toThrow(/expected element to have accessible name/i); + expect(() => { + expect(svgElement).not.toHaveAccessibleName('Test title'); + }).toThrow(/expected element not to have accessible name/i); + }); + + it('works as in the examples in the README', () => { + const { queryByTestId: getByTestId } = render(` +
+ Test alt + + Test title + +

Test content

+
+ `); + + expect(getByTestId('img-alt')).toHaveAccessibleName('Test alt'); + expect(getByTestId('img-empty-alt')).not.toHaveAccessibleName(); + expect(getByTestId('svg-title')).toHaveAccessibleName('Test title'); + expect(getByTestId('button-img-alt')).toHaveAccessibleName(); + expect(getByTestId('img-paragraph')).not.toHaveAccessibleName(); + expect(getByTestId('svg-button')).toHaveAccessibleName(); + expect(getByTestId('svg-without-title')).not.toHaveAccessibleName(); + expect(getByTestId('input-title')).toHaveAccessibleName(); + }); +}); diff --git a/packages/testing-library/lynx-dom-jest-matchers/src/__tests__/to-have-attribute.js b/packages/testing-library/lynx-dom-jest-matchers/src/__tests__/to-have-attribute.js new file mode 100644 index 0000000000..3a08c3ba3b --- /dev/null +++ b/packages/testing-library/lynx-dom-jest-matchers/src/__tests__/to-have-attribute.js @@ -0,0 +1,59 @@ +import { render } from './helpers/test-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(() => 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(); +}); diff --git a/packages/testing-library/lynx-dom-jest-matchers/src/__tests__/to-have-class.js b/packages/testing-library/lynx-dom-jest-matchers/src/__tests__/to-have-class.js new file mode 100644 index 0000000000..5106cf6982 --- /dev/null +++ b/packages/testing-library/lynx-dom-jest-matchers/src/__tests__/to-have-class.js @@ -0,0 +1,222 @@ +/* eslint max-statements:off */ + +import { render } from './helpers/test-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(() => + expect(queryByTestId('delete-button')).not.toHaveClass(/btn/, { + exact: true, + }) + ).toThrowError(); + + expect(() => + expect(queryByTestId('delete-button')).not.toHaveClass( + /-danger$/, + 'extra', + /\bbtn/, + { exact: true }, + ) + ).toThrowError(); + + expect(() => + expect(queryByTestId('delete-button')).toHaveClass(/danger/, { + exact: true, + }) + ).toThrowError(); +}); diff --git a/packages/testing-library/lynx-dom-jest-matchers/src/__tests__/to-have-description.js b/packages/testing-library/lynx-dom-jest-matchers/src/__tests__/to-have-description.js new file mode 100644 index 0000000000..2d1b3b2f32 --- /dev/null +++ b/packages/testing-library/lynx-dom-jest-matchers/src/__tests__/to-have-description.js @@ -0,0 +1,148 @@ +import { render } from './helpers/test-utils'; + +describe('.toHaveDescription', () => { + let spy; + beforeAll(() => { + // @deprecated intentionally hiding warnings for test clarity + spy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterAll(() => { + spy.mockRestore(); + }); + + test('handles positive test cases', () => { + const { queryByTestId } = render(` +
The description
+ +
+
+
+ `); + + expect(queryByTestId('single')).toHaveDescription('The description'); + expect(queryByTestId('single')).toHaveDescription( + expect.stringContaining('The'), + ); + expect(queryByTestId('single')).toHaveDescription(/The/); + expect(queryByTestId('single')).toHaveDescription( + expect.stringMatching(/The/), + ); + expect(queryByTestId('single')).toHaveDescription(/description/); + expect(queryByTestId('single')).not.toHaveDescription('Something else'); + expect(queryByTestId('single')).not.toHaveDescription('The'); + + expect(queryByTestId('invalid_id')).not.toHaveDescription(); + expect(queryByTestId('invalid_id')).toHaveDescription(''); + + expect(queryByTestId('without')).not.toHaveDescription(); + expect(queryByTestId('without')).toHaveDescription(''); + }); + + test('handles multiple ids', () => { + const { queryByTestId } = render(` +
First description
+
Second description
+
Third description
+ +
+ `); + + expect(queryByTestId('multiple')).toHaveDescription( + 'First description Second description Third description', + ); + expect(queryByTestId('multiple')).toHaveDescription( + /Second description Third/, + ); + expect(queryByTestId('multiple')).toHaveDescription( + expect.stringContaining('Second description Third'), + ); + expect(queryByTestId('multiple')).toHaveDescription( + expect.stringMatching(/Second description Third/), + ); + expect(queryByTestId('multiple')).not.toHaveDescription('Something else'); + expect(queryByTestId('multiple')).not.toHaveDescription('First'); + }); + + test('handles negative test cases', () => { + const { queryByTestId } = render(` +
The description
+
+ `); + + expect(() => + expect(queryByTestId('other')).toHaveDescription('The description') + ).toThrowError(); + + expect(() => + expect(queryByTestId('target')).toHaveDescription('Something else') + ).toThrowError(); + + expect(() => + expect(queryByTestId('target')).not.toHaveDescription('The description') + ).toThrowError(); + }); + + test('normalizes whitespace', () => { + const { queryByTestId } = render(` +
+ Step + 1 + of + 4 +
+
+ And + extra + description +
+
+ `); + + expect(queryByTestId('target')).toHaveDescription( + 'Step 1 of 4 And extra description', + ); + }); + + test('can handle multiple levels with content spread across decendants', () => { + const { queryByTestId } = render(` + + Step + 1 + of + + + 4 +
+
+ `); + + expect(queryByTestId('target')).toHaveDescription('Step 1 of 4'); + }); + + test('handles extra whitespace with multiple ids', () => { + const { queryByTestId } = render(` +
First description
+
Second description
+
Third description
+ +
+ `); + + expect(queryByTestId('multiple')).toHaveDescription( + 'First description Second description Third description', + ); + }); + + test('is case-sensitive', () => { + const { queryByTestId } = render(` + Sensitive text +
+ `); + + expect(queryByTestId('target')).toHaveDescription('Sensitive text'); + expect(queryByTestId('target')).not.toHaveDescription('sensitive text'); + }); +}); diff --git a/packages/testing-library/lynx-dom-jest-matchers/src/__tests__/to-have-display-value.js b/packages/testing-library/lynx-dom-jest-matchers/src/__tests__/to-have-display-value.js new file mode 100644 index 0000000000..87e96e86df --- /dev/null +++ b/packages/testing-library/lynx-dom-jest-matchers/src/__tests__/to-have-display-value.js @@ -0,0 +1,163 @@ +import { render } from './helpers/test-utils'; + +test('it should work as expected', () => { + const { queryByTestId } = render(` + + `); + + expect(queryByTestId('select')).toHaveDisplayValue('Select a fruit...'); + expect(queryByTestId('select')).not.toHaveDisplayValue('Select'); + expect(queryByTestId('select')).not.toHaveDisplayValue('Banana'); + expect(() => + expect(queryByTestId('select')).not.toHaveDisplayValue('Select a fruit...') + ).toThrow(); + expect(() => expect(queryByTestId('select')).toHaveDisplayValue('Ananas')) + .toThrow(); + + queryByTestId('select').value = 'banana'; + expect(queryByTestId('select')).toHaveDisplayValue('Banana'); + expect(queryByTestId('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').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', + ]); + }); +}); + +test('it should work with input elements', () => { + const { queryByTestId } = render(` + + `); + + expect(queryByTestId('input')).toHaveDisplayValue('Luca'); + expect(queryByTestId('input')).toHaveDisplayValue(/Luc/); + + queryByTestId('input').value = 'Piero'; + expect(queryByTestId('input')).toHaveDisplayValue('Piero'); +}); + +test('it should work with textarea elements', () => { + const { queryByTestId } = render( + '', + ); + + expect(queryByTestId('textarea-example')).toHaveDisplayValue( + 'An example description here.', + ); + expect(queryByTestId('textarea-example')).toHaveDisplayValue(/example/); + + queryByTestId('textarea-example').value = 'Another example'; + expect(queryByTestId('textarea-example')).toHaveDisplayValue( + 'Another example', + ); +}); + +test('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 { + 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.`, + ); +}); + +test('it should work with numbers', () => { + const { queryByTestId } = render(` + + `); + + expect(queryByTestId('select')).toHaveDisplayValue(1); +}); diff --git a/packages/testing-library/lynx-dom-jest-matchers/src/__tests__/to-have-errormessage.js b/packages/testing-library/lynx-dom-jest-matchers/src/__tests__/to-have-errormessage.js new file mode 100644 index 0000000000..06f34b7d76 --- /dev/null +++ b/packages/testing-library/lynx-dom-jest-matchers/src/__tests__/to-have-errormessage.js @@ -0,0 +1,206 @@ +import { render } from './helpers/test-utils'; + +// eslint-disable-next-line max-lines-per-function +describe('.toHaveErrorMessage', () => { + test('resolves for object with correct aria-errormessage reference', () => { + const { queryByTestId } = render(` + + + Invalid time: the time must be between 9:00 AM and 5:00 PM + `); + + const timeInput = queryByTestId('startTime'); + + expect(timeInput).toHaveErrorMessage( + 'Invalid time: the time must be between 9:00 AM and 5:00 PM', + ); + expect(timeInput).toHaveErrorMessage(/invalid time/i); // to partially match + expect(timeInput).toHaveErrorMessage( + expect.stringContaining('Invalid time'), + ); // to partially match + expect(timeInput).not.toHaveErrorMessage('Pikachu!'); + }); + + test('works correctly on implicit invalid element', () => { + const { queryByTestId } = render(` + + + Invalid time: the time must be between 9:00 AM and 5:00 PM + `); + + const timeInput = queryByTestId('startTime'); + + expect(timeInput).toHaveErrorMessage( + 'Invalid time: the time must be between 9:00 AM and 5:00 PM', + ); + expect(timeInput).toHaveErrorMessage(/invalid time/i); // to partially match + expect(timeInput).toHaveErrorMessage( + expect.stringContaining('Invalid time'), + ); // to partially match + expect(timeInput).not.toHaveErrorMessage('Pikachu!'); + }); + + test('rejects for valid object', () => { + const { queryByTestId } = render(` +
The errormessage
+
+
+ `); + + expect(queryByTestId('valid')).not.toHaveErrorMessage('The errormessage'); + expect(() => { + expect(queryByTestId('valid')).toHaveErrorMessage('The errormessage'); + }).toThrowError(); + + expect(queryByTestId('explicitly_valid')).not.toHaveErrorMessage( + 'The errormessage', + ); + expect(() => { + expect(queryByTestId('explicitly_valid')).toHaveErrorMessage( + 'The errormessage', + ); + }).toThrowError(); + }); + + test('rejects for object with incorrect aria-errormessage reference', () => { + const { queryByTestId } = render(` +
The errormessage
+
+ `); + + expect(queryByTestId('invalid_id')).not.toHaveErrorMessage(); + expect(queryByTestId('invalid_id')).toHaveErrorMessage(''); + }); + + test('handles invalid element without aria-errormessage', () => { + const { queryByTestId } = render(` +
The errormessage
+
+ `); + + expect(queryByTestId('without')).not.toHaveErrorMessage(); + expect(queryByTestId('without')).toHaveErrorMessage(''); + }); + + test('handles valid element without aria-errormessage', () => { + const { queryByTestId } = render(` +
The errormessage
+
+ `); + + expect(queryByTestId('without')).not.toHaveErrorMessage(); + expect(() => { + expect(queryByTestId('without')).toHaveErrorMessage(); + }).toThrowError(); + + expect(queryByTestId('without')).not.toHaveErrorMessage(''); + expect(() => { + expect(queryByTestId('without')).toHaveErrorMessage(''); + }).toThrowError(); + }); + + test('handles multiple ids', () => { + const { queryByTestId } = render(` +
First errormessage
+
Second errormessage
+
Third errormessage
+
+ `); + + expect(queryByTestId('multiple')).toHaveErrorMessage( + 'First errormessage Second errormessage Third errormessage', + ); + expect(queryByTestId('multiple')).toHaveErrorMessage( + /Second errormessage Third/, + ); + expect(queryByTestId('multiple')).toHaveErrorMessage( + expect.stringContaining('Second errormessage Third'), + ); + expect(queryByTestId('multiple')).toHaveErrorMessage( + expect.stringMatching(/Second errormessage Third/), + ); + expect(queryByTestId('multiple')).not.toHaveErrorMessage('Something else'); + expect(queryByTestId('multiple')).not.toHaveErrorMessage('First'); + }); + + test('handles negative test cases', () => { + const { queryByTestId } = render(` +
The errormessage
+
+ `); + + expect(() => + expect(queryByTestId('other')).toHaveErrorMessage('The errormessage') + ).toThrowError(); + + expect(() => + expect(queryByTestId('target')).toHaveErrorMessage('Something else') + ).toThrowError(); + + expect(() => + expect(queryByTestId('target')).not.toHaveErrorMessage( + 'The errormessage', + ) + ).toThrowError(); + }); + + test('normalizes whitespace', () => { + const { queryByTestId } = render(` +
+ Step + 1 + of + 4 +
+
+ And + extra + errormessage +
+
+ `); + + expect(queryByTestId('target')).toHaveErrorMessage( + 'Step 1 of 4 And extra errormessage', + ); + }); + + test('can handle multiple levels with content spread across decendants', () => { + const { queryByTestId } = render(` + + Step + 1 + of + 4 + +
+ `); + + expect(queryByTestId('target')).toHaveErrorMessage('Step 1 of 4'); + }); + + test('handles extra whitespace with multiple ids', () => { + const { queryByTestId } = render(` +
First errormessage
+
Second errormessage
+
Third errormessage
+
+ `); + + expect(queryByTestId('multiple')).toHaveErrorMessage( + 'First errormessage Second errormessage Third errormessage', + ); + }); + + test('is case-sensitive', () => { + const { queryByTestId } = render(` + Sensitive text +
+ `); + + expect(queryByTestId('target')).toHaveErrorMessage('Sensitive text'); + expect(queryByTestId('target')).not.toHaveErrorMessage('sensitive text'); + }); +}); diff --git a/packages/testing-library/lynx-dom-jest-matchers/src/__tests__/to-have-focus.js b/packages/testing-library/lynx-dom-jest-matchers/src/__tests__/to-have-focus.js new file mode 100644 index 0000000000..15601df1af --- /dev/null +++ b/packages/testing-library/lynx-dom-jest-matchers/src/__tests__/to-have-focus.js @@ -0,0 +1,23 @@ +import { render } from './helpers/test-utils'; +import document from './helpers/document'; + +test('.toHaveFocus', () => { + const { container } = render(` +
+ + + +
`); + + const focused = container.querySelector('#focused'); + 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(); +}); diff --git a/packages/testing-library/lynx-dom-jest-matchers/src/__tests__/to-have-form-values.js b/packages/testing-library/lynx-dom-jest-matchers/src/__tests__/to-have-form-values.js new file mode 100644 index 0000000000..8d34cddb5d --- /dev/null +++ b/packages/testing-library/lynx-dom-jest-matchers/src/__tests__/to-have-form-values.js @@ -0,0 +1,355 @@ +import { render } from './helpers/test-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(''); +} diff --git a/packages/testing-library/lynx-dom-jest-matchers/src/__tests__/to-have-role.js b/packages/testing-library/lynx-dom-jest-matchers/src/__tests__/to-have-role.js new file mode 100644 index 0000000000..48103e749c --- /dev/null +++ b/packages/testing-library/lynx-dom-jest-matchers/src/__tests__/to-have-role.js @@ -0,0 +1,107 @@ +import { render } from './helpers/test-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).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); + expect(() => { + expect(continueButton).not.toHaveRole('switch'); + }).toThrow(/expected element not to have role/i); + }); + + // At this point, we might be testing the details of getImplicitAriaRoles, but + // it's good to have a gut check + it('handles implicit roles with multiple conditions', () => { + const { queryByTestId } = render(` + + `); + + const validLink = queryByTestId('link-valid'); + const invalidLink = queryByTestId('link-invalid'); + + // valid link has role 'link' + expect(validLink).not.toHaveRole('listitem'); + expect(validLink).toHaveRole('link'); + + expect(() => { + expect(validLink).toHaveRole('listitem'); + }).toThrow(/expected element to have role/i); + expect(() => { + expect(validLink).not.toHaveRole('link'); + }).toThrow(/expected element not to have role/i); + + // invalid link has role 'generic' + expect(invalidLink).not.toHaveRole('listitem'); + expect(invalidLink).not.toHaveRole('link'); + expect(invalidLink).toHaveRole('generic'); + + expect(() => { + expect(invalidLink).toHaveRole('listitem'); + }).toThrow(/expected element to have role/i); + expect(() => { + expect(invalidLink).toHaveRole('link'); + }).toThrow(/expected element to have role/i); + expect(() => { + expect(invalidLink).not.toHaveRole('generic'); + }).toThrow(/expected element not to have role/i); + }); +}); diff --git a/packages/testing-library/lynx-dom-jest-matchers/src/__tests__/to-have-selection.js b/packages/testing-library/lynx-dom-jest-matchers/src/__tests__/to-have-selection.js new file mode 100644 index 0000000000..4104b39f01 --- /dev/null +++ b/packages/testing-library/lynx-dom-jest-matchers/src/__tests__/to-have-selection.js @@ -0,0 +1,184 @@ +import { render } from './helpers/test-utils'; + +describe('.toHaveSelection', () => { + test.each(['text', 'password', 'textarea'])( + 'handles selection within form elements', + testId => { + const { queryByTestId } = render(` + + + + `); + + queryByTestId(testId).setSelectionRange(5, 13); + expect(queryByTestId(testId)).toHaveSelection('selected'); + + queryByTestId(testId).select(); + expect(queryByTestId(testId)).toHaveSelection('text selected text'); + }, + ); + + test.each(['checkbox', 'radio'])( + 'returns empty string for form elements without text', + testId => { + const { queryByTestId } = render(` + + + `); + + queryByTestId(testId).select(); + expect(queryByTestId(testId)).toHaveSelection(''); + }, + ); + + test('does not match subset string', () => { + const { queryByTestId } = render(` + + `); + + queryByTestId('text').setSelectionRange(5, 13); + expect(queryByTestId('text')).not.toHaveSelection('select'); + expect(queryByTestId('text')).toHaveSelection('selected'); + }); + + test('accepts any selection when expected selection is missing', () => { + const { queryByTestId } = render(` + + `); + + expect(queryByTestId('text')).not.toHaveSelection(); + + queryByTestId('text').setSelectionRange(5, 13); + + expect(queryByTestId('text')).toHaveSelection(); + }); + + test('throws when form element is not selected', () => { + const { queryByTestId } = render(` + + `); + + expect(() => expect(queryByTestId('text')).toHaveSelection()) + .toThrowErrorMatchingInlineSnapshot( + ` + expect(element).toHaveSelection(expected) + + Expected the element to have selection: + (any) + Received: + + `, + ); + }); + + test('throws when form element is selected', () => { + const { queryByTestId } = render(` + + `); + queryByTestId('text').setSelectionRange(5, 13); + + expect(() => expect(queryByTestId('text')).not.toHaveSelection()) + .toThrowErrorMatchingInlineSnapshot( + ` + 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( + ` + expect(element).toHaveSelection(expected) + + Expected the element to have selection: + (any) + Received: + + `, + ); + }); + + test('throws when element selection does not match', () => { + const { queryByTestId } = render(` + + `); + queryByTestId('text').setSelectionRange(0, 4); + + expect(() => expect(queryByTestId('text')).toHaveSelection('no match')) + .toThrowErrorMatchingInlineSnapshot( + ` + 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(() => expect(element).toHaveSelection(1)) + .toThrowErrorMatchingInlineSnapshot( + `expected selection must be a string or undefined`, + ); + }); +}); diff --git a/packages/testing-library/lynx-dom-jest-matchers/src/__tests__/to-have-style.js b/packages/testing-library/lynx-dom-jest-matchers/src/__tests__/to-have-style.js new file mode 100644 index 0000000000..4023f1b5d7 --- /dev/null +++ b/packages/testing-library/lynx-dom-jest-matchers/src/__tests__/to-have-style.js @@ -0,0 +1,258 @@ +import { render } from './helpers/test-utils'; +import document from './helpers/document'; + +// eslint-disable-next-line max-lines-per-function +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(1.175, 0.885, 0.32, 1.275); + transform: translateX(0px); + } + `; + document.body.appendChild(style); + document.body.appendChild(container); + + 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(1.175, 0.885, 0.32, 1.275)', + ); + + 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); + + 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(); + + // Make sure the test fails if the css syntax is not valid + expect(() => + expect(container.querySelector('.label')).not.toHaveStyle( + 'font-weight bold', + ) + ).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', + }); + }); + + test('Uses px as the default unit', () => { + const { queryByTestId } = render(` + Hello World + `); + expect(queryByTestId('color-example')).toHaveStyle({ + fontSize: 12, + }); + }); + + 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/packages/testing-library/lynx-dom-jest-matchers/src/__tests__/to-have-text-content.js b/packages/testing-library/lynx-dom-jest-matchers/src/__tests__/to-have-text-content.js new file mode 100644 index 0000000000..7dfb9e273e --- /dev/null +++ b/packages/testing-library/lynx-dom-jest-matchers/src/__tests__/to-have-text-content.js @@ -0,0 +1,109 @@ +import { render } from './helpers/test-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\(\)/); + }); +}); diff --git a/packages/testing-library/lynx-dom-jest-matchers/src/__tests__/to-have-value.js b/packages/testing-library/lynx-dom-jest-matchers/src/__tests__/to-have-value.js new file mode 100644 index 0000000000..f134677d82 --- /dev/null +++ b/packages/testing-library/lynx-dom-jest-matchers/src/__tests__/to-have-value.js @@ -0,0 +1,220 @@ +import { render } from './helpers/test-utils'; + +describe('.toHaveValue', () => { + test('handles value of text input', () => { + const { queryByTestId } = 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'); + queryByTestId('without').value = 'bar'; + expect(queryByTestId('without')).toHaveValue('bar'); + }); + + test('handles value of number input', () => { + const { queryByTestId } = 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'); + queryByTestId('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); + }); +}); diff --git a/packages/testing-library/lynx-dom-jest-matchers/src/__tests__/utils.js b/packages/testing-library/lynx-dom-jest-matchers/src/__tests__/utils.js new file mode 100644 index 0000000000..e28070abe0 --- /dev/null +++ b/packages/testing-library/lynx-dom-jest-matchers/src/__tests__/utils.js @@ -0,0 +1,213 @@ +import { + deprecate, + checkHtmlElement, + checkNode, + HtmlElementTypeError, + NodeTypeError, + toSentence, +} from '../utils'; +import document from './helpers/document'; + +test('deprecate', () => { + const spy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const name = 'test'; + const replacement = 'test'; + const message = + `Warning: ${name} has been deprecated and will be removed in future updates.`; + + deprecate(name, replacement); + expect(spy).toHaveBeenCalledWith(message, replacement); + + deprecate(name); + expect(spy).toHaveBeenCalledWith(message, undefined); + + spy.mockRestore(); +}); + +describe('checkHtmlElement', () => { + let assertionContext; + beforeAll(() => { + expect.extend({ + fakeMatcher() { + assertionContext = this; + + return { pass: true }; + }, + }); + + expect(true).fakeMatcher(true); + }); + it('does not throw an error for correct html element', () => { + expect(() => { + const element = document.createElement('p'); + checkHtmlElement(element, () => {}, assertionContext); + }).not.toThrow(); + }); + + it('does not throw an error for correct svg element', () => { + expect(() => { + const element = document.createElementNS( + 'http://www.w3.org/2000/svg', + 'rect', + ); + checkHtmlElement(element, () => {}, assertionContext); + }).not.toThrow(); + }); + + it('does not throw for body', () => { + expect(() => { + checkHtmlElement(document.body, () => {}, assertionContext); + }).not.toThrow(); + }); + + it('throws for undefined', () => { + expect(() => { + checkHtmlElement(undefined, () => {}, assertionContext); + }).toThrow(HtmlElementTypeError); + }); + + it('throws for document', () => { + expect(() => { + checkHtmlElement(document, () => {}, assertionContext); + }).toThrow(HtmlElementTypeError); + }); + + it('throws for function', () => { + expect(() => { + checkHtmlElement( + () => {}, + () => {}, + assertionContext, + ); + }).toThrow(HtmlElementTypeError); + }); + + it('throws for almost element-like objects', () => { + class FakeObject {} + expect(() => { + checkHtmlElement( + { + ownerDocument: { + defaultView: { HTMLElement: FakeObject, SVGElement: FakeObject }, + }, + }, + () => {}, + assertionContext, + ); + }).toThrow(HtmlElementTypeError); + }); +}); + +describe('checkNode', () => { + let assertionContext; + beforeAll(() => { + expect.extend({ + fakeMatcher() { + assertionContext = this; + + return { pass: true }; + }, + }); + + expect(true).fakeMatcher(true); + }); + it('does not throw an error for correct html element', () => { + expect(() => { + const element = document.createElement('p'); + checkNode(element, () => {}, assertionContext); + }).not.toThrow(); + }); + + it('does not throw an error for correct svg element', () => { + expect(() => { + const element = document.createElementNS( + 'http://www.w3.org/2000/svg', + 'rect', + ); + checkNode(element, () => {}, assertionContext); + }).not.toThrow(); + }); + + it('does not throw an error for Document fragments', () => { + expect(() => { + const fragment = document.createDocumentFragment(); + checkNode(fragment, () => {}, assertionContext); + }).not.toThrow(); + }); + + it('does not throw an error for text nodes', () => { + expect(() => { + const text = document.createTextNode('foo'); + checkNode(text, () => {}, assertionContext); + }).not.toThrow(); + }); + + it('does not throw for body', () => { + expect(() => { + checkNode(document.body, () => {}, assertionContext); + }).not.toThrow(); + }); + + it('throws for undefined', () => { + expect(() => { + checkNode(undefined, () => {}, assertionContext); + }).toThrow(NodeTypeError); + }); + + it('throws for document', () => { + expect(() => { + checkNode(document, () => {}, assertionContext); + }).toThrow(NodeTypeError); + }); + + it('throws for function', () => { + expect(() => { + checkNode( + () => {}, + () => {}, + assertionContext, + ); + }).toThrow(NodeTypeError); + }); + + it('throws for almost element-like objects', () => { + class FakeObject {} + expect(() => { + checkNode( + { + ownerDocument: { + defaultView: { Node: FakeObject, SVGElement: FakeObject }, + }, + }, + () => {}, + assertionContext, + ); + }).toThrow(NodeTypeError); + }); +}); + +describe('toSentence', () => { + it('turns array into string of comma separated list with default last word connector', () => { + expect(toSentence(['one', 'two', 'three'])).toBe('one, two and three'); + }); + + it('supports custom word connector', () => { + expect(toSentence(['one', 'two', 'three'], { wordConnector: '; ' })).toBe( + 'one; two and three', + ); + }); + + it('supports custom last word connector', () => { + expect( + toSentence(['one', 'two', 'three'], { lastWordConnector: ' or ' }), + ).toBe('one, two or three'); + }); + + it('turns one element array into string containing first element', () => { + expect(toSentence(['one'])).toBe('one'); + }); + + it('turns empty array into empty string', () => { + expect(toSentence([])).toBe(''); + }); +}); diff --git a/packages/testing-library/lynx-dom-jest-matchers/src/index.js b/packages/testing-library/lynx-dom-jest-matchers/src/index.js new file mode 100644 index 0000000000..5d3cf42351 --- /dev/null +++ b/packages/testing-library/lynx-dom-jest-matchers/src/index.js @@ -0,0 +1,3 @@ +import * as extensions from './matchers'; + +expect.extend(extensions); diff --git a/packages/testing-library/lynx-dom-jest-matchers/src/jest-globals.js b/packages/testing-library/lynx-dom-jest-matchers/src/jest-globals.js new file mode 100644 index 0000000000..4df6bf5e71 --- /dev/null +++ b/packages/testing-library/lynx-dom-jest-matchers/src/jest-globals.js @@ -0,0 +1,6 @@ +/* istanbul ignore file */ + +import { expect } from '@jest/globals'; +import * as extensions from './matchers'; + +expect.extend(extensions); diff --git a/packages/testing-library/lynx-dom-jest-matchers/src/matchers.js b/packages/testing-library/lynx-dom-jest-matchers/src/matchers.js new file mode 100644 index 0000000000..7d9a05599e --- /dev/null +++ b/packages/testing-library/lynx-dom-jest-matchers/src/matchers.js @@ -0,0 +1,27 @@ +// export {toBeInTheDOM} from './to-be-in-the-dom' +export { toBeInTheDocument } from './to-be-in-the-document'; +// export {toBeEmpty} from './to-be-empty' +// export {toBeEmptyDOMElement} from './to-be-empty-dom-element' +// export {toContainElement} from './to-contain-element' +// export {toContainHTML} from './to-contain-html' +export { toHaveTextContent } from './to-have-text-content'; +// export {toHaveAccessibleDescription} from './to-have-accessible-description' +// export {toHaveAccessibleErrorMessage} from './to-have-accessible-errormessage' +// export {toHaveRole} from './to-have-role' +// export {toHaveAccessibleName} from './to-have-accessible-name' +// export {toHaveAttribute} from './to-have-attribute' +// export {toHaveClass} from './to-have-class' +// export {toHaveStyle} from './to-have-style' +// export {toHaveFocus} from './to-have-focus' +// export {toHaveFormValues} from './to-have-form-values' +// export {toBeVisible} from './to-be-visible' +// export {toBeDisabled, toBeEnabled} from './to-be-disabled' +// export {toBeRequired} from './to-be-required' +// export {toBeInvalid, toBeValid} from './to-be-invalid' +// export {toHaveValue} from './to-have-value' +// export {toHaveDisplayValue} from './to-have-display-value' +// export {toBeChecked} from './to-be-checked' +// export {toBePartiallyChecked} from './to-be-partially-checked' +// export {toHaveDescription} from './to-have-description' +// export {toHaveErrorMessage} from './to-have-errormessage' +// export {toHaveSelection} from './to-have-selection' diff --git a/packages/testing-library/lynx-dom-jest-matchers/src/to-be-checked.js b/packages/testing-library/lynx-dom-jest-matchers/src/to-be-checked.js new file mode 100644 index 0000000000..8cda0436a1 --- /dev/null +++ b/packages/testing-library/lynx-dom-jest-matchers/src/to-be-checked.js @@ -0,0 +1,65 @@ +import { roles } from 'aria-query'; +import { checkHtmlElement, toSentence } from './utils'; + +export function toBeChecked(element) { + checkHtmlElement(element, toBeChecked, this); + + const isValidInput = () => { + return ( + element.tagName.toLowerCase() === 'input' + && ['checkbox', 'radio'].includes(element.type) + ); + }; + + const isValidAriaElement = () => { + return ( + roleSupportsChecked(element.getAttribute('role')) + && ['true', 'false'].includes(element.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 isChecked = () => { + if (isValidInput()) return element.checked; + return element.getAttribute('aria-checked') === 'true'; + }; + + 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(element.cloneNode(false))}`, + ].join('\n'); + }, + }; +} + +function supportedRolesSentence() { + return toSentence( + supportedRoles().map(role => `role="${role}"`), + { lastWordConnector: ' or ' }, + ); +} + +function supportedRoles() { + return roles.keys().filter(roleSupportsChecked); +} + +function roleSupportsChecked(role) { + return roles.get(role)?.props['aria-checked'] !== undefined; +} diff --git a/packages/testing-library/lynx-dom-jest-matchers/src/to-be-disabled.js b/packages/testing-library/lynx-dom-jest-matchers/src/to-be-disabled.js new file mode 100644 index 0000000000..5975ed4e79 --- /dev/null +++ b/packages/testing-library/lynx-dom-jest-matchers/src/to-be-disabled.js @@ -0,0 +1,116 @@ +import { checkHtmlElement, getTag } from './utils'; + +// form elements that support 'disabled' +const FORM_TAGS = [ + 'fieldset', + 'input', + 'select', + 'optgroup', + 'option', + 'button', + 'textarea', +]; + +/* + * According to specification: + * If
is disabled, the form controls that are its descendants, + * except descendants of its first optional element, are disabled + * + * https://html.spec.whatwg.org/multipage/form-elements.html#concept-fieldset-disabled + * + * This method tests whether element is first legend child of fieldset parent + */ +function isFirstLegendChildOfFieldset(element, parent) { + return ( + getTag(element) === 'legend' + && getTag(parent) === 'fieldset' + && element.isSameNode( + Array.from(parent.children).find(child => getTag(child) === 'legend'), + ) + ); +} + +function isElementDisabledByParent(element, parent) { + return ( + isElementDisabled(parent) && !isFirstLegendChildOfFieldset(element, parent) + ); +} + +function isCustomElement(tag) { + return tag.includes('-'); +} + +/* + * Only certain form elements and custom elements can actually be disabled: + * https://html.spec.whatwg.org/multipage/semantics-other.html#disabled-elements + */ +function canElementBeDisabled(element) { + const tag = getTag(element); + return FORM_TAGS.includes(tag) || isCustomElement(tag); +} + +function isElementDisabled(element) { + return canElementBeDisabled(element) && element.hasAttribute('disabled'); +} + +function isAncestorDisabled(element) { + const parent = element.parentElement; + return ( + Boolean(parent) + && (isElementDisabledByParent(element, parent) + || isAncestorDisabled(parent)) + ); +} + +function isElementOrAncestorDisabled(element) { + return ( + canElementBeDisabled(element) + && (isElementDisabled(element) || isAncestorDisabled(element)) + ); +} + +export function toBeDisabled(element) { + checkHtmlElement(element, toBeDisabled, this); + + const isDisabled = isElementOrAncestorDisabled(element); + + 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(element.cloneNode(false))}`, + ].join('\n'); + }, + }; +} + +export function toBeEnabled(element) { + checkHtmlElement(element, toBeEnabled, this); + + const isEnabled = !isElementOrAncestorDisabled(element); + + return { + pass: isEnabled, + message: () => { + const is = isEnabled ? 'is' : 'is not'; + return [ + this.utils.matcherHint( + `${this.isNot ? '.not' : ''}.toBeEnabled`, + 'element', + '', + ), + '', + `Received element ${is} enabled:`, + ` ${this.utils.printReceived(element.cloneNode(false))}`, + ].join('\n'); + }, + }; +} diff --git a/packages/testing-library/lynx-dom-jest-matchers/src/to-be-empty-dom-element.js b/packages/testing-library/lynx-dom-jest-matchers/src/to-be-empty-dom-element.js new file mode 100644 index 0000000000..be3a1cb8e5 --- /dev/null +++ b/packages/testing-library/lynx-dom-jest-matchers/src/to-be-empty-dom-element.js @@ -0,0 +1,36 @@ +import { checkHtmlElement } from './utils'; + +export function toBeEmptyDOMElement(element) { + checkHtmlElement(element, toBeEmptyDOMElement, this); + + return { + pass: isEmptyElement(element), + message: () => { + return [ + this.utils.matcherHint( + `${this.isNot ? '.not' : ''}.toBeEmptyDOMElement`, + 'element', + '', + ), + '', + 'Received:', + ` ${this.utils.printReceived(element.innerHTML)}`, + ].join('\n'); + }, + }; +} + +/** + * Identifies if an element doesn't contain child nodes (excluding comments) + * ℹ Node.COMMENT_NODE can't be used because of the following issue + * https://github.com/jsdom/jsdom/issues/2220 + * + * @param {*} element an HtmlElement or SVGElement + * @return {*} true if the element only contains comments or none + */ +function isEmptyElement(element) { + const nonCommentChildNodes = [...element.childNodes].filter(node => + node.nodeType !== 8 + ); + return nonCommentChildNodes.length === 0; +} diff --git a/packages/testing-library/lynx-dom-jest-matchers/src/to-be-empty.js b/packages/testing-library/lynx-dom-jest-matchers/src/to-be-empty.js new file mode 100644 index 0000000000..d2b0f69fc5 --- /dev/null +++ b/packages/testing-library/lynx-dom-jest-matchers/src/to-be-empty.js @@ -0,0 +1,25 @@ +import { checkHtmlElement, deprecate } from './utils'; + +export function toBeEmpty(element) { + deprecate( + 'toBeEmpty', + 'Please use instead toBeEmptyDOMElement for finding empty nodes in the DOM.', + ); + checkHtmlElement(element, toBeEmpty, this); + + return { + pass: element.innerHTML === '', + message: () => { + return [ + this.utils.matcherHint( + `${this.isNot ? '.not' : ''}.toBeEmpty`, + 'element', + '', + ), + '', + 'Received:', + ` ${this.utils.printReceived(element.innerHTML)}`, + ].join('\n'); + }, + }; +} diff --git a/packages/testing-library/lynx-dom-jest-matchers/src/to-be-in-the-document.js b/packages/testing-library/lynx-dom-jest-matchers/src/to-be-in-the-document.js new file mode 100644 index 0000000000..2f7e0f79a8 --- /dev/null +++ b/packages/testing-library/lynx-dom-jest-matchers/src/to-be-in-the-document.js @@ -0,0 +1,38 @@ +import { checkHtmlElement } from './utils'; + +export function toBeInTheDocument(element) { + // if (element !== null || !this.isNot) { + // checkHtmlElement(element, toBeInTheDocument, this) + // } + + const pass = element === null + ? false + : element.ownerDocument === element.getRootNode({ composed: true }); + + const errorFound = () => { + return `expected document not to contain element, found ${ + this.utils.stringify( + element.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', + '', + ), + '', + // eslint-disable-next-line new-cap + this.utils.RECEIVED_COLOR(this.isNot ? errorFound() : errorNotFound()), + ].join('\n'); + }, + }; +} diff --git a/packages/testing-library/lynx-dom-jest-matchers/src/to-be-in-the-dom.js b/packages/testing-library/lynx-dom-jest-matchers/src/to-be-in-the-dom.js new file mode 100644 index 0000000000..39d1392546 --- /dev/null +++ b/packages/testing-library/lynx-dom-jest-matchers/src/to-be-in-the-dom.js @@ -0,0 +1,36 @@ +import { checkHtmlElement, deprecate } from './utils'; + +export function toBeInTheDOM(element, container) { + deprecate( + 'toBeInTheDOM', + 'Please use toBeInTheDocument for searching the entire document and toContainElement for searching a specific container.', + ); + + if (element) { + checkHtmlElement(element, toBeInTheDOM, this); + } + + if (container) { + checkHtmlElement(container, toBeInTheDOM, this); + } + + return { + pass: container ? container.contains(element) : !!element, + message: () => { + return [ + this.utils.matcherHint( + `${this.isNot ? '.not' : ''}.toBeInTheDOM`, + 'element', + '', + ), + '', + 'Received:', + ` ${ + this.utils.printReceived( + element ? element.cloneNode(false) : element, + ) + }`, + ].join('\n'); + }, + }; +} diff --git a/packages/testing-library/lynx-dom-jest-matchers/src/to-be-invalid.js b/packages/testing-library/lynx-dom-jest-matchers/src/to-be-invalid.js new file mode 100644 index 0000000000..3f9776d15b --- /dev/null +++ b/packages/testing-library/lynx-dom-jest-matchers/src/to-be-invalid.js @@ -0,0 +1,69 @@ +import { checkHtmlElement, getTag } from './utils'; + +const FORM_TAGS = ['form', 'input', 'select', 'textarea']; + +function isElementHavingAriaInvalid(element) { + return ( + element.hasAttribute('aria-invalid') + && element.getAttribute('aria-invalid') !== 'false' + ); +} + +function isSupportsValidityMethod(element) { + return FORM_TAGS.includes(getTag(element)); +} + +function isElementInvalid(element) { + const isHaveAriaInvalid = isElementHavingAriaInvalid(element); + if (isSupportsValidityMethod(element)) { + return isHaveAriaInvalid || !element.checkValidity(); + } else { + return isHaveAriaInvalid; + } +} + +export function toBeInvalid(element) { + checkHtmlElement(element, toBeInvalid, this); + + const isInvalid = isElementInvalid(element); + + 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(element.cloneNode(false))}`, + ].join('\n'); + }, + }; +} + +export function toBeValid(element) { + checkHtmlElement(element, toBeValid, this); + + const isValid = !isElementInvalid(element); + + 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(element.cloneNode(false))}`, + ].join('\n'); + }, + }; +} diff --git a/packages/testing-library/lynx-dom-jest-matchers/src/to-be-partially-checked.js b/packages/testing-library/lynx-dom-jest-matchers/src/to-be-partially-checked.js new file mode 100644 index 0000000000..854835a5a0 --- /dev/null +++ b/packages/testing-library/lynx-dom-jest-matchers/src/to-be-partially-checked.js @@ -0,0 +1,50 @@ +import { checkHtmlElement } from './utils'; + +export function toBePartiallyChecked(element) { + checkHtmlElement(element, toBePartiallyChecked, this); + + const isValidInput = () => { + return ( + element.tagName.toLowerCase() === 'input' && element.type === 'checkbox' + ); + }; + + const isValidAriaElement = () => { + return element.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 = () => { + const isAriaMixed = element.getAttribute('aria-checked') === 'mixed'; + + if (isValidInput()) { + return element.indeterminate || isAriaMixed; + } + + return isAriaMixed; + }; + + 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(element.cloneNode(false))}`, + ].join('\n'); + }, + }; +} diff --git a/packages/testing-library/lynx-dom-jest-matchers/src/to-be-required.js b/packages/testing-library/lynx-dom-jest-matchers/src/to-be-required.js new file mode 100644 index 0000000000..9d762ae077 --- /dev/null +++ b/packages/testing-library/lynx-dom-jest-matchers/src/to-be-required.js @@ -0,0 +1,76 @@ +import { checkHtmlElement, 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) { + return FORM_TAGS.includes(getTag(element)) + && element.hasAttribute('required'); +} + +function isRequiredOnSupportedInput(element) { + return ( + getTag(element) === 'input' + && element.hasAttribute('required') + && ((element.hasAttribute('type') + && !UNSUPPORTED_INPUT_TYPES.includes(element.getAttribute('type'))) + || !element.hasAttribute('type')) + ); +} + +function isElementRequiredByARIA(element) { + 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 function toBeRequired(element) { + checkHtmlElement(element, toBeRequired, this); + + const isRequired = isRequiredOnFormTagsExceptInput(element) + || isRequiredOnSupportedInput(element) + || isElementRequiredByARIA(element); + + 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(element.cloneNode(false))}`, + ].join('\n'); + }, + }; +} diff --git a/packages/testing-library/lynx-dom-jest-matchers/src/to-be-visible.js b/packages/testing-library/lynx-dom-jest-matchers/src/to-be-visible.js new file mode 100644 index 0000000000..50024f90fc --- /dev/null +++ b/packages/testing-library/lynx-dom-jest-matchers/src/to-be-visible.js @@ -0,0 +1,65 @@ +import { checkHtmlElement } from './utils'; + +function isStyleVisible(element) { + const { getComputedStyle } = element.ownerDocument.defaultView; + + const { display, visibility, opacity } = getComputedStyle(element); + return ( + display !== 'none' + && visibility !== 'hidden' + && visibility !== 'collapse' + && opacity !== '0' + && opacity !== 0 + ); +} + +function isAttributeVisible(element, previousElement) { + let detailsVisibility; + + if (previousElement) { + detailsVisibility = + element.nodeName === 'DETAILS' && previousElement.nodeName !== 'SUMMARY' + ? element.hasAttribute('open') + : true; + } else { + detailsVisibility = element.nodeName === 'DETAILS' + ? element.hasAttribute('open') + : true; + } + + return !element.hasAttribute('hidden') && detailsVisibility; +} + +function isElementVisible(element, previousElement) { + return ( + isStyleVisible(element) + && isAttributeVisible(element, previousElement) + && (!element.parentElement + || isElementVisible(element.parentElement, element)) + ); +} + +export function toBeVisible(element) { + checkHtmlElement(element, toBeVisible, this); + const isInDocument = + element.ownerDocument === element.getRootNode({ composed: true }); + const isVisible = isInDocument && isElementVisible(element); + 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(element.cloneNode(false))}`, + ].join('\n'); + }, + }; +} diff --git a/packages/testing-library/lynx-dom-jest-matchers/src/to-contain-element.js b/packages/testing-library/lynx-dom-jest-matchers/src/to-contain-element.js new file mode 100644 index 0000000000..471cf529d8 --- /dev/null +++ b/packages/testing-library/lynx-dom-jest-matchers/src/to-contain-element.js @@ -0,0 +1,32 @@ +import { checkHtmlElement } from './utils'; + +export function toContainElement(container, element) { + checkHtmlElement(container, toContainElement, this); + + if (element !== null) { + checkHtmlElement(element, toContainElement, this); + } + + return { + pass: container.contains(element), + message: () => { + return [ + this.utils.matcherHint( + `${this.isNot ? '.not' : ''}.toContainElement`, + 'element', + 'element', + ), + '', + // eslint-disable-next-line new-cap + this.utils.RECEIVED_COLOR(`${ + this.utils.stringify( + container.cloneNode(false), + ) + } ${this.isNot ? 'contains:' : 'does not contain:'} ${ + this.utils.stringify(element ? element.cloneNode(false) : element) + } + `), + ].join('\n'); + }, + }; +} diff --git a/packages/testing-library/lynx-dom-jest-matchers/src/to-contain-html.js b/packages/testing-library/lynx-dom-jest-matchers/src/to-contain-html.js new file mode 100644 index 0000000000..54eabe15de --- /dev/null +++ b/packages/testing-library/lynx-dom-jest-matchers/src/to-contain-html.js @@ -0,0 +1,33 @@ +import { checkHtmlElement } from './utils'; + +function getNormalizedHtml(container, htmlText) { + const div = container.ownerDocument.createElement('div'); + div.innerHTML = htmlText; + return div.innerHTML; +} + +export function toContainHTML(container, htmlText) { + checkHtmlElement(container, toContainHTML, this); + + if (typeof htmlText !== 'string') { + throw new Error(`.toContainHTML() expects a string value, got ${htmlText}`); + } + + return { + pass: container.outerHTML.includes(getNormalizedHtml(container, htmlText)), + message: () => { + return [ + this.utils.matcherHint( + `${this.isNot ? '.not' : ''}.toContainHTML`, + 'element', + '', + ), + 'Expected:', + // eslint-disable-next-line new-cap + ` ${this.utils.EXPECTED_COLOR(htmlText)}`, + 'Received:', + ` ${this.utils.printReceived(container.cloneNode(true))}`, + ].join('\n'); + }, + }; +} diff --git a/packages/testing-library/lynx-dom-jest-matchers/src/to-have-accessible-description.js b/packages/testing-library/lynx-dom-jest-matchers/src/to-have-accessible-description.js new file mode 100644 index 0000000000..f3b9469050 --- /dev/null +++ b/packages/testing-library/lynx-dom-jest-matchers/src/to-have-accessible-description.js @@ -0,0 +1,45 @@ +import { computeAccessibleDescription } from 'dom-accessibility-api'; +import { checkHtmlElement, getMessage } from './utils'; + +export function toHaveAccessibleDescription( + htmlElement, + expectedAccessibleDescription, +) { + checkHtmlElement(htmlElement, toHaveAccessibleDescription, this); + const actualAccessibleDescription = computeAccessibleDescription(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 = actualAccessibleDescription !== ''; + } else { + pass = expectedAccessibleDescription instanceof RegExp + ? expectedAccessibleDescription.test(actualAccessibleDescription) + : this.equals( + actualAccessibleDescription, + expectedAccessibleDescription, + ); + } + + return { + pass, + + message: () => { + const to = this.isNot ? 'not to' : 'to'; + return getMessage( + this, + this.utils.matcherHint( + `${this.isNot ? '.not' : ''}.${toHaveAccessibleDescription.name}`, + 'element', + '', + ), + `Expected element ${to} have accessible description`, + expectedAccessibleDescription, + 'Received', + actualAccessibleDescription, + ); + }, + }; +} diff --git a/packages/testing-library/lynx-dom-jest-matchers/src/to-have-accessible-errormessage.js b/packages/testing-library/lynx-dom-jest-matchers/src/to-have-accessible-errormessage.js new file mode 100644 index 0000000000..2c76c84491 --- /dev/null +++ b/packages/testing-library/lynx-dom-jest-matchers/src/to-have-accessible-errormessage.js @@ -0,0 +1,83 @@ +import { checkHtmlElement, getMessage, normalize } from './utils'; + +const ariaInvalidName = 'aria-invalid'; +const validStates = ['false']; + +// See `aria-errormessage` spec at https://www.w3.org/TR/wai-aria-1.2/#aria-errormessage +export function toHaveAccessibleErrorMessage( + htmlElement, + expectedAccessibleErrorMessage, +) { + checkHtmlElement(htmlElement, toHaveAccessibleErrorMessage, this); + const to = this.isNot ? 'not to' : 'to'; + const method = this.isNot + ? '.not.toHaveAccessibleErrorMessage' + : '.toHaveAccessibleErrorMessage'; + + // Enforce Valid Id + const errormessageId = htmlElement.getAttribute('aria-errormessage'); + const errormessageIdInvalid = !!errormessageId && /\s+/.test(errormessageId); + + if (errormessageIdInvalid) { + return { + pass: false, + message: () => { + return getMessage( + this, + this.utils.matcherHint(method, 'element'), + 'Expected element\'s `aria-errormessage` attribute to be empty or a single, valid ID', + '', + 'Received', + `aria-errormessage="${errormessageId}"`, + ); + }, + }; + } + + // See `aria-invalid` spec at https://www.w3.org/TR/wai-aria-1.2/#aria-invalid + const ariaInvalidVal = htmlElement.getAttribute(ariaInvalidName); + const fieldValid = !htmlElement.hasAttribute(ariaInvalidName) + || validStates.includes(ariaInvalidVal); + + // Enforce Valid `aria-invalid` Attribute + if (fieldValid) { + return { + pass: false, + message: () => { + return getMessage( + this, + this.utils.matcherHint(method, 'element'), + 'Expected element to be marked as invalid with attribute', + `${ariaInvalidName}="${String(true)}"`, + 'Received', + htmlElement.hasAttribute('aria-invalid') + ? `${ariaInvalidName}="${htmlElement.getAttribute(ariaInvalidName)}` + : null, + ); + }, + }; + } + + const error = normalize( + htmlElement.ownerDocument.getElementById(errormessageId)?.textContent ?? '', + ); + + return { + pass: expectedAccessibleErrorMessage === undefined + ? Boolean(error) + : expectedAccessibleErrorMessage instanceof RegExp + ? expectedAccessibleErrorMessage.test(error) + : this.equals(error, expectedAccessibleErrorMessage), + + message: () => { + return getMessage( + this, + this.utils.matcherHint(method, 'element'), + `Expected element ${to} have accessible error message`, + expectedAccessibleErrorMessage ?? '', + 'Received', + error, + ); + }, + }; +} diff --git a/packages/testing-library/lynx-dom-jest-matchers/src/to-have-accessible-name.js b/packages/testing-library/lynx-dom-jest-matchers/src/to-have-accessible-name.js new file mode 100644 index 0000000000..12937d3bd0 --- /dev/null +++ b/packages/testing-library/lynx-dom-jest-matchers/src/to-have-accessible-name.js @@ -0,0 +1,39 @@ +import { computeAccessibleName } from 'dom-accessibility-api'; +import { checkHtmlElement, getMessage } from './utils'; + +export function toHaveAccessibleName(htmlElement, expectedAccessibleName) { + checkHtmlElement(htmlElement, toHaveAccessibleName, this); + const actualAccessibleName = computeAccessibleName(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 name, whatever it may be. + pass = actualAccessibleName !== ''; + } else { + pass = expectedAccessibleName instanceof RegExp + ? expectedAccessibleName.test(actualAccessibleName) + : this.equals(actualAccessibleName, expectedAccessibleName); + } + + 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/testing-library/lynx-dom-jest-matchers/src/to-have-attribute.js b/packages/testing-library/lynx-dom-jest-matchers/src/to-have-attribute.js new file mode 100644 index 0000000000..951fbc9103 --- /dev/null +++ b/packages/testing-library/lynx-dom-jest-matchers/src/to-have-attribute.js @@ -0,0 +1,52 @@ +import { checkHtmlElement, getMessage } from './utils'; + +function printAttribute(stringify, name, value) { + return value === undefined ? name : `${name}=${stringify(value)}`; +} + +function getAttributeComment(stringify, name, value) { + return value === undefined + ? `element.hasAttribute(${stringify(name)})` + : `element.getAttribute(${stringify(name)}) === ${stringify(value)}`; +} + +export function toHaveAttribute(htmlElement, name, expectedValue) { + checkHtmlElement(htmlElement, toHaveAttribute, this); + const isExpectedValuePresent = expectedValue !== undefined; + const hasAttribute = htmlElement.hasAttribute(name); + const receivedValue = htmlElement.getAttribute(name); + return { + pass: isExpectedValuePresent + ? hasAttribute && this.equals(receivedValue, expectedValue) + : hasAttribute, + message: () => { + const to = this.isNot ? 'not to' : 'to'; + const receivedAttribute = hasAttribute + ? printAttribute(this.utils.stringify, name, receivedValue) + : null; + const matcher = this.utils.matcherHint( + `${this.isNot ? '.not' : ''}.toHaveAttribute`, + 'element', + this.utils.printExpected(name), + { + secondArgument: isExpectedValuePresent + ? this.utils.printExpected(expectedValue) + : undefined, + comment: getAttributeComment( + this.utils.stringify, + name, + expectedValue, + ), + }, + ); + return getMessage( + this, + matcher, + `Expected the element ${to} have attribute`, + printAttribute(this.utils.stringify, name, expectedValue), + 'Received', + receivedAttribute, + ); + }, + }; +} diff --git a/packages/testing-library/lynx-dom-jest-matchers/src/to-have-class.js b/packages/testing-library/lynx-dom-jest-matchers/src/to-have-class.js new file mode 100644 index 0000000000..9edfd34515 --- /dev/null +++ b/packages/testing-library/lynx-dom-jest-matchers/src/to-have-class.js @@ -0,0 +1,111 @@ +import { checkHtmlElement, getMessage } from './utils'; + +function getExpectedClassNamesAndOptions(params) { + 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, options }; +} + +function splitClassNames(str) { + if (!str) return []; + return str.split(/\s+/).filter(s => s.length > 0); +} + +function isSubset(subset, superset) { + return subset.every(strOrRegexp => + typeof strOrRegexp === 'string' + ? superset.includes(strOrRegexp) + : superset.some(className => strOrRegexp.test(className)) + ); +} + +export function toHaveClass(htmlElement, ...params) { + checkHtmlElement(htmlElement, toHaveClass, this); + const { expectedClassNames, options } = getExpectedClassNamesAndOptions( + params, + ); + + const received = splitClassNames(htmlElement.getAttribute('class')); + const expected = expectedClassNames.reduce( + (acc, className) => + acc.concat( + typeof className === 'string' || !className + ? splitClassNames(className) + : className, + ), + [], + ); + + 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'), + }; +} diff --git a/packages/testing-library/lynx-dom-jest-matchers/src/to-have-description.js b/packages/testing-library/lynx-dom-jest-matchers/src/to-have-description.js new file mode 100644 index 0000000000..ddab18475b --- /dev/null +++ b/packages/testing-library/lynx-dom-jest-matchers/src/to-have-description.js @@ -0,0 +1,47 @@ +import { checkHtmlElement, getMessage, normalize, deprecate } from './utils'; + +// See algoritm: https://www.w3.org/TR/accname-1.1/#mapping_additional_nd_description +export function toHaveDescription(htmlElement, checkWith) { + deprecate( + 'toHaveDescription', + 'Please use toHaveAccessibleDescription.', + ); + + checkHtmlElement(htmlElement, toHaveDescription, this); + + const expectsDescription = checkWith !== undefined; + + const descriptionIDRaw = htmlElement.getAttribute('aria-describedby') || ''; + const descriptionIDs = descriptionIDRaw.split(/\s+/).filter(Boolean); + let description = ''; + if (descriptionIDs.length > 0) { + const document = htmlElement.ownerDocument; + const descriptionEls = descriptionIDs + .map(descriptionID => document.getElementById(descriptionID)) + .filter(Boolean); + description = normalize(descriptionEls.map(el => el.textContent).join(' ')); + } + + return { + pass: expectsDescription + ? checkWith instanceof RegExp + ? checkWith.test(description) + : this.equals(description, checkWith) + : Boolean(description), + message: () => { + const to = this.isNot ? 'not to' : 'to'; + return getMessage( + this, + this.utils.matcherHint( + `${this.isNot ? '.not' : ''}.toHaveDescription`, + 'element', + '', + ), + `Expected the element ${to} have description`, + this.utils.printExpected(checkWith), + 'Received', + this.utils.printReceived(description), + ); + }, + }; +} diff --git a/packages/testing-library/lynx-dom-jest-matchers/src/to-have-display-value.js b/packages/testing-library/lynx-dom-jest-matchers/src/to-have-display-value.js new file mode 100644 index 0000000000..0236ac8f94 --- /dev/null +++ b/packages/testing-library/lynx-dom-jest-matchers/src/to-have-display-value.js @@ -0,0 +1,62 @@ +import { checkHtmlElement, getMessage } from './utils'; + +export function toHaveDisplayValue(htmlElement, expectedValue) { + checkHtmlElement(htmlElement, toHaveDisplayValue, this); + const tagName = htmlElement.tagName.toLowerCase(); + + if (!['select', 'input', 'textarea'].includes(tagName)) { + throw new Error( + '.toHaveDisplayValue() currently supports only input, textarea or select elements, try with another matcher instead.', + ); + } + + if (tagName === 'input' && ['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)) + ) + ).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, htmlElement) { + return tagName === 'select' + ? Array.from(htmlElement) + .filter(option => option.selected) + .map(option => option.textContent) + : [htmlElement.value]; +} + +function getExpectedValues(expectedValue) { + return expectedValue instanceof Array ? expectedValue : [expectedValue]; +} diff --git a/packages/testing-library/lynx-dom-jest-matchers/src/to-have-errormessage.js b/packages/testing-library/lynx-dom-jest-matchers/src/to-have-errormessage.js new file mode 100644 index 0000000000..f8ef848347 --- /dev/null +++ b/packages/testing-library/lynx-dom-jest-matchers/src/to-have-errormessage.js @@ -0,0 +1,71 @@ +import { checkHtmlElement, getMessage, normalize, deprecate } from './utils'; + +// See aria-errormessage spec https://www.w3.org/TR/wai-aria-1.2/#aria-errormessage +export function toHaveErrorMessage(htmlElement, checkWith) { + deprecate('toHaveErrorMessage', 'Please use toHaveAccessibleErrorMessage.'); + checkHtmlElement(htmlElement, toHaveErrorMessage, this); + + if ( + !htmlElement.hasAttribute('aria-invalid') + || htmlElement.getAttribute('aria-invalid') === 'false' + ) { + const not = this.isNot ? '.not' : ''; + + return { + pass: false, + message: () => { + return getMessage( + this, + this.utils.matcherHint(`${not}.toHaveErrorMessage`, 'element', ''), + `Expected the element to have invalid state indicated by`, + 'aria-invalid="true"', + 'Received', + htmlElement.hasAttribute('aria-invalid') + ? `aria-invalid="${htmlElement.getAttribute('aria-invalid')}"` + : this.utils.printReceived(''), + ); + }, + }; + } + + const expectsErrorMessage = checkWith !== undefined; + + const errormessageIDRaw = htmlElement.getAttribute('aria-errormessage') || ''; + const errormessageIDs = errormessageIDRaw.split(/\s+/).filter(Boolean); + + let errormessage = ''; + if (errormessageIDs.length > 0) { + const document = htmlElement.ownerDocument; + + const errormessageEls = errormessageIDs + .map(errormessageID => document.getElementById(errormessageID)) + .filter(Boolean); + + errormessage = normalize( + errormessageEls.map(el => el.textContent).join(' '), + ); + } + + return { + pass: expectsErrorMessage + ? checkWith instanceof RegExp + ? checkWith.test(errormessage) + : this.equals(errormessage, checkWith) + : Boolean(errormessage), + message: () => { + const to = this.isNot ? 'not to' : 'to'; + return getMessage( + this, + this.utils.matcherHint( + `${this.isNot ? '.not' : ''}.toHaveErrorMessage`, + 'element', + '', + ), + `Expected the element ${to} have error message`, + this.utils.printExpected(checkWith), + 'Received', + this.utils.printReceived(errormessage), + ); + }, + }; +} diff --git a/packages/testing-library/lynx-dom-jest-matchers/src/to-have-focus.js b/packages/testing-library/lynx-dom-jest-matchers/src/to-have-focus.js new file mode 100644 index 0000000000..db664bf6ac --- /dev/null +++ b/packages/testing-library/lynx-dom-jest-matchers/src/to-have-focus.js @@ -0,0 +1,34 @@ +import { checkHtmlElement } from './utils'; + +export function toHaveFocus(element) { + checkHtmlElement(element, toHaveFocus, this); + + return { + pass: element.ownerDocument.activeElement === element, + message: () => { + return [ + this.utils.matcherHint( + `${this.isNot ? '.not' : ''}.toHaveFocus`, + 'element', + '', + ), + '', + ...(this.isNot + ? [ + 'Received element is focused:', + ` ${this.utils.printReceived(element)}`, + ] + : [ + 'Expected element with focus:', + ` ${this.utils.printExpected(element)}`, + 'Received element with focus:', + ` ${ + this.utils.printReceived( + element.ownerDocument.activeElement, + ) + }`, + ]), + ].join('\n'); + }, + }; +} diff --git a/packages/testing-library/lynx-dom-jest-matchers/src/to-have-form-values.js b/packages/testing-library/lynx-dom-jest-matchers/src/to-have-form-values.js new file mode 100644 index 0000000000..a58b54a26a --- /dev/null +++ b/packages/testing-library/lynx-dom-jest-matchers/src/to-have-form-values.js @@ -0,0 +1,87 @@ +import isEqualWith from 'lodash/isEqualWith.js'; +import escape from 'css.escape'; +import { + checkHtmlElement, + getSingleElementValue, + compareArraysAsSet, +} from './utils'; + +// Returns the combined value of several elements that have the same name +// e.g. radio buttons or groups of checkboxes +function getMultiElementValue(elements) { + 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 theChosenOne = elements.find(radio => radio.checked); + return theChosenOne ? theChosenOne.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, name) { + const elements = [...container.querySelectorAll(`[name="${escape(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); + } +} + +// Strips the `[]` suffix off a form value name +function getPureName(name) { + return /\[\]$/.test(name) ? name.slice(0, -2) : name; +} + +function getAllFormValues(container) { + const names = Array.from(container.elements).map(element => element.name); + return names.reduce( + (obj, name) => ({ + ...obj, + [getPureName(name)]: getFormValue(container, name), + }), + {}, + ); +} + +export function toHaveFormValues(formElement, expectedValues) { + checkHtmlElement(formElement, toHaveFormValues, this); + if (!formElement.elements) { + // TODO: Change condition to use instanceof against the appropriate element classes instead + throw new Error('toHaveFormValues must be called on a form or a fieldset'); + } + const formValues = getAllFormValues(formElement); + return { + pass: Object.entries(expectedValues).every(([name, expectedValue]) => + isEqualWith(formValues[name], expectedValue, compareArraysAsSet) + ), + message: () => { + const to = this.isNot ? 'not to' : 'to'; + const matcher = `${this.isNot ? '.not' : ''}.toHaveFormValues`; + const commonKeyValues = Object.keys(formValues) + .filter(key => expectedValues.hasOwnProperty(key)) + .reduce((obj, key) => ({ ...obj, [key]: formValues[key] }), {}); + return [ + this.utils.matcherHint(matcher, 'element', ''), + `Expected the element ${to} have form values`, + this.utils.diff(expectedValues, commonKeyValues), + ].join('\n\n'); + }, + }; +} diff --git a/packages/testing-library/lynx-dom-jest-matchers/src/to-have-role.js b/packages/testing-library/lynx-dom-jest-matchers/src/to-have-role.js new file mode 100644 index 0000000000..d32baca00e --- /dev/null +++ b/packages/testing-library/lynx-dom-jest-matchers/src/to-have-role.js @@ -0,0 +1,149 @@ +import { elementRoles } from 'aria-query'; +import { checkHtmlElement, getMessage } from './utils'; + +const elementRoleList = buildElementRoleList(elementRoles); + +export function toHaveRole(htmlElement, expectedRole) { + checkHtmlElement(htmlElement, toHaveRole, this); + + const actualRoles = getExplicitOrImplicitRoles(htmlElement); + const pass = actualRoles.some(el => el === expectedRole); + + return { + pass, + + message: () => { + const to = this.isNot ? 'not to' : 'to'; + return getMessage( + this, + this.utils.matcherHint( + `${this.isNot ? '.not' : ''}.${toHaveRole.name}`, + 'element', + '', + ), + `Expected element ${to} have role`, + expectedRole, + 'Received', + actualRoles.join(', '), + ); + }, + }; +} + +function getExplicitOrImplicitRoles(htmlElement) { + const hasExplicitRole = htmlElement.hasAttribute('role'); + + if (hasExplicitRole) { + const roleValue = htmlElement.getAttribute('role'); + + // Handle fallback roles, such as role="switch button" + // testing-library gates this behind the `queryFallbacks` flag; it is + // unclear why, but it makes sense to support this pattern out of the box + // https://testing-library.com/docs/queries/byrole/#queryfallbacks + return roleValue.split(' ').filter(Boolean); + } + + const implicitRoles = getImplicitAriaRoles(htmlElement); + + return implicitRoles; +} + +function getImplicitAriaRoles(currentNode) { + for (const { match, roles } of elementRoleList) { + if (match(currentNode)) { + return [...roles]; + } + } + + /* istanbul ignore next */ + return []; // this does not get reached in practice, since elements have at least a 'generic' role +} + +/** + * Transform the roles map (with required attributes and constraints) to a list + * of roles. Each item in the list has functions to match an element against it. + * + * Essentially copied over from [dom-testing-library's + * helpers](https://github.com/testing-library/dom-testing-library/blob/bd04cf95a1ed85a2238f7dfc1a77d5d16b4f59dc/src/role-helpers.js#L80) + * + * TODO: If we are truly just copying over stuff, would it make sense to move + * this to a separate package? + * + * TODO: This technique relies on CSS selectors; are those consistently + * available in all jest-dom environments? Why do other matchers in this package + * not use them like this? + */ +function buildElementRoleList(elementRolesMap) { + function makeElementSelector({ name, attributes }) { + return `${name}${ + attributes + .map(({ name: attributeName, value, constraints = [] }) => { + const shouldNotExist = constraints.indexOf('undefined') !== -1; + if (shouldNotExist) { + return `:not([${attributeName}])`; + } else if (value) { + return `[${attributeName}="${value}"]`; + } else { + return `[${attributeName}]`; + } + }) + .join('') + }`; + } + + function getSelectorSpecificity({ attributes = [] }) { + return attributes.length; + } + + function bySelectorSpecificity( + { specificity: leftSpecificity }, + { specificity: rightSpecificity }, + ) { + return rightSpecificity - leftSpecificity; + } + + function match(element) { + let { attributes = [] } = element; + + // https://github.com/testing-library/dom-testing-library/issues/814 + const typeTextIndex = attributes.findIndex( + attribute => + attribute.value + && attribute.name === 'type' + && attribute.value === 'text', + ); + + if (typeTextIndex >= 0) { + // not using splice to not mutate the attributes array + attributes = [ + ...attributes.slice(0, typeTextIndex), + ...attributes.slice(typeTextIndex + 1), + ]; + } + + const selector = makeElementSelector({ ...element, attributes }); + + return node => { + if (typeTextIndex >= 0 && node.type !== 'text') { + return false; + } + + return node.matches(selector); + }; + } + + let result = []; + + for (const [element, roles] of elementRolesMap.entries()) { + result = [ + ...result, + { + match: match(element), + roles: Array.from(roles), + specificity: getSelectorSpecificity(element), + }, + ]; + } + + return result.sort(bySelectorSpecificity); +} diff --git a/packages/testing-library/lynx-dom-jest-matchers/src/to-have-selection.js b/packages/testing-library/lynx-dom-jest-matchers/src/to-have-selection.js new file mode 100644 index 0000000000..5d61f50593 --- /dev/null +++ b/packages/testing-library/lynx-dom-jest-matchers/src/to-have-selection.js @@ -0,0 +1,113 @@ +import isEqualWith from 'lodash/isEqualWith.js'; +import { checkHtmlElement, compareArraysAsSet, getMessage } from './utils'; + +/** + * Returns the selection from the element. + * + * @param element {HTMLElement} The element to get the selection from. + * @returns {String} The selection. + */ +function getSelection(element) { + const selection = element.ownerDocument.getSelection(); + + if (['input', 'textarea'].includes(element.tagName.toLowerCase())) { + if (['radio', 'checkbox'].includes(element.type)) return ''; + return element.value + .toString() + .substring(element.selectionStart, element.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; +} + +/** + * Checks if the element has the string selected. + * + * @param htmlElement {HTMLElement} The html element to check the selection for. + * @param expectedSelection {String} The selection as a string. + */ +export function toHaveSelection(htmlElement, expectedSelection) { + checkHtmlElement(htmlElement, 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 + ? isEqualWith(receivedSelection, expectedSelection, compareArraysAsSet) + : 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, + ); + }, + }; +} diff --git a/packages/testing-library/lynx-dom-jest-matchers/src/to-have-style.js b/packages/testing-library/lynx-dom-jest-matchers/src/to-have-style.js new file mode 100644 index 0000000000..9807f8d7e5 --- /dev/null +++ b/packages/testing-library/lynx-dom-jest-matchers/src/to-have-style.js @@ -0,0 +1,76 @@ +import chalk from 'chalk'; +import { checkHtmlElement, parseCSS } from './utils'; + +function getStyleDeclaration(document, css) { + const styles = {}; + + // The next block is necessary to normalize colors + const copy = document.createElement('div'); + Object.keys(css).forEach(property => { + copy.style[property] = css[property]; + styles[property] = copy.style[property]; + }); + + return styles; +} + +function isSubset(styles, computedStyle) { + return ( + !!Object.keys(styles).length + && Object.entries(styles).every(([prop, value]) => { + const isCustomProperty = prop.startsWith('--'); + const spellingVariants = [prop]; + if (!isCustomProperty) spellingVariants.push(prop.toLowerCase()); + + return spellingVariants.some( + name => + computedStyle[name] === value + || computedStyle.getPropertyValue(name) === value, + ); + }) + ); +} + +function printoutStyles(styles) { + return Object.keys(styles) + .sort() + .map(prop => `${prop}: ${styles[prop]};`) + .join('\n'); +} + +// Highlights only style rules that were expected but were not found in the +// received computed styles +function expectedDiff(diffFn, expected, computedStyles) { + const received = Array.from(computedStyles) + .filter(prop => expected[prop] !== undefined) + .reduce( + (obj, prop) => + Object.assign(obj, { [prop]: computedStyles.getPropertyValue(prop) }), + {}, + ); + const diffOutput = diffFn(printoutStyles(expected), printoutStyles(received)); + // Remove the "+ Received" annotation because this is a one-way diff + return diffOutput.replace(`${chalk.red('+ Received')}\n`, ''); +} + +export function toHaveStyle(htmlElement, css) { + checkHtmlElement(htmlElement, toHaveStyle, this); + const parsedCSS = typeof css === 'object' + ? css + : parseCSS(css, toHaveStyle, this); + const { getComputedStyle } = htmlElement.ownerDocument.defaultView; + + const expected = getStyleDeclaration(htmlElement.ownerDocument, parsedCSS); + const received = getComputedStyle(htmlElement); + + return { + pass: isSubset(expected, received), + message: () => { + const matcher = `${this.isNot ? '.not' : ''}.toHaveStyle`; + return [ + this.utils.matcherHint(matcher, 'element', ''), + expectedDiff(this.utils.diff, expected, received), + ].join('\n\n'); + }, + }; +} diff --git a/packages/testing-library/lynx-dom-jest-matchers/src/to-have-text-content.js b/packages/testing-library/lynx-dom-jest-matchers/src/to-have-text-content.js new file mode 100644 index 0000000000..8303e7fb99 --- /dev/null +++ b/packages/testing-library/lynx-dom-jest-matchers/src/to-have-text-content.js @@ -0,0 +1,36 @@ +import { getMessage, checkNode, matches, normalize } from './utils'; + +export function toHaveTextContent( + node, + checkWith, + options = { normalizeWhitespace: true }, +) { + // checkNode(node, toHaveTextContent, this) + + const textContent = options.normalizeWhitespace + ? normalize(node.textContent) + : node.textContent.replace(/\u00a0/g, ' '); // Replace   with normal spaces + + const checkingWithEmptyString = textContent !== '' && checkWith === ''; + + return { + pass: !checkingWithEmptyString && matches(textContent, checkWith), + 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`, + checkWith, + 'Received', + textContent, + ); + }, + }; +} diff --git a/packages/testing-library/lynx-dom-jest-matchers/src/to-have-value.js b/packages/testing-library/lynx-dom-jest-matchers/src/to-have-value.js new file mode 100644 index 0000000000..b45fe8f29b --- /dev/null +++ b/packages/testing-library/lynx-dom-jest-matchers/src/to-have-value.js @@ -0,0 +1,52 @@ +import isEqualWith from 'lodash/isEqualWith.js'; +import { + checkHtmlElement, + getMessage, + getSingleElementValue, + compareArraysAsSet, +} from './utils'; + +export function toHaveValue(htmlElement, expectedValue) { + checkHtmlElement(htmlElement, toHaveValue, this); + + if ( + htmlElement.tagName.toLowerCase() === 'input' + && ['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; + if (expectedValue == receivedValue && expectedValue !== receivedValue) { + expectedTypedValue = `${expectedValue} (${typeof expectedValue})`; + receivedTypedValue = `${receivedValue} (${typeof receivedValue})`; + } + + return { + pass: expectsValue + ? isEqualWith(receivedValue, expectedValue, compareArraysAsSet) + : 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/testing-library/lynx-dom-jest-matchers/src/utils.js b/packages/testing-library/lynx-dom-jest-matchers/src/utils.js new file mode 100644 index 0000000000..092671ece8 --- /dev/null +++ b/packages/testing-library/lynx-dom-jest-matchers/src/utils.js @@ -0,0 +1,261 @@ +import redent from 'redent'; +import { parse } from '@adobe/css-tools'; + +class GenericTypeError extends Error { + constructor(expectedString, received, matcherFn, context) { + super(); + + /* istanbul ignore next */ + if (Error.captureStackTrace) { + Error.captureStackTrace(this, matcherFn); + } + let withType = ''; + try { + withType = context.utils.printWithType( + 'Received', + received, + context.utils.printReceived, + ); + } catch (e) { + // Can throw for Document: + // https://github.com/jsdom/jsdom/issues/2304 + } + this.message = [ + context.utils.matcherHint( + `${context.isNot ? '.not' : ''}.${matcherFn.name}`, + 'received', + '', + ), + '', + // eslint-disable-next-line new-cap + `${ + context.utils.RECEIVED_COLOR( + 'received', + ) + } value must ${expectedString}.`, + withType, + ].join('\n'); + } +} + +class HtmlElementTypeError extends GenericTypeError { + constructor(...args) { + super('be an HTMLElement or an SVGElement', ...args); + } +} + +class NodeTypeError extends GenericTypeError { + constructor(...args) { + super('be a Node', ...args); + } +} + +function checkHasWindow(htmlElement, ErrorClass, ...args) { + if ( + !htmlElement + || !htmlElement.ownerDocument + || !htmlElement.ownerDocument.defaultView + ) { + throw new ErrorClass(htmlElement, ...args); + } +} + +function checkNode(node, ...args) { + checkHasWindow(node, NodeTypeError, ...args); + const window = node.ownerDocument.defaultView; + + if (!(node instanceof window.Node)) { + throw new NodeTypeError(node, ...args); + } +} + +function checkHtmlElement(htmlElement, ...args) { + checkHasWindow(htmlElement, HtmlElementTypeError, ...args); + const window = htmlElement.ownerDocument.defaultView; + + if ( + !(htmlElement instanceof window.HTMLElement) + && !(htmlElement instanceof window.SVGElement) + ) { + throw new HtmlElementTypeError(htmlElement, ...args); + } +} + +class InvalidCSSError extends Error { + constructor(received, matcherFn, context) { + super(); + + /* istanbul ignore next */ + if (Error.captureStackTrace) { + Error.captureStackTrace(this, matcherFn); + } + this.message = [ + received.message, + '', + // eslint-disable-next-line new-cap + context.utils.RECEIVED_COLOR(`Failing css:`), + // eslint-disable-next-line new-cap + context.utils.RECEIVED_COLOR(`${received.css}`), + ].join('\n'); + } +} + +function parseCSS(css, ...args) { + const ast = parse(`selector { ${css} }`, { silent: true }).stylesheet; + + if (ast.parsingErrors && ast.parsingErrors.length > 0) { + const { reason, line } = ast.parsingErrors[0]; + + throw new InvalidCSSError( + { + css, + message: + `Syntax error parsing expected css: ${reason} on line: ${line}`, + }, + ...args, + ); + } + + const parsedRules = ast.rules[0].declarations + .filter(d => d.type === 'declaration') + .reduce( + (obj, { property, value }) => Object.assign(obj, { [property]: value }), + {}, + ); + return parsedRules; +} + +function display(context, value) { + return typeof value === 'string' ? value : context.utils.stringify(value); +} + +function getMessage( + context, + matcher, + expectedLabel, + expectedValue, + receivedLabel, + receivedValue, +) { + return [ + `${matcher}\n`, + // eslint-disable-next-line new-cap + `${expectedLabel}:\n${ + context.utils.EXPECTED_COLOR( + redent(display(context, expectedValue), 2), + ) + }`, + // eslint-disable-next-line new-cap + `${receivedLabel}:\n${ + context.utils.RECEIVED_COLOR( + redent(display(context, receivedValue), 2), + ) + }`, + ].join('\n'); +} + +function matches(textToMatch, matcher) { + if (matcher instanceof RegExp) { + return matcher.test(textToMatch); + } else { + return textToMatch.includes(String(matcher)); + } +} + +function deprecate(name, replacementText) { + // Notify user that they are using deprecated functionality. + // eslint-disable-next-line no-console + console.warn( + `Warning: ${name} has been deprecated and will be removed in future updates.`, + replacementText, + ); +} + +function normalize(text) { + return text.replace(/\s+/g, ' ').trim(); +} + +function getTag(element) { + return element.tagName && element.tagName.toLowerCase(); +} + +function getSelectValue({ multiple, options }) { + 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) { + 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) { + if (!rolesSupportingValues.includes(element.getAttribute('role'))) { + return undefined; + } + return Number(element.getAttribute('aria-valuenow')); +} + +function getSingleElementValue(element) { + /* istanbul ignore if */ + if (!element) { + return undefined; + } + + switch (element.tagName.toLowerCase()) { + case 'input': + return getInputValue(element); + case 'select': + return getSelectValue(element); + default: { + return element.value ?? getAccessibleValue(element); + } + } +} + +function toSentence( + array, + { wordConnector = ', ', lastWordConnector = ' and ' } = {}, +) { + return [array.slice(0, -1).join(wordConnector), array[array.length - 1]].join( + array.length > 1 ? lastWordConnector : '', + ); +} + +function compareArraysAsSet(arr1, arr2) { + if (Array.isArray(arr1) && Array.isArray(arr2)) { + return [...new Set(arr1)].every(v => new Set(arr2).has(v)); + } + return undefined; +} + +export { + HtmlElementTypeError, + NodeTypeError, + checkHtmlElement, + checkNode, + parseCSS, + deprecate, + getMessage, + matches, + normalize, + getTag, + getSingleElementValue, + toSentence, + compareArraysAsSet, +}; diff --git a/packages/testing-library/lynx-dom-jest-matchers/src/vitest.js b/packages/testing-library/lynx-dom-jest-matchers/src/vitest.js new file mode 100644 index 0000000000..8e3736d00b --- /dev/null +++ b/packages/testing-library/lynx-dom-jest-matchers/src/vitest.js @@ -0,0 +1,6 @@ +/* istanbul ignore file */ + +import { expect } from 'vitest'; +import * as extensions from './matchers'; + +expect.extend(extensions); diff --git a/packages/testing-library/lynx-dom-jest-matchers/tests/jest.config.dom.js b/packages/testing-library/lynx-dom-jest-matchers/tests/jest.config.dom.js new file mode 100644 index 0000000000..c304ba55e3 --- /dev/null +++ b/packages/testing-library/lynx-dom-jest-matchers/tests/jest.config.dom.js @@ -0,0 +1,9 @@ +const path = require('path'); +const config = require('kcd-scripts/jest'); + +module.exports = { + rootDir: path.resolve(__dirname, '..'), + displayName: 'jsdom', + testEnvironment: 'dom', + ...config, +}; diff --git a/packages/testing-library/lynx-dom-jest-matchers/tests/jest.config.node.js b/packages/testing-library/lynx-dom-jest-matchers/tests/jest.config.node.js new file mode 100644 index 0000000000..3b6216dabc --- /dev/null +++ b/packages/testing-library/lynx-dom-jest-matchers/tests/jest.config.node.js @@ -0,0 +1,9 @@ +const path = require('path'); +const config = require('kcd-scripts/jest'); + +module.exports = { + rootDir: path.resolve(__dirname, '..'), + displayName: 'node', + testEnvironment: 'node', + ...config, +}; diff --git a/packages/testing-library/lynx-dom-jest-matchers/tests/setup-env.js b/packages/testing-library/lynx-dom-jest-matchers/tests/setup-env.js new file mode 100644 index 0000000000..758df4d4e1 --- /dev/null +++ b/packages/testing-library/lynx-dom-jest-matchers/tests/setup-env.js @@ -0,0 +1,4 @@ +import { plugins } from 'pretty-format'; +import '../src/index'; + +expect.addSnapshotSerializer(plugins.ConvertAnsi); diff --git a/packages/testing-library/lynx-dom-jest-matchers/tsconfig.json b/packages/testing-library/lynx-dom-jest-matchers/tsconfig.json new file mode 100644 index 0000000000..523cf9fc43 --- /dev/null +++ b/packages/testing-library/lynx-dom-jest-matchers/tsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "noEmit": true, + "strict": true, + "skipLibCheck": true, + }, + "include": ["*.d.ts", "types"], + "exclude": ["types/__tests__"], +} diff --git a/packages/testing-library/lynx-dom-jest-matchers/types/__tests__/bun/bun-custom-expect-types.test.ts b/packages/testing-library/lynx-dom-jest-matchers/types/__tests__/bun/bun-custom-expect-types.test.ts new file mode 100644 index 0000000000..58f34dedcf --- /dev/null +++ b/packages/testing-library/lynx-dom-jest-matchers/types/__tests__/bun/bun-custom-expect-types.test.ts @@ -0,0 +1,101 @@ +/** + * File that tests whether the TypeScript typings work as expected. + */ + +/* eslint-disable @typescript-eslint/no-unsafe-call */ +/* eslint-disable @typescript-eslint/no-floating-promises */ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ + +import { expect } from 'bun:test'; +import * as matchersStandalone from '../../matchers-standalone'; +import * as originalMatchers from '../../matchers'; + +expect.extend(matchersStandalone); + +const element: HTMLElement = document.body; + +function customExpect( + _actual: HTMLElement, +): + | originalMatchers.TestingLibraryMatchers + | originalMatchers.TestingLibraryMatchers> +{ + throw new Error('Method not implemented.'); +} + +customExpect(element).toBeInTheDOM(); +customExpect(element).toBeInTheDOM(document.body); +customExpect(element).toBeInTheDocument(); +customExpect(element).toBeVisible(); +customExpect(element).toBeEmpty(); +customExpect(element).toBeDisabled(); +customExpect(element).toBeEnabled(); +customExpect(element).toBeInvalid(); +customExpect(element).toBeRequired(); +customExpect(element).toBeValid(); +customExpect(element).toContainElement(document.body); +customExpect(element).toContainElement(null); +customExpect(element).toContainHTML('body'); +customExpect(element).toHaveAttribute('attr'); +customExpect(element).toHaveAttribute('attr', true); +customExpect(element).toHaveAttribute('attr', 'yes'); +customExpect(element).toHaveClass(); +customExpect(element).toHaveClass('cls1'); +customExpect(element).toHaveClass('cls1', 'cls2', 'cls3', 'cls4'); +customExpect(element).toHaveClass('cls1', { exact: true }); +customExpect(element).toHaveDisplayValue('str'); +customExpect(element).toHaveDisplayValue(['str1', 'str2']); +customExpect(element).toHaveDisplayValue(/str/); +customExpect(element).toHaveDisplayValue([/str1/, 'str2']); +customExpect(element).toHaveFocus(); +customExpect(element).toHaveFormValues({ foo: 'bar', baz: 1 }); +customExpect(element).toHaveStyle('display: block'); +customExpect(element).toHaveStyle({ display: 'block', width: 100 }); +customExpect(element).toHaveTextContent('Text'); +customExpect(element).toHaveTextContent(/Text/); +customExpect(element).toHaveTextContent('Text', { normalizeWhitespace: true }); +customExpect(element).toHaveTextContent(/Text/, { normalizeWhitespace: true }); +customExpect(element).toHaveValue(); +customExpect(element).toHaveValue('str'); +customExpect(element).toHaveValue(['str1', 'str2']); +customExpect(element).toHaveValue(1); +customExpect(element).toHaveValue(null); +customExpect(element).toBeChecked(); +customExpect(element).toHaveDescription('some description'); +customExpect(element).toHaveDescription(/some description/); +customExpect(element).toHaveDescription(expect.stringContaining('partial')); +customExpect(element).toHaveDescription(); +customExpect(element).toHaveAccessibleDescription('some description'); +customExpect(element).toHaveAccessibleDescription(/some description/); +customExpect(element).toHaveAccessibleDescription( + expect.stringContaining('partial'), +); +customExpect(element).toHaveAccessibleDescription(); + +customExpect(element).toHaveAccessibleErrorMessage(); +customExpect(element).toHaveAccessibleErrorMessage( + 'Invalid time: the time must be between 9:00 AM and 5:00 PM', +); +customExpect(element).toHaveAccessibleErrorMessage(/invalid time/i); +customExpect(element).toHaveAccessibleErrorMessage( + expect.stringContaining('Invalid time'), +); + +customExpect(element).toHaveAccessibleName('a label'); +customExpect(element).toHaveAccessibleName(/a label/); +customExpect(element).toHaveAccessibleName( + expect.stringContaining('partial label'), +); +customExpect(element).toHaveAccessibleName(); +customExpect(element).toHaveErrorMessage( + 'Invalid time: the time must be between 9:00 AM and 5:00 PM', +); +customExpect(element).toHaveErrorMessage(/invalid time/i); +customExpect(element).toHaveErrorMessage( + expect.stringContaining('Invalid time'), +); + +customExpect(element).toHaveRole('button'); + +// @ts-expect-error The types accidentally allowed any property by falling back to "any" +customExpect(element).nonExistentProperty(); diff --git a/packages/testing-library/lynx-dom-jest-matchers/types/__tests__/bun/bun-types.test.ts b/packages/testing-library/lynx-dom-jest-matchers/types/__tests__/bun/bun-types.test.ts new file mode 100644 index 0000000000..99959cc2af --- /dev/null +++ b/packages/testing-library/lynx-dom-jest-matchers/types/__tests__/bun/bun-types.test.ts @@ -0,0 +1,120 @@ +/** + * File that tests whether the TypeScript typings for @types/jest work as expected. + */ + +/* eslint-disable @typescript-eslint/no-unsafe-call */ +/* eslint-disable @typescript-eslint/no-floating-promises */ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ + +import { expect } from 'bun:test'; +import '../../bun'; + +const element: HTMLElement = document.body; + +expect(element).toBeInTheDOM(); +expect(element).toBeInTheDOM(document.body); +expect(element).toBeInTheDocument(); +expect(element).toBeVisible(); +expect(element).toBeEmpty(); +expect(element).toBeDisabled(); +expect(element).toBeEnabled(); +expect(element).toBeInvalid(); +expect(element).toBeRequired(); +expect(element).toBeValid(); +expect(element).toContainElement(document.body); +expect(element).toContainElement(null); +expect(element).toContainHTML('body'); +expect(element).toHaveAttribute('attr'); +expect(element).toHaveAttribute('attr', true); +expect(element).toHaveAttribute('attr', 'yes'); +expect(element).toHaveClass(); +expect(element).toHaveClass('cls1'); +expect(element).toHaveClass('cls1', 'cls2', 'cls3', 'cls4'); +expect(element).toHaveClass('cls1', { exact: true }); +expect(element).toHaveDisplayValue('str'); +expect(element).toHaveDisplayValue(['str1', 'str2']); +expect(element).toHaveDisplayValue(/str/); +expect(element).toHaveDisplayValue([/str1/, 'str2']); +expect(element).toHaveFocus(); +expect(element).toHaveFormValues({ foo: 'bar', baz: 1 }); +expect(element).toHaveStyle('display: block'); +expect(element).toHaveStyle({ display: 'block', width: 100 }); +expect(element).toHaveTextContent('Text'); +expect(element).toHaveTextContent(/Text/); +expect(element).toHaveTextContent('Text', { normalizeWhitespace: true }); +expect(element).toHaveTextContent(/Text/, { normalizeWhitespace: true }); +expect(element).toHaveValue(); +expect(element).toHaveValue('str'); +expect(element).toHaveValue(['str1', 'str2']); +expect(element).toHaveValue(1); +expect(element).toHaveValue(null); +expect(element).toBeChecked(); +expect(element).toHaveDescription('some description'); +expect(element).toHaveDescription(/some description/); +expect(element).toHaveDescription(expect.stringContaining('partial')); +expect(element).toHaveDescription(); +expect(element).toHaveAccessibleDescription('some description'); +expect(element).toHaveAccessibleDescription(/some description/); +expect(element).toHaveAccessibleDescription(expect.stringContaining('partial')); +expect(element).toHaveAccessibleDescription(); +expect(element).toHaveAccessibleName('a label'); +expect(element).toHaveAccessibleName(/a label/); +expect(element).toHaveAccessibleName(expect.stringContaining('partial label')); +expect(element).toHaveAccessibleName(); +expect(element).toHaveErrorMessage( + 'Invalid time: the time must be between 9:00 AM and 5:00 PM', +); +expect(element).toHaveErrorMessage(/invalid time/i); +expect(element).toHaveErrorMessage(expect.stringContaining('Invalid time')); +expect(element).toHaveRole('button'); + +expect(element).not.toBeInTheDOM(); +expect(element).not.toBeInTheDOM(document.body); +expect(element).not.toBeInTheDocument(); +expect(element).not.toBeVisible(); +expect(element).not.toBeEmpty(); +expect(element).not.toBeEmptyDOMElement(); +expect(element).not.toBeDisabled(); +expect(element).not.toBeEnabled(); +expect(element).not.toBeInvalid(); +expect(element).not.toBeRequired(); +expect(element).not.toBeValid(); +expect(element).not.toContainElement(document.body); +expect(element).not.toContainElement(null); +expect(element).not.toContainHTML('body'); +expect(element).not.toHaveAttribute('attr'); +expect(element).not.toHaveAttribute('attr', true); +expect(element).not.toHaveAttribute('attr', 'yes'); +expect(element).not.toHaveClass(); +expect(element).not.toHaveClass('cls1'); +expect(element).not.toHaveClass('cls1', 'cls2', 'cls3', 'cls4'); +expect(element).not.toHaveClass('cls1', { exact: true }); +expect(element).not.toHaveDisplayValue('str'); +expect(element).not.toHaveDisplayValue(['str1', 'str2']); +expect(element).not.toHaveDisplayValue(/str/); +expect(element).not.toHaveDisplayValue([/str1/, 'str2']); +expect(element).not.toHaveFocus(); +expect(element).not.toHaveFormValues({ foo: 'bar', baz: 1 }); +expect(element).not.toHaveStyle('display: block'); +expect(element).not.toHaveTextContent('Text'); +expect(element).not.toHaveTextContent(/Text/); +expect(element).not.toHaveTextContent('Text', { normalizeWhitespace: true }); +expect(element).not.toHaveTextContent(/Text/, { normalizeWhitespace: true }); +expect(element).not.toHaveValue(); +expect(element).not.toHaveValue('str'); +expect(element).not.toHaveValue(['str1', 'str2']); +expect(element).not.toHaveValue(1); +expect(element).not.toBeChecked(); +expect(element).not.toHaveDescription('some description'); +expect(element).not.toHaveDescription(); +expect(element).not.toHaveAccessibleDescription('some description'); +expect(element).not.toHaveAccessibleDescription(); +expect(element).not.toHaveAccessibleName('a label'); +expect(element).not.toHaveAccessibleName(); +expect(element).not.toBePartiallyChecked(); +expect(element).not.toHaveErrorMessage(); +expect(element).not.toHaveErrorMessage('Pikachu!'); +expect(element).not.toHaveRole('button'); + +// @ts-expect-error The types accidentally allowed any property by falling back to "any" +expect(element).nonExistentProperty(); diff --git a/packages/testing-library/lynx-dom-jest-matchers/types/__tests__/bun/tsconfig.json b/packages/testing-library/lynx-dom-jest-matchers/types/__tests__/bun/tsconfig.json new file mode 100644 index 0000000000..064fd12d16 --- /dev/null +++ b/packages/testing-library/lynx-dom-jest-matchers/types/__tests__/bun/tsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "types": ["bun", "web"], + }, + "include": ["*.ts"], +} diff --git a/packages/testing-library/lynx-dom-jest-matchers/types/__tests__/jest-globals/jest-globals-custom-expect-types.test.ts b/packages/testing-library/lynx-dom-jest-matchers/types/__tests__/jest-globals/jest-globals-custom-expect-types.test.ts new file mode 100644 index 0000000000..9cf302d23f --- /dev/null +++ b/packages/testing-library/lynx-dom-jest-matchers/types/__tests__/jest-globals/jest-globals-custom-expect-types.test.ts @@ -0,0 +1,98 @@ +/** + * File that tests whether the TypeScript typings work as expected. + */ + +/* eslint-disable @typescript-eslint/no-unsafe-call */ +/* eslint-disable @typescript-eslint/no-floating-promises */ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ + +import { expect } from '@jest/globals'; +import * as matchers from '../../matchers'; + +expect.extend(matchers); + +const element: HTMLElement = document.body; + +function customExpect( + _actual: HTMLElement, +): + | matchers.TestingLibraryMatchers + | matchers.TestingLibraryMatchers> +{ + throw new Error('Method not implemented.'); +} + +customExpect(element).toBeInTheDOM(); +customExpect(element).toBeInTheDOM(document.body); +customExpect(element).toBeInTheDocument(); +customExpect(element).toBeVisible(); +customExpect(element).toBeEmpty(); +customExpect(element).toBeDisabled(); +customExpect(element).toBeEnabled(); +customExpect(element).toBeInvalid(); +customExpect(element).toBeRequired(); +customExpect(element).toBeValid(); +customExpect(element).toContainElement(document.body); +customExpect(element).toContainElement(null); +customExpect(element).toContainHTML('body'); +customExpect(element).toHaveAttribute('attr'); +customExpect(element).toHaveAttribute('attr', true); +customExpect(element).toHaveAttribute('attr', 'yes'); +customExpect(element).toHaveClass(); +customExpect(element).toHaveClass('cls1'); +customExpect(element).toHaveClass('cls1', 'cls2', 'cls3', 'cls4'); +customExpect(element).toHaveClass('cls1', { exact: true }); +customExpect(element).toHaveDisplayValue('str'); +customExpect(element).toHaveDisplayValue(['str1', 'str2']); +customExpect(element).toHaveDisplayValue(/str/); +customExpect(element).toHaveDisplayValue([/str1/, 'str2']); +customExpect(element).toHaveFocus(); +customExpect(element).toHaveFormValues({ foo: 'bar', baz: 1 }); +customExpect(element).toHaveStyle('display: block'); +customExpect(element).toHaveStyle({ display: 'block', width: 100 }); +customExpect(element).toHaveTextContent('Text'); +customExpect(element).toHaveTextContent(/Text/); +customExpect(element).toHaveTextContent('Text', { normalizeWhitespace: true }); +customExpect(element).toHaveTextContent(/Text/, { normalizeWhitespace: true }); +customExpect(element).toHaveValue(); +customExpect(element).toHaveValue('str'); +customExpect(element).toHaveValue(['str1', 'str2']); +customExpect(element).toHaveValue(1); +customExpect(element).toHaveValue(null); +customExpect(element).toBeChecked(); +customExpect(element).toHaveDescription('some description'); +customExpect(element).toHaveDescription(/some description/); +customExpect(element).toHaveDescription(expect.stringContaining('partial')); +customExpect(element).toHaveDescription(); +customExpect(element).toHaveAccessibleDescription('some description'); +customExpect(element).toHaveAccessibleDescription(/some description/); +customExpect(element).toHaveAccessibleDescription( + expect.stringContaining('partial'), +); +customExpect(element).toHaveAccessibleDescription(); + +customExpect(element).toHaveAccessibleErrorMessage(); +customExpect(element).toHaveAccessibleErrorMessage( + 'Invalid time: the time must be between 9:00 AM and 5:00 PM', +); +customExpect(element).toHaveAccessibleErrorMessage(/invalid time/i); +customExpect(element).toHaveAccessibleErrorMessage( + expect.stringContaining('Invalid time'), +); + +customExpect(element).toHaveAccessibleName('a label'); +customExpect(element).toHaveAccessibleName(/a label/); +customExpect(element).toHaveAccessibleName( + expect.stringContaining('partial label'), +); +customExpect(element).toHaveAccessibleName(); +customExpect(element).toHaveErrorMessage( + 'Invalid time: the time must be between 9:00 AM and 5:00 PM', +); +customExpect(element).toHaveErrorMessage(/invalid time/i); +customExpect(element).toHaveErrorMessage( + expect.stringContaining('Invalid time'), +); + +// @ts-expect-error The types accidentally allowed any property by falling back to "any" +customExpect(element).nonExistentProperty(); diff --git a/packages/testing-library/lynx-dom-jest-matchers/types/__tests__/jest-globals/jest-globals-types.test.ts b/packages/testing-library/lynx-dom-jest-matchers/types/__tests__/jest-globals/jest-globals-types.test.ts new file mode 100644 index 0000000000..4f605858b2 --- /dev/null +++ b/packages/testing-library/lynx-dom-jest-matchers/types/__tests__/jest-globals/jest-globals-types.test.ts @@ -0,0 +1,120 @@ +/** + * File that tests whether the TypeScript typings for @types/jest work as expected. + */ + +/* eslint-disable @typescript-eslint/no-unsafe-call */ +/* eslint-disable @typescript-eslint/no-floating-promises */ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ + +import { expect } from '@jest/globals'; +import '../../jest-globals'; + +const element: HTMLElement = document.body; + +expect(element).toBeInTheDOM(); +expect(element).toBeInTheDOM(document.body); +expect(element).toBeInTheDocument(); +expect(element).toBeVisible(); +expect(element).toBeEmpty(); +expect(element).toBeDisabled(); +expect(element).toBeEnabled(); +expect(element).toBeInvalid(); +expect(element).toBeRequired(); +expect(element).toBeValid(); +expect(element).toContainElement(document.body); +expect(element).toContainElement(null); +expect(element).toContainHTML('body'); +expect(element).toHaveAttribute('attr'); +expect(element).toHaveAttribute('attr', true); +expect(element).toHaveAttribute('attr', 'yes'); +expect(element).toHaveClass(); +expect(element).toHaveClass('cls1'); +expect(element).toHaveClass('cls1', 'cls2', 'cls3', 'cls4'); +expect(element).toHaveClass('cls1', { exact: true }); +expect(element).toHaveDisplayValue('str'); +expect(element).toHaveDisplayValue(['str1', 'str2']); +expect(element).toHaveDisplayValue(/str/); +expect(element).toHaveDisplayValue([/str1/, 'str2']); +expect(element).toHaveFocus(); +expect(element).toHaveFormValues({ foo: 'bar', baz: 1 }); +expect(element).toHaveStyle('display: block'); +expect(element).toHaveStyle({ display: 'block', width: 100 }); +expect(element).toHaveTextContent('Text'); +expect(element).toHaveTextContent(/Text/); +expect(element).toHaveTextContent('Text', { normalizeWhitespace: true }); +expect(element).toHaveTextContent(/Text/, { normalizeWhitespace: true }); +expect(element).toHaveValue(); +expect(element).toHaveValue('str'); +expect(element).toHaveValue(['str1', 'str2']); +expect(element).toHaveValue(1); +expect(element).toHaveValue(null); +expect(element).toBeChecked(); +expect(element).toHaveDescription('some description'); +expect(element).toHaveDescription(/some description/); +expect(element).toHaveDescription(expect.stringContaining('partial')); +expect(element).toHaveDescription(); +expect(element).toHaveAccessibleDescription('some description'); +expect(element).toHaveAccessibleDescription(/some description/); +expect(element).toHaveAccessibleDescription(expect.stringContaining('partial')); +expect(element).toHaveAccessibleDescription(); +expect(element).toHaveAccessibleName('a label'); +expect(element).toHaveAccessibleName(/a label/); +expect(element).toHaveAccessibleName(expect.stringContaining('partial label')); +expect(element).toHaveAccessibleName(); +expect(element).toHaveErrorMessage( + 'Invalid time: the time must be between 9:00 AM and 5:00 PM', +); +expect(element).toHaveErrorMessage(/invalid time/i); +expect(element).toHaveErrorMessage(expect.stringContaining('Invalid time')); +expect(element).toHaveRole('button'); + +expect(element).not.toBeInTheDOM(); +expect(element).not.toBeInTheDOM(document.body); +expect(element).not.toBeInTheDocument(); +expect(element).not.toBeVisible(); +expect(element).not.toBeEmpty(); +expect(element).not.toBeEmptyDOMElement(); +expect(element).not.toBeDisabled(); +expect(element).not.toBeEnabled(); +expect(element).not.toBeInvalid(); +expect(element).not.toBeRequired(); +expect(element).not.toBeValid(); +expect(element).not.toContainElement(document.body); +expect(element).not.toContainElement(null); +expect(element).not.toContainHTML('body'); +expect(element).not.toHaveAttribute('attr'); +expect(element).not.toHaveAttribute('attr', true); +expect(element).not.toHaveAttribute('attr', 'yes'); +expect(element).not.toHaveClass(); +expect(element).not.toHaveClass('cls1'); +expect(element).not.toHaveClass('cls1', 'cls2', 'cls3', 'cls4'); +expect(element).not.toHaveClass('cls1', { exact: true }); +expect(element).not.toHaveDisplayValue('str'); +expect(element).not.toHaveDisplayValue(['str1', 'str2']); +expect(element).not.toHaveDisplayValue(/str/); +expect(element).not.toHaveDisplayValue([/str1/, 'str2']); +expect(element).not.toHaveFocus(); +expect(element).not.toHaveFormValues({ foo: 'bar', baz: 1 }); +expect(element).not.toHaveStyle('display: block'); +expect(element).not.toHaveTextContent('Text'); +expect(element).not.toHaveTextContent(/Text/); +expect(element).not.toHaveTextContent('Text', { normalizeWhitespace: true }); +expect(element).not.toHaveTextContent(/Text/, { normalizeWhitespace: true }); +expect(element).not.toHaveValue(); +expect(element).not.toHaveValue('str'); +expect(element).not.toHaveValue(['str1', 'str2']); +expect(element).not.toHaveValue(1); +expect(element).not.toBeChecked(); +expect(element).not.toHaveDescription('some description'); +expect(element).not.toHaveDescription(); +expect(element).not.toHaveAccessibleDescription('some description'); +expect(element).not.toHaveAccessibleDescription(); +expect(element).not.toHaveAccessibleName('a label'); +expect(element).not.toHaveAccessibleName(); +expect(element).not.toBePartiallyChecked(); +expect(element).not.toHaveErrorMessage(); +expect(element).not.toHaveErrorMessage('Pikachu!'); +expect(element).not.toHaveRole('button'); + +// @ts-expect-error The types accidentally allowed any property by falling back to "any" +expect(element).nonExistentProperty(); diff --git a/packages/testing-library/lynx-dom-jest-matchers/types/__tests__/jest-globals/tsconfig.json b/packages/testing-library/lynx-dom-jest-matchers/types/__tests__/jest-globals/tsconfig.json new file mode 100644 index 0000000000..df19e76436 --- /dev/null +++ b/packages/testing-library/lynx-dom-jest-matchers/types/__tests__/jest-globals/tsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "types": [], + }, + "include": ["*.ts"], +} diff --git a/packages/testing-library/lynx-dom-jest-matchers/types/__tests__/jest/jest-custom-expect-types.test.ts b/packages/testing-library/lynx-dom-jest-matchers/types/__tests__/jest/jest-custom-expect-types.test.ts new file mode 100644 index 0000000000..056ed80527 --- /dev/null +++ b/packages/testing-library/lynx-dom-jest-matchers/types/__tests__/jest/jest-custom-expect-types.test.ts @@ -0,0 +1,101 @@ +/** + * File that tests whether the TypeScript typings work as expected. + */ + +/* eslint-disable @typescript-eslint/no-unsafe-call */ +/* eslint-disable @typescript-eslint/no-floating-promises */ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ + +import * as matchers from '../../matchers'; + +expect.extend(matchers); + +const element: HTMLElement = document.body; + +function customExpect( + _actual: HTMLElement, +): + | matchers.TestingLibraryMatchers + | matchers.TestingLibraryMatchers> +{ + throw new Error('Method not implemented.'); +} + +customExpect(element).toBeInTheDOM(); +customExpect(element).toBeInTheDOM(document.body); +customExpect(element).toBeInTheDocument(); +customExpect(element).toBeVisible(); +customExpect(element).toBeEmpty(); +customExpect(element).toBeDisabled(); +customExpect(element).toBeEnabled(); +customExpect(element).toBeInvalid(); +customExpect(element).toBeRequired(); +customExpect(element).toBeValid(); +customExpect(element).toContainElement(document.body); +customExpect(element).toContainElement(null); +customExpect(element).toContainHTML('body'); +customExpect(element).toHaveAttribute('attr'); +customExpect(element).toHaveAttribute('attr', true); +customExpect(element).toHaveAttribute('attr', 'yes'); +customExpect(element).toHaveClass(); +customExpect(element).toHaveClass('cls1'); +customExpect(element).toHaveClass(/cls/); +customExpect(element).toHaveClass('cls1', 'cls2', /cls(3|4)/); +customExpect(element).toHaveClass('cls1', { exact: true }); +customExpect(element).toHaveDisplayValue('str'); +customExpect(element).toHaveDisplayValue(['str1', 'str2']); +customExpect(element).toHaveDisplayValue(/str/); +customExpect(element).toHaveDisplayValue([/str1/, 'str2']); +customExpect(element).toHaveFocus(); +customExpect(element).toHaveFormValues({ foo: 'bar', baz: 1 }); +customExpect(element).toHaveStyle('display: block'); +customExpect(element).toHaveStyle({ display: 'block', width: 100 }); +customExpect(element).toHaveTextContent('Text'); +customExpect(element).toHaveTextContent(/Text/); +customExpect(element).toHaveTextContent('Text', { normalizeWhitespace: true }); +customExpect(element).toHaveTextContent(/Text/, { normalizeWhitespace: true }); +customExpect(element).toHaveValue(); +customExpect(element).toHaveValue('str'); +customExpect(element).toHaveValue(['str1', 'str2']); +customExpect(element).toHaveValue(1); +customExpect(element).toHaveValue(null); +customExpect(element).toBeChecked(); +customExpect(element).toHaveDescription('some description'); +customExpect(element).toHaveDescription(/some description/); +customExpect(element).toHaveDescription(expect.stringContaining('partial')); +customExpect(element).toHaveDescription(); +customExpect(element).toHaveAccessibleDescription('some description'); +customExpect(element).toHaveAccessibleDescription(/some description/); +customExpect(element).toHaveAccessibleDescription( + expect.stringContaining('partial'), +); +customExpect(element).toHaveAccessibleDescription(); + +customExpect(element).toHaveAccessibleErrorMessage(); +customExpect(element).toHaveAccessibleErrorMessage( + 'Invalid time: the time must be between 9:00 AM and 5:00 PM', +); +customExpect(element).toHaveAccessibleErrorMessage(/invalid time/i); +customExpect(element).toHaveAccessibleErrorMessage( + expect.stringContaining('Invalid time'), +); + +customExpect(element).toHaveAccessibleName('a label'); +customExpect(element).toHaveAccessibleName(/a label/); +customExpect(element).toHaveAccessibleName( + expect.stringContaining('partial label'), +); +customExpect(element).toHaveAccessibleName(); +customExpect(element).toHaveErrorMessage( + 'Invalid time: the time must be between 9:00 AM and 5:00 PM', +); +customExpect(element).toHaveErrorMessage(/invalid time/i); +customExpect(element).toHaveErrorMessage( + expect.stringContaining('Invalid time'), +); + +// @ts-expect-error The types accidentally allowed any property by falling back to "any" +customExpect(element).nonExistentProperty(); + +// @ts-expect-error +customExpect(element).toHaveClass(/cls/, { exact: true }); diff --git a/packages/testing-library/lynx-dom-jest-matchers/types/__tests__/jest/jest-types.test.ts b/packages/testing-library/lynx-dom-jest-matchers/types/__tests__/jest/jest-types.test.ts new file mode 100644 index 0000000000..e029de1f72 --- /dev/null +++ b/packages/testing-library/lynx-dom-jest-matchers/types/__tests__/jest/jest-types.test.ts @@ -0,0 +1,119 @@ +/** + * File that tests whether the TypeScript typings for @types/jest work as expected. + */ + +/* eslint-disable @typescript-eslint/no-unsafe-call */ +/* eslint-disable @typescript-eslint/no-floating-promises */ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ + +import '../../jest'; + +const element: HTMLElement = document.body; + +expect(element).toBeInTheDOM(); +expect(element).toBeInTheDOM(document.body); +expect(element).toBeInTheDocument(); +expect(element).toBeVisible(); +expect(element).toBeEmpty(); +expect(element).toBeDisabled(); +expect(element).toBeEnabled(); +expect(element).toBeInvalid(); +expect(element).toBeRequired(); +expect(element).toBeValid(); +expect(element).toContainElement(document.body); +expect(element).toContainElement(null); +expect(element).toContainHTML('body'); +expect(element).toHaveAttribute('attr'); +expect(element).toHaveAttribute('attr', true); +expect(element).toHaveAttribute('attr', 'yes'); +expect(element).toHaveClass(); +expect(element).toHaveClass('cls1'); +expect(element).toHaveClass('cls1', 'cls2', 'cls3', 'cls4'); +expect(element).toHaveClass('cls1', { exact: true }); +expect(element).toHaveDisplayValue('str'); +expect(element).toHaveDisplayValue(['str1', 'str2']); +expect(element).toHaveDisplayValue(/str/); +expect(element).toHaveDisplayValue([/str1/, 'str2']); +expect(element).toHaveFocus(); +expect(element).toHaveFormValues({ foo: 'bar', baz: 1 }); +expect(element).toHaveStyle('display: block'); +expect(element).toHaveStyle({ display: 'block', width: 100 }); +expect(element).toHaveTextContent('Text'); +expect(element).toHaveTextContent(/Text/); +expect(element).toHaveTextContent('Text', { normalizeWhitespace: true }); +expect(element).toHaveTextContent(/Text/, { normalizeWhitespace: true }); +expect(element).toHaveValue(); +expect(element).toHaveValue('str'); +expect(element).toHaveValue(['str1', 'str2']); +expect(element).toHaveValue(1); +expect(element).toHaveValue(null); +expect(element).toBeChecked(); +expect(element).toHaveDescription('some description'); +expect(element).toHaveDescription(/some description/); +expect(element).toHaveDescription(expect.stringContaining('partial')); +expect(element).toHaveDescription(); +expect(element).toHaveAccessibleDescription('some description'); +expect(element).toHaveAccessibleDescription(/some description/); +expect(element).toHaveAccessibleDescription(expect.stringContaining('partial')); +expect(element).toHaveAccessibleDescription(); +expect(element).toHaveAccessibleName('a label'); +expect(element).toHaveAccessibleName(/a label/); +expect(element).toHaveAccessibleName(expect.stringContaining('partial label')); +expect(element).toHaveAccessibleName(); +expect(element).toHaveErrorMessage( + 'Invalid time: the time must be between 9:00 AM and 5:00 PM', +); +expect(element).toHaveErrorMessage(/invalid time/i); +expect(element).toHaveErrorMessage(expect.stringContaining('Invalid time')); +expect(element).toHaveRole('button'); + +expect(element).not.toBeInTheDOM(); +expect(element).not.toBeInTheDOM(document.body); +expect(element).not.toBeInTheDocument(); +expect(element).not.toBeVisible(); +expect(element).not.toBeEmpty(); +expect(element).not.toBeEmptyDOMElement(); +expect(element).not.toBeDisabled(); +expect(element).not.toBeEnabled(); +expect(element).not.toBeInvalid(); +expect(element).not.toBeRequired(); +expect(element).not.toBeValid(); +expect(element).not.toContainElement(document.body); +expect(element).not.toContainElement(null); +expect(element).not.toContainHTML('body'); +expect(element).not.toHaveAttribute('attr'); +expect(element).not.toHaveAttribute('attr', true); +expect(element).not.toHaveAttribute('attr', 'yes'); +expect(element).not.toHaveClass(); +expect(element).not.toHaveClass('cls1'); +expect(element).not.toHaveClass('cls1', 'cls2', 'cls3', 'cls4'); +expect(element).not.toHaveClass('cls1', { exact: true }); +expect(element).not.toHaveDisplayValue('str'); +expect(element).not.toHaveDisplayValue(['str1', 'str2']); +expect(element).not.toHaveDisplayValue(/str/); +expect(element).not.toHaveDisplayValue([/str1/, 'str2']); +expect(element).not.toHaveFocus(); +expect(element).not.toHaveFormValues({ foo: 'bar', baz: 1 }); +expect(element).not.toHaveStyle('display: block'); +expect(element).not.toHaveTextContent('Text'); +expect(element).not.toHaveTextContent(/Text/); +expect(element).not.toHaveTextContent('Text', { normalizeWhitespace: true }); +expect(element).not.toHaveTextContent(/Text/, { normalizeWhitespace: true }); +expect(element).not.toHaveValue(); +expect(element).not.toHaveValue('str'); +expect(element).not.toHaveValue(['str1', 'str2']); +expect(element).not.toHaveValue(1); +expect(element).not.toBeChecked(); +expect(element).not.toHaveDescription('some description'); +expect(element).not.toHaveDescription(); +expect(element).not.toHaveAccessibleDescription('some description'); +expect(element).not.toHaveAccessibleDescription(); +expect(element).not.toHaveAccessibleName('a label'); +expect(element).not.toHaveAccessibleName(); +expect(element).not.toBePartiallyChecked(); +expect(element).not.toHaveErrorMessage(); +expect(element).not.toHaveErrorMessage('Pikachu!'); +expect(element).not.toHaveRole('button'); + +// @ts-expect-error The types accidentally allowed any property by falling back to "any" +expect(element).nonExistentProperty(); diff --git a/packages/testing-library/lynx-dom-jest-matchers/types/__tests__/jest/tsconfig.json b/packages/testing-library/lynx-dom-jest-matchers/types/__tests__/jest/tsconfig.json new file mode 100644 index 0000000000..84f8557bf5 --- /dev/null +++ b/packages/testing-library/lynx-dom-jest-matchers/types/__tests__/jest/tsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "types": ["jest"], + }, + "include": ["*.ts"], +} diff --git a/packages/testing-library/lynx-dom-jest-matchers/types/__tests__/vitest/tsconfig.json b/packages/testing-library/lynx-dom-jest-matchers/types/__tests__/vitest/tsconfig.json new file mode 100644 index 0000000000..df19e76436 --- /dev/null +++ b/packages/testing-library/lynx-dom-jest-matchers/types/__tests__/vitest/tsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "types": [], + }, + "include": ["*.ts"], +} diff --git a/packages/testing-library/lynx-dom-jest-matchers/types/__tests__/vitest/vitest-custom-expect-types.test.ts b/packages/testing-library/lynx-dom-jest-matchers/types/__tests__/vitest/vitest-custom-expect-types.test.ts new file mode 100644 index 0000000000..9bcbc79697 --- /dev/null +++ b/packages/testing-library/lynx-dom-jest-matchers/types/__tests__/vitest/vitest-custom-expect-types.test.ts @@ -0,0 +1,98 @@ +/** + * File that tests whether the TypeScript typings work as expected. + */ + +/* eslint-disable @typescript-eslint/no-unsafe-call */ +/* eslint-disable @typescript-eslint/no-floating-promises */ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ + +import { expect } from 'vitest'; +import * as matchers from '../../matchers'; + +expect.extend(matchers); + +const element: HTMLElement = document.body; + +function customExpect( + _actual: HTMLElement, +): + | matchers.TestingLibraryMatchers + | matchers.TestingLibraryMatchers> +{ + throw new Error('Method not implemented.'); +} + +customExpect(element).toBeInTheDOM(); +customExpect(element).toBeInTheDOM(document.body); +customExpect(element).toBeInTheDocument(); +customExpect(element).toBeVisible(); +customExpect(element).toBeEmpty(); +customExpect(element).toBeDisabled(); +customExpect(element).toBeEnabled(); +customExpect(element).toBeInvalid(); +customExpect(element).toBeRequired(); +customExpect(element).toBeValid(); +customExpect(element).toContainElement(document.body); +customExpect(element).toContainElement(null); +customExpect(element).toContainHTML('body'); +customExpect(element).toHaveAttribute('attr'); +customExpect(element).toHaveAttribute('attr', true); +customExpect(element).toHaveAttribute('attr', 'yes'); +customExpect(element).toHaveClass(); +customExpect(element).toHaveClass('cls1'); +customExpect(element).toHaveClass('cls1', 'cls2', 'cls3', 'cls4'); +customExpect(element).toHaveClass('cls1', { exact: true }); +customExpect(element).toHaveDisplayValue('str'); +customExpect(element).toHaveDisplayValue(['str1', 'str2']); +customExpect(element).toHaveDisplayValue(/str/); +customExpect(element).toHaveDisplayValue([/str1/, 'str2']); +customExpect(element).toHaveFocus(); +customExpect(element).toHaveFormValues({ foo: 'bar', baz: 1 }); +customExpect(element).toHaveStyle('display: block'); +customExpect(element).toHaveStyle({ display: 'block', width: 100 }); +customExpect(element).toHaveTextContent('Text'); +customExpect(element).toHaveTextContent(/Text/); +customExpect(element).toHaveTextContent('Text', { normalizeWhitespace: true }); +customExpect(element).toHaveTextContent(/Text/, { normalizeWhitespace: true }); +customExpect(element).toHaveValue(); +customExpect(element).toHaveValue('str'); +customExpect(element).toHaveValue(['str1', 'str2']); +customExpect(element).toHaveValue(1); +customExpect(element).toHaveValue(null); +customExpect(element).toBeChecked(); +customExpect(element).toHaveDescription('some description'); +customExpect(element).toHaveDescription(/some description/); +customExpect(element).toHaveDescription(expect.stringContaining('partial')); +customExpect(element).toHaveDescription(); +customExpect(element).toHaveAccessibleDescription('some description'); +customExpect(element).toHaveAccessibleDescription(/some description/); +customExpect(element).toHaveAccessibleDescription( + expect.stringContaining('partial'), +); +customExpect(element).toHaveAccessibleDescription(); + +customExpect(element).toHaveAccessibleErrorMessage(); +customExpect(element).toHaveAccessibleErrorMessage( + 'Invalid time: the time must be between 9:00 AM and 5:00 PM', +); +customExpect(element).toHaveAccessibleErrorMessage(/invalid time/i); +customExpect(element).toHaveAccessibleErrorMessage( + expect.stringContaining('Invalid time'), +); + +customExpect(element).toHaveAccessibleName('a label'); +customExpect(element).toHaveAccessibleName(/a label/); +customExpect(element).toHaveAccessibleName( + expect.stringContaining('partial label'), +); +customExpect(element).toHaveAccessibleName(); +customExpect(element).toHaveErrorMessage( + 'Invalid time: the time must be between 9:00 AM and 5:00 PM', +); +customExpect(element).toHaveErrorMessage(/invalid time/i); +customExpect(element).toHaveErrorMessage( + expect.stringContaining('Invalid time'), +); + +// @ts-expect-error The types accidentally allowed any property by falling back to "any" +customExpect(element).nonExistentProperty(); diff --git a/packages/testing-library/lynx-dom-jest-matchers/types/__tests__/vitest/vitest-types.test.ts b/packages/testing-library/lynx-dom-jest-matchers/types/__tests__/vitest/vitest-types.test.ts new file mode 100644 index 0000000000..e6acc2bdf6 --- /dev/null +++ b/packages/testing-library/lynx-dom-jest-matchers/types/__tests__/vitest/vitest-types.test.ts @@ -0,0 +1,120 @@ +/** + * File that tests whether the TypeScript typings for @types/jest work as expected. + */ + +/* eslint-disable @typescript-eslint/no-unsafe-call */ +/* eslint-disable @typescript-eslint/no-floating-promises */ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ + +import { expect } from 'vitest'; +import '../../vitest'; + +const element: HTMLElement = document.body; + +expect(element).toBeInTheDOM(); +expect(element).toBeInTheDOM(document.body); +expect(element).toBeInTheDocument(); +expect(element).toBeVisible(); +expect(element).toBeEmpty(); +expect(element).toBeDisabled(); +expect(element).toBeEnabled(); +expect(element).toBeInvalid(); +expect(element).toBeRequired(); +expect(element).toBeValid(); +expect(element).toContainElement(document.body); +expect(element).toContainElement(null); +expect(element).toContainHTML('body'); +expect(element).toHaveAttribute('attr'); +expect(element).toHaveAttribute('attr', true); +expect(element).toHaveAttribute('attr', 'yes'); +expect(element).toHaveClass(); +expect(element).toHaveClass('cls1'); +expect(element).toHaveClass('cls1', 'cls2', 'cls3', 'cls4'); +expect(element).toHaveClass('cls1', { exact: true }); +expect(element).toHaveDisplayValue('str'); +expect(element).toHaveDisplayValue(['str1', 'str2']); +expect(element).toHaveDisplayValue(/str/); +expect(element).toHaveDisplayValue([/str1/, 'str2']); +expect(element).toHaveFocus(); +expect(element).toHaveFormValues({ foo: 'bar', baz: 1 }); +expect(element).toHaveStyle('display: block'); +expect(element).toHaveStyle({ display: 'block', width: 100 }); +expect(element).toHaveTextContent('Text'); +expect(element).toHaveTextContent(/Text/); +expect(element).toHaveTextContent('Text', { normalizeWhitespace: true }); +expect(element).toHaveTextContent(/Text/, { normalizeWhitespace: true }); +expect(element).toHaveValue(); +expect(element).toHaveValue('str'); +expect(element).toHaveValue(['str1', 'str2']); +expect(element).toHaveValue(1); +expect(element).toHaveValue(null); +expect(element).toBeChecked(); +expect(element).toHaveDescription('some description'); +expect(element).toHaveDescription(/some description/); +expect(element).toHaveDescription(expect.stringContaining('partial')); +expect(element).toHaveDescription(); +expect(element).toHaveAccessibleDescription('some description'); +expect(element).toHaveAccessibleDescription(/some description/); +expect(element).toHaveAccessibleDescription(expect.stringContaining('partial')); +expect(element).toHaveAccessibleDescription(); +expect(element).toHaveAccessibleName('a label'); +expect(element).toHaveAccessibleName(/a label/); +expect(element).toHaveAccessibleName(expect.stringContaining('partial label')); +expect(element).toHaveAccessibleName(); +expect(element).toHaveErrorMessage( + 'Invalid time: the time must be between 9:00 AM and 5:00 PM', +); +expect(element).toHaveErrorMessage(/invalid time/i); +expect(element).toHaveErrorMessage(expect.stringContaining('Invalid time')); +expect(element).toHaveRole('button'); + +expect(element).not.toBeInTheDOM(); +expect(element).not.toBeInTheDOM(document.body); +expect(element).not.toBeInTheDocument(); +expect(element).not.toBeVisible(); +expect(element).not.toBeEmpty(); +expect(element).not.toBeEmptyDOMElement(); +expect(element).not.toBeDisabled(); +expect(element).not.toBeEnabled(); +expect(element).not.toBeInvalid(); +expect(element).not.toBeRequired(); +expect(element).not.toBeValid(); +expect(element).not.toContainElement(document.body); +expect(element).not.toContainElement(null); +expect(element).not.toContainHTML('body'); +expect(element).not.toHaveAttribute('attr'); +expect(element).not.toHaveAttribute('attr', true); +expect(element).not.toHaveAttribute('attr', 'yes'); +expect(element).not.toHaveClass(); +expect(element).not.toHaveClass('cls1'); +expect(element).not.toHaveClass('cls1', 'cls2', 'cls3', 'cls4'); +expect(element).not.toHaveClass('cls1', { exact: true }); +expect(element).not.toHaveDisplayValue('str'); +expect(element).not.toHaveDisplayValue(['str1', 'str2']); +expect(element).not.toHaveDisplayValue(/str/); +expect(element).not.toHaveDisplayValue([/str1/, 'str2']); +expect(element).not.toHaveFocus(); +expect(element).not.toHaveFormValues({ foo: 'bar', baz: 1 }); +expect(element).not.toHaveStyle('display: block'); +expect(element).not.toHaveTextContent('Text'); +expect(element).not.toHaveTextContent(/Text/); +expect(element).not.toHaveTextContent('Text', { normalizeWhitespace: true }); +expect(element).not.toHaveTextContent(/Text/, { normalizeWhitespace: true }); +expect(element).not.toHaveValue(); +expect(element).not.toHaveValue('str'); +expect(element).not.toHaveValue(['str1', 'str2']); +expect(element).not.toHaveValue(1); +expect(element).not.toBeChecked(); +expect(element).not.toHaveDescription('some description'); +expect(element).not.toHaveDescription(); +expect(element).not.toHaveAccessibleDescription('some description'); +expect(element).not.toHaveAccessibleDescription(); +expect(element).not.toHaveAccessibleName('a label'); +expect(element).not.toHaveAccessibleName(); +expect(element).not.toBePartiallyChecked(); +expect(element).not.toHaveErrorMessage(); +expect(element).not.toHaveErrorMessage('Pikachu!'); +expect(element).not.toHaveRole('button'); + +// @ts-expect-error The types accidentally allowed any property by falling back to "any" +expect(element).nonExistentProperty(); diff --git a/packages/testing-library/lynx-dom-jest-matchers/types/bun.d.ts b/packages/testing-library/lynx-dom-jest-matchers/types/bun.d.ts new file mode 100644 index 0000000000..d9ffe4c82b --- /dev/null +++ b/packages/testing-library/lynx-dom-jest-matchers/types/bun.d.ts @@ -0,0 +1,12 @@ +import { type expect } from 'bun:test'; +import { type TestingLibraryMatchers } from './matchers'; + +export {}; +declare module 'bun:test' { + interface Matchers extends + TestingLibraryMatchers< + ReturnType, + T + > + {} +} diff --git a/packages/testing-library/lynx-dom-jest-matchers/types/index.d.ts b/packages/testing-library/lynx-dom-jest-matchers/types/index.d.ts new file mode 100644 index 0000000000..cebdd1afd7 --- /dev/null +++ b/packages/testing-library/lynx-dom-jest-matchers/types/index.d.ts @@ -0,0 +1 @@ +/// diff --git a/packages/testing-library/lynx-dom-jest-matchers/types/jest-globals.d.ts b/packages/testing-library/lynx-dom-jest-matchers/types/jest-globals.d.ts new file mode 100644 index 0000000000..df908e1cc8 --- /dev/null +++ b/packages/testing-library/lynx-dom-jest-matchers/types/jest-globals.d.ts @@ -0,0 +1,13 @@ +import { type expect } from '@jest/globals'; +import { type TestingLibraryMatchers } from './matchers'; + +export {}; +declare module '@jest/expect' { + export interface Matchers> + extends + TestingLibraryMatchers< + ReturnType, + R + > + {} +} diff --git a/packages/testing-library/lynx-dom-jest-matchers/types/jest.d.ts b/packages/testing-library/lynx-dom-jest-matchers/types/jest.d.ts new file mode 100644 index 0000000000..05af24e453 --- /dev/null +++ b/packages/testing-library/lynx-dom-jest-matchers/types/jest.d.ts @@ -0,0 +1,14 @@ +/// + +import { type TestingLibraryMatchers } from './matchers'; + +declare global { + namespace jest { + interface Matchers extends + TestingLibraryMatchers< + ReturnType, + R + > + {} + } +} diff --git a/packages/testing-library/lynx-dom-jest-matchers/types/matchers-standalone.d.ts b/packages/testing-library/lynx-dom-jest-matchers/types/matchers-standalone.d.ts new file mode 100644 index 0000000000..4b3c88893b --- /dev/null +++ b/packages/testing-library/lynx-dom-jest-matchers/types/matchers-standalone.d.ts @@ -0,0 +1,33 @@ +import { type TestingLibraryMatchers as _TLM } from './matchers'; + +interface MatcherReturnType { + pass: boolean; + message: () => string; +} + +interface OverloadedMatchers { + toHaveClass(expected: any, ...rest: string[]): MatcherReturnType; + toHaveClass( + expected: any, + className: string, + options?: { exact: boolean }, + ): MatcherReturnType; +} + +declare namespace matchersStandalone { + type MatchersStandalone = + & { + [T in keyof _TLM]: ( + expected: any, + ...rest: Parameters<_TLM[T]> + ) => MatcherReturnType; + } + & OverloadedMatchers; + + type TestingLibraryMatchers = _TLM; +} + +declare const matchersStandalone: + & matchersStandalone.MatchersStandalone + & Record; +export = matchersStandalone; diff --git a/packages/testing-library/lynx-dom-jest-matchers/types/matchers.d.ts b/packages/testing-library/lynx-dom-jest-matchers/types/matchers.d.ts new file mode 100755 index 0000000000..b6f56572a5 --- /dev/null +++ b/packages/testing-library/lynx-dom-jest-matchers/types/matchers.d.ts @@ -0,0 +1,771 @@ +import { type ARIARole } from 'aria-query'; + +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 nonExistantElement = getByTestId('does-not-exist') + * expect(ancestor).toContainElement(descendant) + * expect(descendant).not.toContainElement(ancestor) + * expect(ancestor).not.toContainElement(nonExistantElement) + * @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: Array): R; + toHaveClass(classNames: 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-insentive 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 ``, ` + *

prev

+ *

text selected text

+ *

next

+ *
+ * + * getByTestId('text').setSelectionRange(5, 13) + * expect(getByTestId('text')).toHaveSelection('selected') + * + * getByTestId('textarea').setSelectionRange(0, 5) + * expect('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')) + * expect(getByTestId('child')).toHaveSelection('selected') + * expect(getByTestId('parent')).toHaveSelection('selected') + * + * // selection that applies from prev all, parent text before child, and part child. + * range.setStart(getByTestId('prev'), 0) + * range.setEnd(getByTestId('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() + * + * // selection that applies from part child, parent text after child and part next. + * range.setStart(getByTestId('child').childNodes[0], 3) + * range.setEnd(getByTestId('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') + * + * @see + * [testing-library/jest-dom#tohaveselection](https://github.com/testing-library/jest-dom#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; +export = matchers; diff --git a/packages/testing-library/lynx-dom-jest-matchers/types/vitest.d.ts b/packages/testing-library/lynx-dom-jest-matchers/types/vitest.d.ts new file mode 100644 index 0000000000..798f26712d --- /dev/null +++ b/packages/testing-library/lynx-dom-jest-matchers/types/vitest.d.ts @@ -0,0 +1,17 @@ +import 'vitest'; +import { type TestingLibraryMatchers } from './matchers'; + +declare module 'vitest' { + interface Assertion extends + TestingLibraryMatchers< + any, + T + > + {} + interface AsymmetricMatchersContaining extends + TestingLibraryMatchers< + any, + any + > + {} +} diff --git a/packages/testing-library/lynx-dom-jest-matchers/vitest.d.ts b/packages/testing-library/lynx-dom-jest-matchers/vitest.d.ts new file mode 100644 index 0000000000..1b17a0d4ab --- /dev/null +++ b/packages/testing-library/lynx-dom-jest-matchers/vitest.d.ts @@ -0,0 +1 @@ +/// diff --git a/packages/testing-library/lynx-dom-jest-matchers/vitest.js b/packages/testing-library/lynx-dom-jest-matchers/vitest.js new file mode 100644 index 0000000000..7b4bcc6dd9 --- /dev/null +++ b/packages/testing-library/lynx-dom-jest-matchers/vitest.js @@ -0,0 +1,4 @@ +import { expect } from 'vitest'; +import * as extensions from './dist/matchers'; + +expect.extend(extensions); diff --git a/packages/testing-library/lynx-dom-testing-library/LICENSE b/packages/testing-library/lynx-dom-testing-library/LICENSE new file mode 100644 index 0000000000..4c43675bc4 --- /dev/null +++ b/packages/testing-library/lynx-dom-testing-library/LICENSE @@ -0,0 +1,20 @@ +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. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/testing-library/lynx-dom-testing-library/README.md b/packages/testing-library/lynx-dom-testing-library/README.md new file mode 100644 index 0000000000..efa735f3ea --- /dev/null +++ b/packages/testing-library/lynx-dom-testing-library/README.md @@ -0,0 +1,4 @@ +# @lynx-js/lynx-dom-testing-library + +Lynx equivalent of +[dom-testing-library](https://github.com/testing-library/dom-testing-library) diff --git a/packages/testing-library/lynx-dom-testing-library/jest.config.js b/packages/testing-library/lynx-dom-testing-library/jest.config.js new file mode 100644 index 0000000000..5906e1bbd9 --- /dev/null +++ b/packages/testing-library/lynx-dom-testing-library/jest.config.js @@ -0,0 +1,24 @@ +const { + collectCoverageFrom, + coveragePathIgnorePatterns, + coverageThreshold, + watchPlugins, +} = require('kcd-scripts/jest'); + +module.exports = { + collectCoverageFrom, + coveragePathIgnorePatterns: [ + ...coveragePathIgnorePatterns, + '/__tests__/', + '/__node_tests__/', + ], + coverageThreshold, + watchPlugins: [ + ...watchPlugins, + require.resolve('jest-watch-select-projects'), + ], + projects: [ + require.resolve('./tests/jest.config.dom.js'), + require.resolve('./tests/jest.config.node.js'), + ], +}; diff --git a/packages/testing-library/lynx-dom-testing-library/package.json b/packages/testing-library/lynx-dom-testing-library/package.json new file mode 100644 index 0000000000..832d215463 --- /dev/null +++ b/packages/testing-library/lynx-dom-testing-library/package.json @@ -0,0 +1,60 @@ +{ + "name": "@lynx-js/lynx-dom-testing-library", + "version": "0.0.0", + "description": "Simple and complete DOM testing utilities that encourage good testing practices.", + "keywords": [ + "testing", + "ui", + "dom", + "jsdom", + "unit", + "integration", + "functional", + "end-to-end", + "e2e" + ], + "homepage": "https://github.com/testing-library/dom-testing-library#readme", + "bugs": { + "url": "https://github.com/testing-library/dom-testing-library/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/testing-library/dom-testing-library" + }, + "license": "MIT", + "author": "Kent C. Dodds (https://kentcdodds.com)", + "main": "dist/index.js", + "umd:main": "dist/@testing-library/dom.umd.js", + "module": "dist/@testing-library/dom.esm.js", + "source": "src/index.js", + "types": "types/index.d.ts", + "files": [ + "dist", + "types/*.d.ts" + ], + "scripts": { + "build": "rslib build", + "dev": "rslib build --watch" + }, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "chalk": "^4.1.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "pretty-format": "^27.0.2" + }, + "devDependencies": { + "@rslib/core": "^0.3.1", + "@testing-library/jest-dom": "^5.11.6", + "jest-in-case": "^1.0.2", + "jest-snapshot-serializer-ansi": "^1.0.0", + "jest-watch-select-projects": "^2.0.0", + "jsdom": "20.0.0" + }, + "engines": { + "node": ">=18" + } +} diff --git a/packages/testing-library/lynx-dom-testing-library/rollup.config.js b/packages/testing-library/lynx-dom-testing-library/rollup.config.js new file mode 100644 index 0000000000..00b1867d07 --- /dev/null +++ b/packages/testing-library/lynx-dom-testing-library/rollup.config.js @@ -0,0 +1,5 @@ +const rollupConfig = require('kcd-scripts/dist/config/rollup.config'); + +// the exports in this library should always be named for all formats. +rollupConfig.output[0].exports = 'named'; +module.exports = rollupConfig; diff --git a/packages/testing-library/lynx-dom-testing-library/rslib.config.ts b/packages/testing-library/lynx-dom-testing-library/rslib.config.ts new file mode 100644 index 0000000000..47e3874488 --- /dev/null +++ b/packages/testing-library/lynx-dom-testing-library/rslib.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from '@rslib/core'; + +export default defineConfig({ + source: { + entry: { + index: './src/**', + }, + }, + lib: [ + { + bundle: false, + format: 'cjs', + syntax: 'es2021', + }, + ], +}); diff --git a/packages/testing-library/lynx-dom-testing-library/src/DOMElementFilter.ts b/packages/testing-library/lynx-dom-testing-library/src/DOMElementFilter.ts new file mode 100644 index 0000000000..64e5638a0b --- /dev/null +++ b/packages/testing-library/lynx-dom-testing-library/src/DOMElementFilter.ts @@ -0,0 +1,266 @@ +/** + * Source: https://github.com/facebook/jest/blob/e7bb6a1e26ffab90611b2593912df15b69315611/packages/pretty-format/src/plugins/DOMElement.ts + */ +/* eslint-disable -- trying to stay as close to the original as possible */ +/* istanbul ignore file */ +import type { Config, NewPlugin, Printer, Refs } from 'pretty-format'; + +function escapeHTML(str: string): string { + return str.replace(//g, '>'); +} +// Return empty string if keys is empty. +const printProps = ( + keys: Array, + props: Record, + config: Config, + indentation: string, + depth: number, + refs: Refs, + printer: Printer, +): string => { + const indentationNext = indentation + config.indent; + const colors = config.colors; + return keys + .map(key => { + const value = props[key]; + let printed = printer(value, config, indentationNext, depth, refs); + + if (typeof value !== 'string') { + if (printed.indexOf('\n') !== -1) { + printed = config.spacingOuter + + indentationNext + + printed + + config.spacingOuter + + indentation; + } + printed = '{' + printed + '}'; + } + + return ( + config.spacingInner + + indentation + + colors.prop.open + + key + + colors.prop.close + + '=' + + colors.value.open + + printed + + colors.value.close + ); + }) + .join(''); +}; + +// https://developer.mozilla.org/en-US/docs/Web/API/Node/nodeType#node_type_constants +const NodeTypeTextNode = 3; + +// Return empty string if children is empty. +const printChildren = ( + children: Array, + config: Config, + indentation: string, + depth: number, + refs: Refs, + printer: Printer, +): string => + children + .map(child => { + const printedChild = typeof child === 'string' + ? printText(child, config) + : printer(child, config, indentation, depth, refs); + + if ( + printedChild === '' + && typeof child === 'object' + && child !== null + && (child as Node).nodeType !== NodeTypeTextNode + ) { + // A plugin serialized this Node to '' meaning we should ignore it. + return ''; + } + return config.spacingOuter + indentation + printedChild; + }) + .join(''); + +const printText = (text: string, config: Config): string => { + const contentColor = config.colors.content; + return contentColor.open + escapeHTML(text) + contentColor.close; +}; + +const printComment = (comment: string, config: Config): string => { + const commentColor = config.colors.comment; + return ( + commentColor.open + + '' + + commentColor.close + ); +}; + +// Separate the functions to format props, children, and element, +// so a plugin could override a particular function, if needed. +// Too bad, so sad: the traditional (but unnecessary) space +// in a self-closing tagColor requires a second test of printedProps. +const printElement = ( + type: string, + printedProps: string, + printedChildren: string, + config: Config, + indentation: string, +): string => { + const tagColor = config.colors.tag; + return ( + tagColor.open + + '<' + + type + + (printedProps + && tagColor.close + + printedProps + + config.spacingOuter + + indentation + + tagColor.open) + + (printedChildren + ? '>' + + tagColor.close + + printedChildren + + config.spacingOuter + + indentation + + tagColor.open + + '' + + tagColor.close + ); +}; + +const printElementAsLeaf = (type: string, config: Config): string => { + const tagColor = config.colors.tag; + return ( + tagColor.open + + '<' + + type + + tagColor.close + + ' …' + + tagColor.open + + ' />' + + tagColor.close + ); +}; + +const ELEMENT_NODE = 1; +const TEXT_NODE = 3; +const COMMENT_NODE = 8; +const FRAGMENT_NODE = 11; + +const ELEMENT_REGEXP = /^((HTML|SVG)\w*)?Element$/; + +const isCustomElement = (val: any) => { + const { tagName } = val; + return Boolean( + (typeof tagName === 'string' && tagName.includes('-')) + || (typeof val.hasAttribute === 'function' && val.hasAttribute('is')), + ); +}; + +const testNode = (val: any) => { + const constructorName = val.constructor.name; + + const { nodeType } = val; + + return ( + (nodeType === ELEMENT_NODE + && (ELEMENT_REGEXP.test(constructorName) || isCustomElement(val))) + || (nodeType === TEXT_NODE && constructorName === 'Text') + || (nodeType === COMMENT_NODE && constructorName === 'Comment') + || (nodeType === FRAGMENT_NODE && constructorName === 'DocumentFragment') + ); +}; + +export const test: NewPlugin['test'] = (val: any) => + (val?.constructor?.name || isCustomElement(val)) && testNode(val); + +type HandledType = Element | Text | Comment | DocumentFragment; + +function nodeIsText(node: HandledType): node is Text { + return node.nodeType === TEXT_NODE; +} + +function nodeIsComment(node: HandledType): node is Comment { + return node.nodeType === COMMENT_NODE; +} + +function nodeIsFragment(node: HandledType): node is DocumentFragment { + return node.nodeType === FRAGMENT_NODE; +} + +export default function createDOMElementFilter( + filterNode: (node: Node) => boolean, +): NewPlugin { + return { + test: (val: any) => + (val?.constructor?.name || isCustomElement(val)) && testNode(val), + serialize: ( + node: HandledType, + config: Config, + indentation: string, + depth: number, + refs: Refs, + printer: Printer, + ) => { + if (nodeIsText(node)) { + return printText(node.data, config); + } + + if (nodeIsComment(node)) { + return printComment(node.data, config); + } + + const type = nodeIsFragment(node) + ? `DocumentFragment` + : node.tagName.toLowerCase(); + + if (++depth > config.maxDepth) { + return printElementAsLeaf(type, config); + } + + return printElement( + type, + printProps( + nodeIsFragment(node) + ? [] + : Array.from(node.attributes) + .map(attr => attr.name) + .sort(), + nodeIsFragment(node) + ? {} + : Array.from(node.attributes).reduce>( + (props, attribute) => { + props[attribute.name] = attribute.value; + return props; + }, + {}, + ), + config, + indentation + config.indent, + depth, + refs, + printer, + ), + printChildren( + Array.prototype.slice + .call(node.childNodes || node.children) + .filter(filterNode), + config, + indentation + config.indent, + depth, + refs, + printer, + ), + config, + indentation, + ); + }, + }; +} diff --git a/packages/testing-library/lynx-dom-testing-library/src/__node_tests__/index.js b/packages/testing-library/lynx-dom-testing-library/src/__node_tests__/index.js new file mode 100644 index 0000000000..fc8f1f0eb7 --- /dev/null +++ b/packages/testing-library/lynx-dom-testing-library/src/__node_tests__/index.js @@ -0,0 +1,173 @@ +import { JSDOM } from 'jsdom'; +import * as dtl from '..'; + +test('works without a global dom', async () => { + const container = new JSDOM(` + + +
+ + + + + +
+
+ + + `).window.document.body; + container.querySelector('#login-form').addEventListener('submit', e => { + e.preventDefault(); + const { username, password } = e.target.elements; + setTimeout(() => { + const dataContainer = container.querySelector('#data-container'); + const pre = container.ownerDocument.createElement('pre'); + pre.dataset.testid = 'submitted-data'; + pre.textContent = JSON.stringify({ + username: username.value, + password: password.value, + }); + dataContainer.appendChild(pre); + }, 20); + }); + + const fakeUser = { username: 'chucknorris', password: 'i need no password' }; + const usernameField = dtl.getByLabelText(container, /username/i); + const passwordField = dtl.getByLabelText(container, /password/i); + usernameField.value = fakeUser.username; + passwordField.value = fakeUser.password; + const submitButton = dtl.getByText(container, /submit/i); + dtl.fireEvent.click(submitButton); + const submittedDataPre = await dtl.findByTestId( + container, + 'submitted-data', + {}, + { container }, + ); + const data = JSON.parse(submittedDataPre.textContent); + expect(data).toEqual(fakeUser); +}); + +test('works without a browser context on a dom node (JSDOM Fragment)', () => { + const container = JSDOM.fragment(` + + +
+ + + + + +
+
+ + + `); + + expect(dtl.getByLabelText(container, /username/i)).toMatchInlineSnapshot(` + + `); + expect(dtl.getByLabelText(container, /password/i)).toMatchInlineSnapshot(` + + `); +}); + +test('byRole works without a global DOM', () => { + const { + window: { + document: { body: container }, + }, + } = new JSDOM(` + + + + + + `); + + expect(dtl.getByRole(container, 'button')).toMatchInlineSnapshot(` + + `); +}); + +test('findBy works without a global DOM', async () => { + const { window } = new JSDOM(`
+
test text content
+ + + test alt text + +
+
+
header
+ +
`); + + await expect( + dtl.findByLabelText(window.document, 'test-label'), + ).resolves.toBeTruthy(); + await expect( + dtl.findAllByLabelText(window.document, 'test-label'), + ).resolves.toHaveLength(1); + await expect( + dtl.findByPlaceholderText(window.document, 'placeholder'), + ).resolves.toBeTruthy(); + await expect( + dtl.findAllByPlaceholderText(window.document, 'placeholder'), + ).resolves.toHaveLength(1); + await expect( + dtl.findByText(window.document, 'test text content'), + ).resolves.toBeTruthy(); + await expect( + dtl.findAllByText(window.document, 'test text content'), + ).resolves.toHaveLength(1); + await expect( + dtl.findByAltText(window.document, 'test alt text'), + ).resolves.toBeTruthy(); + await expect( + dtl.findAllByAltText(window.document, 'test alt text'), + ).resolves.toHaveLength(1); + await expect( + dtl.findByTitle(window.document, 'test title'), + ).resolves.toBeTruthy(); + await expect( + dtl.findAllByTitle(window.document, 'test title'), + ).resolves.toHaveLength(1); + await expect( + dtl.findByDisplayValue(window.document, 'display value'), + ).resolves.toBeTruthy(); + await expect( + dtl.findAllByDisplayValue(window.document, 'display value'), + ).resolves.toHaveLength(1); + await expect(dtl.findByRole(window.document, 'dialog')).resolves.toBeTruthy(); + await expect( + dtl.findAllByRole(window.document, 'dialog'), + ).resolves.toHaveLength(1); + await expect(dtl.findByRole(window.document, 'meter')).resolves.toBeTruthy(); + await expect( + dtl.findAllByRole(window.document, 'meter'), + ).resolves.toHaveLength(1); + await expect( + dtl.findByRole(window.document, 'progressbar', { queryFallbacks: true }), + ).resolves.toBeTruthy(); + await expect( + dtl.findAllByRole(window.document, 'progressbar', { queryFallbacks: true }), + ).resolves.toHaveLength(1); + await expect(dtl.findByRole(window.document, 'banner')).resolves.toBeTruthy(); + await expect( + dtl.findAllByRole(window.document, 'banner'), + ).resolves.toHaveLength(1); + await expect( + dtl.findByTestId(window.document, 'test-id'), + ).resolves.toBeTruthy(); + await expect( + dtl.findAllByTestId(window.document, 'test-id'), + ).resolves.toHaveLength(1); +}); diff --git a/packages/testing-library/lynx-dom-testing-library/src/__node_tests__/pretty-dom.js b/packages/testing-library/lynx-dom-testing-library/src/__node_tests__/pretty-dom.js new file mode 100644 index 0000000000..6a18aace20 --- /dev/null +++ b/packages/testing-library/lynx-dom-testing-library/src/__node_tests__/pretty-dom.js @@ -0,0 +1,106 @@ +import { JSDOM } from 'jsdom'; +import { prettyDOM } from '../pretty-dom'; + +function render(html) { + const { window } = new JSDOM(); + const container = window.document.createElement('div'); + container.innerHTML = html; + return { container }; +} + +jest.mock('../get-user-code-frame'); + +test('prettyDOM supports a COLORS environment variable', () => { + const { container } = render('
Hello World!
'); + + // process.env.COLORS is a string, so make sure we test it as such + process.env.COLORS = 'false'; + expect(prettyDOM(container)).toMatchInlineSnapshot(` +
+
+ Hello World! +
+
+ `); + + process.env.COLORS = 'true'; + expect(prettyDOM(container)).toMatchInlineSnapshot(` + 
 + 
 + Hello World! + 
 + 
 + `); +}); + +test('prettyDOM handles a COLORS env variable of unexpected object type by colorizing for node', () => { + const { container } = render('
Hello World!
'); + + const originalNodeVersion = process.versions.node; + process.env.COLORS = '{}'; + delete process.versions.node; + expect(prettyDOM(container)).toMatchInlineSnapshot(` +
+
+ Hello World! +
+
+ `); + process.versions.node = '1.2.3'; + expect(prettyDOM(container)).toMatchInlineSnapshot(` + 
 + 
 + Hello World! + 
 + 
 + `); + process.versions.node = originalNodeVersion; +}); + +test('prettyDOM handles a COLORS env variable of undefined by colorizing for node', () => { + const { container } = render('
Hello World!
'); + + const originalNodeVersion = process.versions.node; + process.env.COLORS = undefined; + delete process.versions.node; + expect(prettyDOM(container)).toMatchInlineSnapshot(` +
+
+ Hello World! +
+
+ `); + process.versions.node = '1.2.3'; + expect(prettyDOM(container)).toMatchInlineSnapshot(` + 
 + 
 + Hello World! + 
 + 
 + `); + process.versions.node = originalNodeVersion; +}); + +test('prettyDOM handles a COLORS env variable of empty string by colorizing for node', () => { + const { container } = render('
Hello World!
'); + + const originalNodeVersion = process.versions.node; + process.env.COLORS = ''; + delete process.versions.node; + expect(prettyDOM(container)).toMatchInlineSnapshot(` +
+
+ Hello World! +
+
+ `); + process.versions.node = '1.2.3'; + expect(prettyDOM(container)).toMatchInlineSnapshot(` + 
 + 
 + Hello World! + 
 + 
 + `); + process.versions.node = originalNodeVersion; +}); diff --git a/packages/testing-library/lynx-dom-testing-library/src/__node_tests__/screen.js b/packages/testing-library/lynx-dom-testing-library/src/__node_tests__/screen.js new file mode 100644 index 0000000000..36894bb66b --- /dev/null +++ b/packages/testing-library/lynx-dom-testing-library/src/__node_tests__/screen.js @@ -0,0 +1,8 @@ +import { screen } from '..'; + +test('the screen export throws a helpful error message when no global document is accessible', () => { + expect(() => screen.getByText(/hello world/i)) + .toThrowErrorMatchingInlineSnapshot( + `For queries bound to document.body a global document has to be available... Learn more: https://testing-library.com/s/screen-global-error`, + ); +}); diff --git a/packages/testing-library/lynx-dom-testing-library/src/__tests__/.eslintrc b/packages/testing-library/lynx-dom-testing-library/src/__tests__/.eslintrc new file mode 100644 index 0000000000..e36a045650 --- /dev/null +++ b/packages/testing-library/lynx-dom-testing-library/src/__tests__/.eslintrc @@ -0,0 +1,7 @@ +{ + "rules": { + // this rule doesn't properly detect that our custom `render` does not + // insert elements into the document. + "jest-dom/prefer-in-document": "off" + } +} diff --git a/packages/testing-library/lynx-dom-testing-library/src/__tests__/__snapshots__/get-by-errors.js.snap b/packages/testing-library/lynx-dom-testing-library/src/__tests__/__snapshots__/get-by-errors.js.snap new file mode 100644 index 0000000000..42bc84aee0 --- /dev/null +++ b/packages/testing-library/lynx-dom-testing-library/src/__tests__/__snapshots__/get-by-errors.js.snap @@ -0,0 +1,5 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`getByLabelText query will throw the custom error returned by config.getElementError 1`] = `My custom error: Unable to find a label with the text of: TEST QUERY`; + +exports[`getByText query will throw the custom error returned by config.getElementError 1`] = `My custom error: Unable to find an element with the text: TEST QUERY. This could be because the text is broken up by multiple elements. In this case, you can provide a function for your text matcher to make your matcher more flexible.`; diff --git a/packages/testing-library/lynx-dom-testing-library/src/__tests__/__snapshots__/role-helpers.js.snap b/packages/testing-library/lynx-dom-testing-library/src/__tests__/__snapshots__/role-helpers.js.snap new file mode 100644 index 0000000000..75bbed7fa7 --- /dev/null +++ b/packages/testing-library/lynx-dom-testing-library/src/__tests__/__snapshots__/role-helpers.js.snap @@ -0,0 +1,220 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`logRoles calls console.log with output from prettyRoles 1`] = ` +region: + +Name "a region": +
+ +-------------------------------------------------- +link: + +Name "link": + + +-------------------------------------------------- +navigation: + +Name "": +