UNPKG

@testing-library/jest-dom

Version:

Custom jest matchers to test the state of the DOM

1,795 lines (1,582 loc) 54.9 kB
'use strict'; var redent = require('redent'); var cssTools = require('@adobe/css-tools'); var domAccessibilityApi = require('dom-accessibility-api'); var ariaQuery = require('aria-query'); var chalk = require('chalk'); var isEqualWith = require('lodash/isEqualWith.js'); var escape = require('css.escape'); 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 = cssTools.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 } 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') }, } } 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') }, } } 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') }, } } 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; } 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') }, } } function getNormalizedHtml(container, htmlText) { const div = container.ownerDocument.createElement('div'); div.innerHTML = htmlText; return div.innerHTML } 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') }, } } function toHaveTextContent( node, checkWith, options = {normalizeWhitespace: true}, ) { checkNode(node, toHaveTextContent, this); const textContent = options.normalizeWhitespace ? normalize(node.textContent) : node.textContent.replace(/\u00a0/g, ' '); // Replace &nbsp; 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, ) }, } } function toHaveAccessibleDescription( htmlElement, expectedAccessibleDescription, ) { checkHtmlElement(htmlElement, toHaveAccessibleDescription, this); const actualAccessibleDescription = domAccessibilityApi.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, ) }, } } const ariaInvalidName = 'aria-invalid'; const validStates = ['false']; // See `aria-errormessage` spec at https://www.w3.org/TR/wai-aria-1.2/#aria-errormessage 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, ) }, } } const elementRoleList = buildElementRoleList(ariaQuery.elementRoles); 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) } function toHaveAccessibleName(htmlElement, expectedAccessibleName) { checkHtmlElement(htmlElement, toHaveAccessibleName, this); const actualAccessibleName = domAccessibilityApi.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, ) }, } } 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)}` } 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, ) }, } } 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$1(subset, superset) { return subset.every(strOrRegexp => typeof strOrRegexp === 'string' ? superset.includes(strOrRegexp) : superset.some(className => strOrRegexp.test(className)), ) } 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$1(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$1(expected, received), message: () => { const to = this.isNot ? 'not to' : 'to'; return getMessage( this, this.utils.matcherHint( `${this.isNot ? '.not' : ''}.toHaveClass`, 'element', this.utils.printExpected(expected.join(' ')), ), `Expected the element ${to} have class`, expected.join(' '), 'Received', received.join(' '), ) }, } : { pass: this.isNot ? received.length > 0 : false, message: () => this.isNot ? getMessage( this, this.utils.matcherHint('.not.toHaveClass', 'element', ''), 'Expected the element to have classes', '(none)', 'Received', received.join(' '), ) : [ this.utils.matcherHint(`.toHaveClass`, 'element'), 'At least one expected class must be provided.', ].join('\n'), } } function 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`, '') } 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') }, } } 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') }, } } // 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), }), {}, ) } 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') }, } } 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)) ) } 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') }, } } // form elements that support 'disabled' const FORM_TAGS$2 = [ 'fieldset', 'input', 'select', 'optgroup', 'option', 'button', 'textarea', ]; /* * According to specification: * If <fieldset> is disabled, the form controls that are its descendants, * except descendants of its first optional <legend> 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$2.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)) ) } 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') }, } } 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') }, } } // form elements that support 'required' const FORM_TAGS$1 = ['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$1.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')))) ) } 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') }, } } 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 } } 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') }, } } 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') }, } } 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, ) }, } } 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] } 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 ariaQuery.roles.keys().filter(roleSupportsChecked) } function roleSupportsChecked(role) { return ariaQuery.roles.get(role)?.props['aria-checked'] !== undefined } 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') }, } } // See algoritm: https://www.w3.org/TR/accname-1.1/#mapping_additional_nd_description 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), ) }, } } // See aria-errormessage spec https://www.w3.org/TR/wai-aria-1.2/#aria-errormessage 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), ) }, } } /** * 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 =