From 23fde71a70e330a959391cb368b95dd8c9f1219d Mon Sep 17 00:00:00 2001 From: Pawel Wolak Date: Fri, 15 Oct 2021 17:17:03 +0200 Subject: [PATCH 1/4] chore: run lint --- .github/ISSUE_TEMPLATE.md | 1 + README.md | 9 ++------- other/CODE_OF_CONDUCT.md | 4 ++-- src/__tests__/to-be-empty-dom-element.js | 8 ++++++-- src/__tests__/to-have-class.js | 14 ++++++++------ src/__tests__/to-have-style.js | 4 ++-- src/to-be-empty-dom-element.js | 10 ++++++---- 7 files changed, 27 insertions(+), 23 deletions(-) diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md index 2b8da214..3e533d37 100644 --- a/.github/ISSUE_TEMPLATE.md +++ b/.github/ISSUE_TEMPLATE.md @@ -18,6 +18,7 @@ learn how: http://kcd.im/pull-request Relevant code or config ```javascript + ``` What you did: diff --git a/README.md b/README.md index 5d1791ad..0aa1393e 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,6 @@ clear to read and to maintain. - - [Installation](#installation) - [Usage](#usage) - [With TypeScript](#with-typescript) @@ -1203,12 +1202,8 @@ To perform a partial match, you can pass a `RegExp` or use #### Examples ```html - -
- Closing will discard any changes -
+ +
Closing will discard any changes
``` diff --git a/other/CODE_OF_CONDUCT.md b/other/CODE_OF_CONDUCT.md index cfe82c06..8649c632 100644 --- a/other/CODE_OF_CONDUCT.md +++ b/other/CODE_OF_CONDUCT.md @@ -60,8 +60,8 @@ representative at an online or offline event. Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at -TestingLibraryOSS@gmail.com. All complaints will be reviewed and investigated promptly -and fairly. +TestingLibraryOSS@gmail.com. All complaints will be reviewed and investigated +promptly and fairly. All community leaders are obligated to respect the privacy and security of the reporter of any incident. diff --git a/src/__tests__/to-be-empty-dom-element.js b/src/__tests__/to-be-empty-dom-element.js index 0d16bff9..055ae88e 100644 --- a/src/__tests__/to-be-empty-dom-element.js +++ b/src/__tests__/to-be-empty-dom-element.js @@ -44,11 +44,15 @@ test('.toBeEmptyDOMElement', () => { expect(() => expect(withComment).not.toBeEmptyDOMElement()).toThrowError() - expect(() => expect(withMultipleComments).not.toBeEmptyDOMElement()).toThrowError() + expect(() => + expect(withMultipleComments).not.toBeEmptyDOMElement(), + ).toThrowError() expect(() => expect(withElement).toBeEmptyDOMElement()).toThrowError() - expect(() => expect(withElementAndComment).toBeEmptyDOMElement()).toThrowError() + expect(() => + expect(withElementAndComment).toBeEmptyDOMElement(), + ).toThrowError() expect(() => expect(withWhitespace).toBeEmptyDOMElement()).toThrowError() diff --git a/src/__tests__/to-have-class.js b/src/__tests__/to-have-class.js index 85bf8538..da79ac8b 100644 --- a/src/__tests__/to-have-class.js +++ b/src/__tests__/to-have-class.js @@ -102,9 +102,10 @@ test('.toHaveClass with exact mode option', () => { 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')).not.toHaveClass( + 'btn extra btn-danger foo', + {exact: true}, + ) expect(queryByTestId('delete-button')).toHaveClass('btn extra btn-danger', { exact: false, @@ -112,9 +113,10 @@ test('.toHaveClass with exact mode option', () => { 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')).not.toHaveClass( + 'btn extra btn-danger foo', + {exact: false}, + ) expect(queryByTestId('delete-button')).toHaveClass( 'btn', diff --git a/src/__tests__/to-have-style.js b/src/__tests__/to-have-style.js index 0d94efaa..07396c52 100644 --- a/src/__tests__/to-have-style.js +++ b/src/__tests__/to-have-style.js @@ -205,7 +205,7 @@ describe('.toHaveStyle', () => { Hello World `) expect(queryByTestId('color-example')).toHaveStyle({ - fontSize: 12 + fontSize: 12, }) }) @@ -214,7 +214,7 @@ describe('.toHaveStyle', () => { Hello World `) expect(() => { - expect(queryByTestId('color-example')).toHaveStyle({ fontSize: '12px' }) + expect(queryByTestId('color-example')).toHaveStyle({fontSize: '12px'}) }).toThrowError() }) diff --git a/src/to-be-empty-dom-element.js b/src/to-be-empty-dom-element.js index 652f5299..63a9044a 100644 --- a/src/to-be-empty-dom-element.js +++ b/src/to-be-empty-dom-element.js @@ -22,13 +22,15 @@ export function toBeEmptyDOMElement(element) { /** * Identifies if an element doesn't contain child nodes (excluding comments) - * ℹ Node.COMMENT_NODE can't be used because of the following issue + * ℹ 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; +function isEmptyElement(element) { + const nonCommentChildNodes = [...element.childNodes].filter( + node => node.nodeType !== 8, + ) + return nonCommentChildNodes.length === 0 } From d5417c77d3a50b81218e73afc6f0a3b4cb980db6 Mon Sep 17 00:00:00 2001 From: Pawel Wolak Date: Fri, 15 Oct 2021 17:21:52 +0200 Subject: [PATCH 2/4] feat: add toHaveSelection --- src/__tests__/to-have-selection.js | 70 ++++++++++++++++++++++++++++++ src/matchers.js | 2 + src/to-have-selection.js | 46 ++++++++++++++++++++ src/utils.js | 56 ++++++++++++++++++++++++ 4 files changed, 174 insertions(+) create mode 100644 src/__tests__/to-have-selection.js create mode 100644 src/to-have-selection.js diff --git a/src/__tests__/to-have-selection.js b/src/__tests__/to-have-selection.js new file mode 100644 index 00000000..c6166e79 --- /dev/null +++ b/src/__tests__/to-have-selection.js @@ -0,0 +1,70 @@ +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('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.addRange(range) + + range.selectNodeContents(queryByTestId('child')) + + expect(queryByTestId('parent')).toHaveSelection('selected') + + range.setStart(queryByTestId('prev'), 0) + range.setEnd(queryByTestId('child').childNodes[0], 3) + + expect(queryByTestId('parent')).toHaveSelection('text sel') + + range.setStart(queryByTestId('child').childNodes[0], 3) + range.setEnd(queryByTestId('next').childNodes[0], 4) + + expect(queryByTestId('parent')).toHaveSelection('ected text') + }) +}) diff --git a/src/matchers.js b/src/matchers.js index c90945d5..5be731f5 100644 --- a/src/matchers.js +++ b/src/matchers.js @@ -22,6 +22,7 @@ import {toBeChecked} from './to-be-checked' import {toBePartiallyChecked} from './to-be-partially-checked' import {toHaveDescription} from './to-have-description' import {toHaveErrorMessage} from './to-have-errormessage' +import {toHaveSelection} from './to-have-selection' export { toBeInTheDOM, @@ -50,4 +51,5 @@ export { toBePartiallyChecked, toHaveDescription, toHaveErrorMessage, + toHaveSelection, } diff --git a/src/to-have-selection.js b/src/to-have-selection.js new file mode 100644 index 00000000..4bb2c7ee --- /dev/null +++ b/src/to-have-selection.js @@ -0,0 +1,46 @@ +import isEqualWith from 'lodash/isEqualWith' +import { + checkHtmlElement, + compareArraysAsSet, + getMessage, + getSelection, +} from './utils' + +export function toHaveSelection(htmlElement, expectedSelection) { + checkHtmlElement(htmlElement, toHaveSelection, this) + + const receivedSelection = getSelection(htmlElement) + const expectsSelection = expectedSelection !== undefined + + let expectedTypedSelection = expectedSelection + let receivedTypedSelection = receivedSelection + if ( + expectedSelection == receivedSelection && + expectedSelection !== receivedSelection + ) { + expectedTypedSelection = `${expectedSelection} (${typeof expectedSelection})` + receivedTypedSelection = `${receivedSelection} (${typeof receivedSelection})` + } + + 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 ? expectedTypedSelection : '(any)', + 'Received', + receivedTypedSelection, + ) + }, + } +} diff --git a/src/utils.js b/src/utils.js index a7c61dda..6056258d 100644 --- a/src/utils.js +++ b/src/utils.js @@ -228,6 +228,61 @@ function toSentence( ) } +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 if (selection.containsNode(element, true)) { + // Element is partially selected + const range = element.ownerDocument.getSelection().getRangeAt(0) + const selectionStartsWithinElement = + element === range.startContainer || element.contains(range.startContainer) + const selectionEndsWithinElement = + element === range.endContainer || element.contains(range.endContainer) + + selection.removeAllRanges() + temporaryRange.selectNodeContents(element) + + if (selectionStartsWithinElement) { + temporaryRange.setStart(range.startContainer, range.startOffset) + } else if (selectionEndsWithinElement) { + temporaryRange.setEnd(range.endContainer, range.endOffset) + } + selection.addRange(temporaryRange) + } + + const result = selection.toString() + + selection.removeAllRanges() + selection.addRange(originalRange) + + return result +} + export { HtmlElementTypeError, NodeTypeError, @@ -242,4 +297,5 @@ export { getSingleElementValue, compareArraysAsSet, toSentence, + getSelection, } From dc677e365c042b953712b4a7318f2549f8cc47c4 Mon Sep 17 00:00:00 2001 From: Pawel Wolak Date: Sun, 17 Oct 2021 18:06:34 +0200 Subject: [PATCH 3/4] test: improve coverage of toHaveSelection --- src/__tests__/to-have-selection.js | 128 +++++++++++++++++++++++++++++ src/to-have-selection.js | 17 ++-- src/utils.js | 17 ++-- 3 files changed, 145 insertions(+), 17 deletions(-) diff --git a/src/__tests__/to-have-selection.js b/src/__tests__/to-have-selection.js index c6166e79..7534f962 100644 --- a/src/__tests__/to-have-selection.js +++ b/src/__tests__/to-have-selection.js @@ -41,6 +41,108 @@ describe('.toHaveSelection', () => { 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(` + + `) + + let errorMessage + try { + expect(queryByTestId('text')).toHaveSelection() + } catch (error) { + errorMessage = error.message + } + + expect(errorMessage).toMatchInlineSnapshot(` + 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) + + let errorMessage + try { + expect(queryByTestId('text')).not.toHaveSelection() + } catch (error) { + errorMessage = error.message + } + + expect(errorMessage).toMatchInlineSnapshot(` + 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
+ `) + + let errorMessage + try { + expect(queryByTestId('text')).toHaveSelection() + } catch (error) { + errorMessage = error.message + } + + expect(errorMessage).toMatchInlineSnapshot(` + 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) + + let errorMessage + try { + expect(queryByTestId('text')).toHaveSelection('no match') + } catch (error) { + errorMessage = error.message + } + + expect(errorMessage).toMatchInlineSnapshot(` + 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
@@ -55,16 +157,42 @@ describe('.toHaveSelection', () => { 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('child')).toHaveSelection('sel') expect(queryByTestId('parent')).toHaveSelection('text sel') range.setStart(queryByTestId('child').childNodes[0], 3) range.setEnd(queryByTestId('next').childNodes[0], 4) + expect(queryByTestId('child')).toHaveSelection('ected') expect(queryByTestId('parent')).toHaveSelection('ected text') }) + + 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) + let errorMessage + try { + expect(element).toHaveSelection(1) + } catch (error) { + errorMessage = error.message + } + + expect(errorMessage).toMatchInlineSnapshot( + `expected selection must be a string or undefined`, + ) + }) }) diff --git a/src/to-have-selection.js b/src/to-have-selection.js index 4bb2c7ee..eb88a84c 100644 --- a/src/to-have-selection.js +++ b/src/to-have-selection.js @@ -9,19 +9,14 @@ import { export function toHaveSelection(htmlElement, expectedSelection) { checkHtmlElement(htmlElement, toHaveSelection, this) - const receivedSelection = getSelection(htmlElement) const expectsSelection = expectedSelection !== undefined - let expectedTypedSelection = expectedSelection - let receivedTypedSelection = receivedSelection - if ( - expectedSelection == receivedSelection && - expectedSelection !== receivedSelection - ) { - expectedTypedSelection = `${expectedSelection} (${typeof expectedSelection})` - receivedTypedSelection = `${receivedSelection} (${typeof receivedSelection})` + 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) @@ -37,9 +32,9 @@ export function toHaveSelection(htmlElement, expectedSelection) { this, matcher, `Expected the element ${to} have selection`, - expectsSelection ? expectedTypedSelection : '(any)', + expectsSelection ? expectedSelection : '(any)', 'Received', - receivedTypedSelection, + receivedSelection, ) }, } diff --git a/src/utils.js b/src/utils.js index 6056258d..13843c9b 100644 --- a/src/utils.js +++ b/src/utils.js @@ -258,20 +258,25 @@ function getSelection(element) { // Element contains selection, nothing to do } else if (selection.containsNode(element, true)) { // Element is partially selected - const range = element.ownerDocument.getSelection().getRangeAt(0) const selectionStartsWithinElement = - element === range.startContainer || element.contains(range.startContainer) + element === originalRange.startContainer || + element.contains(originalRange.startContainer) const selectionEndsWithinElement = - element === range.endContainer || element.contains(range.endContainer) + element === originalRange.endContainer || + element.contains(originalRange.endContainer) - selection.removeAllRanges() temporaryRange.selectNodeContents(element) if (selectionStartsWithinElement) { - temporaryRange.setStart(range.startContainer, range.startOffset) + temporaryRange.setStart( + originalRange.startContainer, + originalRange.startOffset, + ) } else if (selectionEndsWithinElement) { - temporaryRange.setEnd(range.endContainer, range.endOffset) + temporaryRange.setEnd(originalRange.endContainer, originalRange.endOffset) } + + selection.removeAllRanges() selection.addRange(temporaryRange) } From e3f71b7917fa2c7bc7b3d5635d0954b88f1475fb Mon Sep 17 00:00:00 2001 From: Pawel Wolak Date: Sun, 17 Oct 2021 18:09:23 +0200 Subject: [PATCH 4/4] refactor: move getSelection from utils --- src/to-have-selection.js | 67 ++++++++++++++++++++++++++++++++++++---- src/utils.js | 61 ------------------------------------ 2 files changed, 61 insertions(+), 67 deletions(-) diff --git a/src/to-have-selection.js b/src/to-have-selection.js index eb88a84c..06baac00 100644 --- a/src/to-have-selection.js +++ b/src/to-have-selection.js @@ -1,10 +1,65 @@ import isEqualWith from 'lodash/isEqualWith' -import { - checkHtmlElement, - compareArraysAsSet, - getMessage, - getSelection, -} from './utils' +import {checkHtmlElement, compareArraysAsSet, getMessage} from './utils' + +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 if (selection.containsNode(element, true)) { + // Element is partially selected + const selectionStartsWithinElement = + element === originalRange.startContainer || + element.contains(originalRange.startContainer) + const selectionEndsWithinElement = + element === originalRange.endContainer || + element.contains(originalRange.endContainer) + + temporaryRange.selectNodeContents(element) + + if (selectionStartsWithinElement) { + temporaryRange.setStart( + originalRange.startContainer, + originalRange.startOffset, + ) + } else if (selectionEndsWithinElement) { + temporaryRange.setEnd(originalRange.endContainer, originalRange.endOffset) + } + + selection.removeAllRanges() + selection.addRange(temporaryRange) + } + + const result = selection.toString() + + selection.removeAllRanges() + selection.addRange(originalRange) + + return result +} export function toHaveSelection(htmlElement, expectedSelection) { checkHtmlElement(htmlElement, toHaveSelection, this) diff --git a/src/utils.js b/src/utils.js index 13843c9b..a7c61dda 100644 --- a/src/utils.js +++ b/src/utils.js @@ -228,66 +228,6 @@ function toSentence( ) } -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 if (selection.containsNode(element, true)) { - // Element is partially selected - const selectionStartsWithinElement = - element === originalRange.startContainer || - element.contains(originalRange.startContainer) - const selectionEndsWithinElement = - element === originalRange.endContainer || - element.contains(originalRange.endContainer) - - temporaryRange.selectNodeContents(element) - - if (selectionStartsWithinElement) { - temporaryRange.setStart( - originalRange.startContainer, - originalRange.startOffset, - ) - } else if (selectionEndsWithinElement) { - temporaryRange.setEnd(originalRange.endContainer, originalRange.endOffset) - } - - selection.removeAllRanges() - selection.addRange(temporaryRange) - } - - const result = selection.toString() - - selection.removeAllRanges() - selection.addRange(originalRange) - - return result -} - export { HtmlElementTypeError, NodeTypeError, @@ -302,5 +242,4 @@ export { getSingleElementValue, compareArraysAsSet, toSentence, - getSelection, }