diff --git a/.codesandbox/ci.json b/.codesandbox/ci.json index 36f65be2..48600aa2 100644 --- a/.codesandbox/ci.json +++ b/.codesandbox/ci.json @@ -1,3 +1,4 @@ { - "sandboxes": ["github/kentcdodds/react-testing-library-examples"] + "sandboxes": ["github/kentcdodds/react-testing-library-examples"], + "node": "12" } diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index a937c57d..8c9ec07e 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -16,7 +16,7 @@ jobs: if: ${{ !contains(github.head_ref, 'all-contributors') }} strategy: matrix: - node: [10.14.2, 12, 14, 15, 16] + node: [12, 14, 16] runs-on: ubuntu-latest steps: - name: 🛑 Cancel Previous Runs diff --git a/package.json b/package.json index a1df7345..78ddf6c9 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "author": "Kent C. Dodds (https://kentcdodds.com)", "license": "MIT", "engines": { - "node": ">=10" + "node": ">=12" }, "scripts": { "build": "kcd-scripts build --no-ts-defs --ignore \"**/__tests__/**,**/__node_tests__/**,**/__mocks__/**\" && kcd-scripts build --no-ts-defs --bundle --no-clean", @@ -46,7 +46,7 @@ "chalk": "^4.1.0", "dom-accessibility-api": "^0.5.6", "lz-string": "^1.4.4", - "pretty-format": "^26.6.2" + "pretty-format": "^27.0.2" }, "devDependencies": { "@testing-library/jest-dom": "^5.11.6", @@ -54,7 +54,7 @@ "jest-serializer-ansi": "^1.0.3", "jest-watch-select-projects": "^2.0.0", "jsdom": "^16.4.0", - "kcd-scripts": "^7.5.3", + "kcd-scripts": "^11.0.0", "typescript": "^4.1.2" }, "eslintConfig": { @@ -63,6 +63,7 @@ "plugin:import/typescript" ], "rules": { + "@typescript-eslint/prefer-includes": "off", "import/prefer-default-export": "off", "import/no-unassigned-import": "off", "import/no-useless-path-segments": "off", diff --git a/src/DOMElementFilter.ts b/src/DOMElementFilter.ts new file mode 100644 index 00000000..bf5ff686 --- /dev/null +++ b/src/DOMElementFilter.ts @@ -0,0 +1,261 @@ +/** + * 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 testNode = (val: any) => { + const constructorName = val.constructor.name + const {nodeType, tagName} = val + const isCustomElement = + (typeof tagName === 'string' && tagName.includes('-')) || + (typeof val.hasAttribute === 'function' && val.hasAttribute('is')) + + return ( + (nodeType === ELEMENT_NODE && + (ELEMENT_REGEXP.test(constructorName) || isCustomElement)) || + (nodeType === TEXT_NODE && constructorName === 'Text') || + (nodeType === COMMENT_NODE && constructorName === 'Comment') || + (nodeType === FRAGMENT_NODE && constructorName === 'DocumentFragment') + ) +} + +export const test: NewPlugin['test'] = (val: any) => + val?.constructor?.name && testNode(val) + +type HandledType = Element | Text | Comment | DocumentFragment + +function nodeIsText(node: HandledType): node is Text { + return node.nodeType === TEXT_NODE +} + +function nodeIsComment(node: HandledType): node is Comment { + return node.nodeType === COMMENT_NODE +} + +function nodeIsFragment(node: HandledType): node is DocumentFragment { + return node.nodeType === FRAGMENT_NODE +} + +export default function createDOMElementFilter( + filterNode: (node: Node) => boolean, +): NewPlugin { + return { + test: (val: any) => val?.constructor?.name && 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/src/__node_tests__/index.js b/src/__node_tests__/index.js index 0f88c323..6c663360 100644 --- a/src/__node_tests__/index.js +++ b/src/__node_tests__/index.js @@ -66,13 +66,13 @@ test('works without a browser context on a dom node (JSDOM Fragment)', () => { expect(dtl.getByLabelText(container, /username/i)).toMatchInlineSnapshot(` `) expect(dtl.getByLabelText(container, /password/i)).toMatchInlineSnapshot(` `) }) diff --git a/src/__node_tests__/screen.js b/src/__node_tests__/screen.js index 896393d0..6dba2399 100644 --- a/src/__node_tests__/screen.js +++ b/src/__node_tests__/screen.js @@ -4,6 +4,6 @@ test('the screen export throws a helpful error message when no global document i 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"`, + `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/src/__tests__/__snapshots__/get-by-errors.js.snap b/src/__tests__/__snapshots__/get-by-errors.js.snap index 45f4cde1..42bc84ae 100644 --- a/src/__tests__/__snapshots__/get-by-errors.js.snap +++ b/src/__tests__/__snapshots__/get-by-errors.js.snap @@ -1,5 +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[`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."`; +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/src/__tests__/ariaAttributes.js b/src/__tests__/ariaAttributes.js index 84334230..2f0b76de 100644 --- a/src/__tests__/ariaAttributes.js +++ b/src/__tests__/ariaAttributes.js @@ -5,7 +5,7 @@ test('`selected` throws on unsupported roles', () => { expect(() => getByRole('textbox', {selected: true}), ).toThrowErrorMatchingInlineSnapshot( - `"\\"aria-selected\\" is not supported on role \\"textbox\\"."`, + `"aria-selected" is not supported on role "textbox".`, ) }) @@ -14,7 +14,7 @@ test('`pressed` throws on unsupported roles', () => { expect(() => getByRole('textbox', {pressed: true}), ).toThrowErrorMatchingInlineSnapshot( - `"\\"aria-pressed\\" is not supported on role \\"textbox\\"."`, + `"aria-pressed" is not supported on role "textbox".`, ) }) @@ -23,7 +23,7 @@ test('`checked` throws on unsupported roles', () => { expect(() => getByRole('textbox', {checked: true}), ).toThrowErrorMatchingInlineSnapshot( - `"\\"aria-checked\\" is not supported on role \\"textbox\\"."`, + `"aria-checked" is not supported on role "textbox".`, ) }) @@ -32,7 +32,7 @@ test('`expanded` throws on unsupported roles', () => { expect(() => getByRole('heading', {expanded: true}), ).toThrowErrorMatchingInlineSnapshot( - `"\\"aria-expanded\\" is not supported on role \\"heading\\"."`, + `"aria-expanded" is not supported on role "heading".`, ) }) @@ -142,9 +142,9 @@ test('`selected: true` matches `aria-selected="true"` on supported roles', () => 'selected-listbox-option', ]) - expect( - getAllByRole('rowheader', {selected: true}).map(({id}) => id), - ).toEqual(['selected-rowheader', 'selected-native-rowheader']) + expect(getAllByRole('rowheader', {selected: true}).map(({id}) => id)).toEqual( + ['selected-rowheader', 'selected-native-rowheader'], + ) expect(getAllByRole('treeitem', {selected: true}).map(({id}) => id)).toEqual([ 'selected-treeitem', @@ -208,7 +208,7 @@ test('`level` throws on unsupported roles', () => { expect(() => getByRole('button', {level: 3}), ).toThrowErrorMatchingInlineSnapshot( - `"Role \\"button\\" cannot have \\"level\\" property."`, + `Role "button" cannot have "level" property.`, ) }) diff --git a/src/__tests__/base-queries-warn-on-invalid-container.js b/src/__tests__/base-queries-warn-on-invalid-container.js index c4684005..6796a874 100644 --- a/src/__tests__/base-queries-warn-on-invalid-container.js +++ b/src/__tests__/base-queries-warn-on-invalid-container.js @@ -86,8 +86,8 @@ describe('synchronous queries throw on invalid container type', () => { ])('%s', (_queryName, query) => { expect(() => query('invalid type for container', 'irrelevant text'), - ).toThrowErrorMatchingInlineSnapshot( - `"Expected container to be an Element, a Document or a DocumentFragment but got string."`, + ).toThrowError( + `Expected container to be an Element, a Document or a DocumentFragment but got string.`, ) }) }) @@ -120,8 +120,8 @@ describe('asynchronous queries throw on invalid container type', () => { queryOptions, waitOptions, ), - ).rejects.toThrowErrorMatchingInlineSnapshot( - `"Expected container to be an Element, a Document or a DocumentFragment but got string."`, + ).rejects.toThrowError( + `Expected container to be an Element, a Document or a DocumentFragment but got string.`, ) }) }) diff --git a/src/__tests__/deprecation-warnings.js b/src/__tests__/deprecation-warnings.js deleted file mode 100644 index 14744159..00000000 --- a/src/__tests__/deprecation-warnings.js +++ /dev/null @@ -1,30 +0,0 @@ -import {waitForElement, waitForDomChange, wait} from '..' - -afterEach(() => { - console.warn.mockClear() -}) - -test('deprecation warnings only warn once', async () => { - await wait(() => {}, {timeout: 1}) - await waitForElement(() => {}, {timeout: 1}).catch(e => e) - await waitForDomChange({timeout: 1}).catch(e => e) - expect(console.warn.mock.calls).toMatchInlineSnapshot(` - Array [ - Array [ - "\`wait\` has been deprecated and replaced by \`waitFor\` instead. In most cases you should be able to find/replace \`wait\` with \`waitFor\`. Learn more: https://testing-library.com/docs/dom-testing-library/api-async#waitfor.", - ], - Array [ - "\`waitForElement\` has been deprecated. Use a \`find*\` query (preferred: https://testing-library.com/docs/dom-testing-library/api-queries#findby) or use \`waitFor\` instead: https://testing-library.com/docs/dom-testing-library/api-async#waitfor", - ], - Array [ - "\`waitForDomChange\` has been deprecated. Use \`waitFor\` instead: https://testing-library.com/docs/dom-testing-library/api-async#waitfor.", - ], - ] - `) - - console.warn.mockClear() - await wait(() => {}, {timeout: 1}) - await waitForElement(() => {}, {timeout: 1}).catch(e => e) - await waitForDomChange({timeout: 1}).catch(e => e) - expect(console.warn).not.toHaveBeenCalled() -}) diff --git a/src/__tests__/element-queries.js b/src/__tests__/element-queries.js index 3e3ee6de..8564ddff 100644 --- a/src/__tests__/element-queries.js +++ b/src/__tests__/element-queries.js @@ -32,68 +32,78 @@ test('get throws a useful error message', () => { getByAltText, getByTitle, getByRole, - } = render('
') + } = render( + `

Hello, Dave

', + ) + + expect(prettyDOM(container)).toMatchInlineSnapshot(` + " +

+ Hello, Dave +

+ " + `) +}) + +test('prettyDOM can include all elements with a custom filter', () => { + const {container} = renderIntoDocument( + '

Hello, Dave

', + ) + + expect( + prettyDOM(container, Number.POSITIVE_INFINITY, {filterNode: () => true}), + ).toMatchInlineSnapshot(` + " +