From c46a703e963289b8bd8013889c27636ceed14fc6 Mon Sep 17 00:00:00 2001 From: Silviu Alexandu Avram Date: Tue, 8 Oct 2024 10:26:56 +0300 Subject: [PATCH 1/2] feat: implement toHaveSelection --- README.md | 69 ++++++++++- src/__tests__/to-have-selection.js | 189 +++++++++++++++++++++++++++++ src/matchers.js | 1 + src/to-have-selection.js | 114 +++++++++++++++++ types/matchers.d.ts | 58 +++++++++ 5 files changed, 430 insertions(+), 1 deletion(-) create mode 100644 src/__tests__/to-have-selection.js create mode 100644 src/to-have-selection.js diff --git a/README.md b/README.md index 776af4da..d012b311 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,7 @@ clear to read and to maintain. - [`toBePartiallyChecked`](#tobepartiallychecked) - [`toHaveRole`](#tohaverole) - [`toHaveErrorMessage`](#tohaveerrormessage) + - [`toHaveSelection`](#tohaveselection) - [Deprecated matchers](#deprecated-matchers) - [`toBeEmpty`](#tobeempty) - [`toBeInTheDOM`](#tobeinthedom) @@ -162,7 +163,8 @@ import '@testing-library/jest-dom/vitest' setupFiles: ['./vitest-setup.js'] ``` -Also, depending on your local setup, you may need to update your `tsconfig.json`: +Also, depending on your local setup, you may need to update your +`tsconfig.json`: ```json // In tsconfig.json @@ -1420,6 +1422,71 @@ expect(deleteButton).not.toHaveDescription() expect(deleteButton).toHaveDescription('') // Missing or empty description always becomes a blank string ``` +
+ +### `toHaveSelection` + +This allows to assert that an element has a +[text selection](https://developer.mozilla.org/en-US/docs/Web/API/Selection). + +This is useful to check if text or part of the text is selected within an +element. The element can be either an input of type text, a textarea, or any +other element that contains text, such as a paragraph, span, div etc. + +NOTE: the expected selection is a string, it does not allow to check for +selection range indeces. + +```typescript +toHaveSelection(expectedSelection: string) +``` + +```html +
+ + +

prev

+

+ text selected text +

+

next

+
+``` + +```javascript +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') +``` + ## Inspiration This whole library was extracted out of Kent C. Dodds' [DOM Testing diff --git a/src/__tests__/to-have-selection.js b/src/__tests__/to-have-selection.js new file mode 100644 index 00000000..9ddcc2c8 --- /dev/null +++ b/src/__tests__/to-have-selection.js @@ -0,0 +1,189 @@ +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/src/matchers.js b/src/matchers.js index 46803f30..ed534e28 100644 --- a/src/matchers.js +++ b/src/matchers.js @@ -24,3 +24,4 @@ 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/src/to-have-selection.js b/src/to-have-selection.js new file mode 100644 index 00000000..55ce430a --- /dev/null +++ b/src/to-have-selection.js @@ -0,0 +1,114 @@ +import isEqualWith from 'lodash/isEqualWith' +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/types/matchers.d.ts b/types/matchers.d.ts index dfea7f10..8bf2ad58 100755 --- a/types/matchers.d.ts +++ b/types/matchers.d.ts @@ -703,6 +703,64 @@ declare namespace matchers { * [testing-library/jest-dom#tohaveerrormessage](https://github.com/testing-library/jest-dom#tohaveerrormessage) */ toHaveErrorMessage(text?: string | RegExp | E): R + /** + * @description + * This allows to assert that an element has a + * [text selection](https://developer.mozilla.org/en-US/docs/Web/API/Selection). + * + * This is useful to check if text or part of the text is selected within an + * element. The element can be either an input of type text, a textarea, or any + * other element that contains text, such as a paragraph, span, div etc. + * + * NOTE: the expected selection is a string, it does not allow to check for + * selection range indeces. + * + * @example + *
+ * + * + *

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 } } From 7025a4d4cac83983dde036b0035597afb9fc3c20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ernesto=20Garc=C3=ADa?= Date: Wed, 16 Oct 2024 13:22:00 -0300 Subject: [PATCH 2/2] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index efc449c3..d34a2d91 100644 --- a/README.md +++ b/README.md @@ -1437,7 +1437,7 @@ NOTE: the expected selection is a string, it does not allow to check for selection range indeces. ```typescript -toHaveSelection(expectedSelection: string) +toHaveSelection(expectedSelection?: string) ``` ```html