matchers-fa50de34.mjs 45 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634
  1. import redent from 'redent';
  2. import isEqual from 'lodash/isEqual.js';
  3. import { parse } from '@adobe/css-tools';
  4. import { computeAccessibleDescription, computeAccessibleName } from 'dom-accessibility-api';
  5. import chalk from 'chalk';
  6. import isEqualWith from 'lodash/isEqualWith.js';
  7. import uniq from 'lodash/uniq.js';
  8. import escape from 'css.escape';
  9. import { roles } from 'aria-query';
  10. class GenericTypeError extends Error {
  11. constructor(expectedString, received, matcherFn, context) {
  12. super();
  13. /* istanbul ignore next */
  14. if (Error.captureStackTrace) {
  15. Error.captureStackTrace(this, matcherFn);
  16. }
  17. let withType = '';
  18. try {
  19. withType = context.utils.printWithType(
  20. 'Received',
  21. received,
  22. context.utils.printReceived,
  23. );
  24. } catch (e) {
  25. // Can throw for Document:
  26. // https://github.com/jsdom/jsdom/issues/2304
  27. }
  28. this.message = [
  29. context.utils.matcherHint(
  30. `${context.isNot ? '.not' : ''}.${matcherFn.name}`,
  31. 'received',
  32. '',
  33. ),
  34. '',
  35. // eslint-disable-next-line new-cap
  36. `${context.utils.RECEIVED_COLOR(
  37. 'received',
  38. )} value must ${expectedString}.`,
  39. withType,
  40. ].join('\n');
  41. }
  42. }
  43. class HtmlElementTypeError extends GenericTypeError {
  44. constructor(...args) {
  45. super('be an HTMLElement or an SVGElement', ...args);
  46. }
  47. }
  48. class NodeTypeError extends GenericTypeError {
  49. constructor(...args) {
  50. super('be a Node', ...args);
  51. }
  52. }
  53. function checkHasWindow(htmlElement, ErrorClass, ...args) {
  54. if (
  55. !htmlElement ||
  56. !htmlElement.ownerDocument ||
  57. !htmlElement.ownerDocument.defaultView
  58. ) {
  59. throw new ErrorClass(htmlElement, ...args)
  60. }
  61. }
  62. function checkNode(node, ...args) {
  63. checkHasWindow(node, NodeTypeError, ...args);
  64. const window = node.ownerDocument.defaultView;
  65. if (!(node instanceof window.Node)) {
  66. throw new NodeTypeError(node, ...args)
  67. }
  68. }
  69. function checkHtmlElement(htmlElement, ...args) {
  70. checkHasWindow(htmlElement, HtmlElementTypeError, ...args);
  71. const window = htmlElement.ownerDocument.defaultView;
  72. if (
  73. !(htmlElement instanceof window.HTMLElement) &&
  74. !(htmlElement instanceof window.SVGElement)
  75. ) {
  76. throw new HtmlElementTypeError(htmlElement, ...args)
  77. }
  78. }
  79. class InvalidCSSError extends Error {
  80. constructor(received, matcherFn, context) {
  81. super();
  82. /* istanbul ignore next */
  83. if (Error.captureStackTrace) {
  84. Error.captureStackTrace(this, matcherFn);
  85. }
  86. this.message = [
  87. received.message,
  88. '',
  89. // eslint-disable-next-line new-cap
  90. context.utils.RECEIVED_COLOR(`Failing css:`),
  91. // eslint-disable-next-line new-cap
  92. context.utils.RECEIVED_COLOR(`${received.css}`),
  93. ].join('\n');
  94. }
  95. }
  96. function parseCSS(css, ...args) {
  97. const ast = parse(`selector { ${css} }`, {silent: true}).stylesheet;
  98. if (ast.parsingErrors && ast.parsingErrors.length > 0) {
  99. const {reason, line} = ast.parsingErrors[0];
  100. throw new InvalidCSSError(
  101. {
  102. css,
  103. message: `Syntax error parsing expected css: ${reason} on line: ${line}`,
  104. },
  105. ...args,
  106. )
  107. }
  108. const parsedRules = ast.rules[0].declarations
  109. .filter(d => d.type === 'declaration')
  110. .reduce(
  111. (obj, {property, value}) => Object.assign(obj, {[property]: value}),
  112. {},
  113. );
  114. return parsedRules
  115. }
  116. function display(context, value) {
  117. return typeof value === 'string' ? value : context.utils.stringify(value)
  118. }
  119. function getMessage(
  120. context,
  121. matcher,
  122. expectedLabel,
  123. expectedValue,
  124. receivedLabel,
  125. receivedValue,
  126. ) {
  127. return [
  128. `${matcher}\n`,
  129. // eslint-disable-next-line new-cap
  130. `${expectedLabel}:\n${context.utils.EXPECTED_COLOR(
  131. redent(display(context, expectedValue), 2),
  132. )}`,
  133. // eslint-disable-next-line new-cap
  134. `${receivedLabel}:\n${context.utils.RECEIVED_COLOR(
  135. redent(display(context, receivedValue), 2),
  136. )}`,
  137. ].join('\n')
  138. }
  139. function matches(textToMatch, matcher) {
  140. if (matcher instanceof RegExp) {
  141. return matcher.test(textToMatch)
  142. } else {
  143. return textToMatch.includes(String(matcher))
  144. }
  145. }
  146. function deprecate(name, replacementText) {
  147. // Notify user that they are using deprecated functionality.
  148. // eslint-disable-next-line no-console
  149. console.warn(
  150. `Warning: ${name} has been deprecated and will be removed in future updates.`,
  151. replacementText,
  152. );
  153. }
  154. function normalize(text) {
  155. return text.replace(/\s+/g, ' ').trim()
  156. }
  157. function getTag(element) {
  158. return element.tagName && element.tagName.toLowerCase()
  159. }
  160. function getSelectValue({multiple, options}) {
  161. const selectedOptions = [...options].filter(option => option.selected);
  162. if (multiple) {
  163. return [...selectedOptions].map(opt => opt.value)
  164. }
  165. /* istanbul ignore if */
  166. if (selectedOptions.length === 0) {
  167. return undefined // Couldn't make this happen, but just in case
  168. }
  169. return selectedOptions[0].value
  170. }
  171. function getInputValue(inputElement) {
  172. switch (inputElement.type) {
  173. case 'number':
  174. return inputElement.value === '' ? null : Number(inputElement.value)
  175. case 'checkbox':
  176. return inputElement.checked
  177. default:
  178. return inputElement.value
  179. }
  180. }
  181. function getSingleElementValue(element) {
  182. /* istanbul ignore if */
  183. if (!element) {
  184. return undefined
  185. }
  186. switch (element.tagName.toLowerCase()) {
  187. case 'input':
  188. return getInputValue(element)
  189. case 'select':
  190. return getSelectValue(element)
  191. default:
  192. return element.value
  193. }
  194. }
  195. function compareArraysAsSet(a, b) {
  196. if (Array.isArray(a) && Array.isArray(b)) {
  197. return isEqual(new Set(a), new Set(b))
  198. }
  199. return undefined
  200. }
  201. function toSentence(
  202. array,
  203. {wordConnector = ', ', lastWordConnector = ' and '} = {},
  204. ) {
  205. return [array.slice(0, -1).join(wordConnector), array[array.length - 1]].join(
  206. array.length > 1 ? lastWordConnector : '',
  207. )
  208. }
  209. function toBeInTheDOM(element, container) {
  210. deprecate(
  211. 'toBeInTheDOM',
  212. 'Please use toBeInTheDocument for searching the entire document and toContainElement for searching a specific container.',
  213. );
  214. if (element) {
  215. checkHtmlElement(element, toBeInTheDOM, this);
  216. }
  217. if (container) {
  218. checkHtmlElement(container, toBeInTheDOM, this);
  219. }
  220. return {
  221. pass: container ? container.contains(element) : !!element,
  222. message: () => {
  223. return [
  224. this.utils.matcherHint(
  225. `${this.isNot ? '.not' : ''}.toBeInTheDOM`,
  226. 'element',
  227. '',
  228. ),
  229. '',
  230. 'Received:',
  231. ` ${this.utils.printReceived(
  232. element ? element.cloneNode(false) : element,
  233. )}`,
  234. ].join('\n')
  235. },
  236. }
  237. }
  238. function toBeInTheDocument(element) {
  239. if (element !== null || !this.isNot) {
  240. checkHtmlElement(element, toBeInTheDocument, this);
  241. }
  242. const pass =
  243. element === null
  244. ? false
  245. : element.ownerDocument === element.getRootNode({composed: true});
  246. const errorFound = () => {
  247. return `expected document not to contain element, found ${this.utils.stringify(
  248. element.cloneNode(true),
  249. )} instead`
  250. };
  251. const errorNotFound = () => {
  252. return `element could not be found in the document`
  253. };
  254. return {
  255. pass,
  256. message: () => {
  257. return [
  258. this.utils.matcherHint(
  259. `${this.isNot ? '.not' : ''}.toBeInTheDocument`,
  260. 'element',
  261. '',
  262. ),
  263. '',
  264. // eslint-disable-next-line new-cap
  265. this.utils.RECEIVED_COLOR(this.isNot ? errorFound() : errorNotFound()),
  266. ].join('\n')
  267. },
  268. }
  269. }
  270. function toBeEmpty(element) {
  271. deprecate(
  272. 'toBeEmpty',
  273. 'Please use instead toBeEmptyDOMElement for finding empty nodes in the DOM.',
  274. );
  275. checkHtmlElement(element, toBeEmpty, this);
  276. return {
  277. pass: element.innerHTML === '',
  278. message: () => {
  279. return [
  280. this.utils.matcherHint(
  281. `${this.isNot ? '.not' : ''}.toBeEmpty`,
  282. 'element',
  283. '',
  284. ),
  285. '',
  286. 'Received:',
  287. ` ${this.utils.printReceived(element.innerHTML)}`,
  288. ].join('\n')
  289. },
  290. }
  291. }
  292. function toBeEmptyDOMElement(element) {
  293. checkHtmlElement(element, toBeEmptyDOMElement, this);
  294. return {
  295. pass: isEmptyElement(element),
  296. message: () => {
  297. return [
  298. this.utils.matcherHint(
  299. `${this.isNot ? '.not' : ''}.toBeEmptyDOMElement`,
  300. 'element',
  301. '',
  302. ),
  303. '',
  304. 'Received:',
  305. ` ${this.utils.printReceived(element.innerHTML)}`,
  306. ].join('\n')
  307. },
  308. }
  309. }
  310. /**
  311. * Identifies if an element doesn't contain child nodes (excluding comments)
  312. * ℹ Node.COMMENT_NODE can't be used because of the following issue
  313. * https://github.com/jsdom/jsdom/issues/2220
  314. *
  315. * @param {*} element an HtmlElement or SVGElement
  316. * @return {*} true if the element only contains comments or none
  317. */
  318. function isEmptyElement(element){
  319. const nonCommentChildNodes = [...element.childNodes].filter(node => node.nodeType !== 8);
  320. return nonCommentChildNodes.length === 0;
  321. }
  322. function toContainElement(container, element) {
  323. checkHtmlElement(container, toContainElement, this);
  324. if (element !== null) {
  325. checkHtmlElement(element, toContainElement, this);
  326. }
  327. return {
  328. pass: container.contains(element),
  329. message: () => {
  330. return [
  331. this.utils.matcherHint(
  332. `${this.isNot ? '.not' : ''}.toContainElement`,
  333. 'element',
  334. 'element',
  335. ),
  336. '',
  337. // eslint-disable-next-line new-cap
  338. this.utils.RECEIVED_COLOR(`${this.utils.stringify(
  339. container.cloneNode(false),
  340. )} ${
  341. this.isNot ? 'contains:' : 'does not contain:'
  342. } ${this.utils.stringify(element ? element.cloneNode(false) : element)}
  343. `),
  344. ].join('\n')
  345. },
  346. }
  347. }
  348. function getNormalizedHtml(container, htmlText) {
  349. const div = container.ownerDocument.createElement('div');
  350. div.innerHTML = htmlText;
  351. return div.innerHTML
  352. }
  353. function toContainHTML(container, htmlText) {
  354. checkHtmlElement(container, toContainHTML, this);
  355. if (typeof htmlText !== 'string') {
  356. throw new Error(`.toContainHTML() expects a string value, got ${htmlText}`)
  357. }
  358. return {
  359. pass: container.outerHTML.includes(getNormalizedHtml(container, htmlText)),
  360. message: () => {
  361. return [
  362. this.utils.matcherHint(
  363. `${this.isNot ? '.not' : ''}.toContainHTML`,
  364. 'element',
  365. '',
  366. ),
  367. 'Expected:',
  368. // eslint-disable-next-line new-cap
  369. ` ${this.utils.EXPECTED_COLOR(htmlText)}`,
  370. 'Received:',
  371. ` ${this.utils.printReceived(container.cloneNode(true))}`,
  372. ].join('\n')
  373. },
  374. }
  375. }
  376. function toHaveTextContent(
  377. node,
  378. checkWith,
  379. options = {normalizeWhitespace: true},
  380. ) {
  381. checkNode(node, toHaveTextContent, this);
  382. const textContent = options.normalizeWhitespace
  383. ? normalize(node.textContent)
  384. : node.textContent.replace(/\u00a0/g, ' '); // Replace   with normal spaces
  385. const checkingWithEmptyString = textContent !== '' && checkWith === '';
  386. return {
  387. pass: !checkingWithEmptyString && matches(textContent, checkWith),
  388. message: () => {
  389. const to = this.isNot ? 'not to' : 'to';
  390. return getMessage(
  391. this,
  392. this.utils.matcherHint(
  393. `${this.isNot ? '.not' : ''}.toHaveTextContent`,
  394. 'element',
  395. '',
  396. ),
  397. checkingWithEmptyString
  398. ? `Checking with empty string will always match, use .toBeEmptyDOMElement() instead`
  399. : `Expected element ${to} have text content`,
  400. checkWith,
  401. 'Received',
  402. textContent,
  403. )
  404. },
  405. }
  406. }
  407. function toHaveAccessibleDescription(
  408. htmlElement,
  409. expectedAccessibleDescription,
  410. ) {
  411. checkHtmlElement(htmlElement, toHaveAccessibleDescription, this);
  412. const actualAccessibleDescription = computeAccessibleDescription(htmlElement);
  413. const missingExpectedValue = arguments.length === 1;
  414. let pass = false;
  415. if (missingExpectedValue) {
  416. // When called without an expected value we only want to validate that the element has an
  417. // accessible description, whatever it may be.
  418. pass = actualAccessibleDescription !== '';
  419. } else {
  420. pass =
  421. expectedAccessibleDescription instanceof RegExp
  422. ? expectedAccessibleDescription.test(actualAccessibleDescription)
  423. : this.equals(
  424. actualAccessibleDescription,
  425. expectedAccessibleDescription,
  426. );
  427. }
  428. return {
  429. pass,
  430. message: () => {
  431. const to = this.isNot ? 'not to' : 'to';
  432. return getMessage(
  433. this,
  434. this.utils.matcherHint(
  435. `${this.isNot ? '.not' : ''}.${toHaveAccessibleDescription.name}`,
  436. 'element',
  437. '',
  438. ),
  439. `Expected element ${to} have accessible description`,
  440. expectedAccessibleDescription,
  441. 'Received',
  442. actualAccessibleDescription,
  443. )
  444. },
  445. }
  446. }
  447. const ariaInvalidName = 'aria-invalid';
  448. const validStates = ['false'];
  449. // See `aria-errormessage` spec at https://www.w3.org/TR/wai-aria-1.2/#aria-errormessage
  450. function toHaveAccessibleErrorMessage(
  451. htmlElement,
  452. expectedAccessibleErrorMessage,
  453. ) {
  454. checkHtmlElement(htmlElement, toHaveAccessibleErrorMessage, this);
  455. const to = this.isNot ? 'not to' : 'to';
  456. const method = this.isNot
  457. ? '.not.toHaveAccessibleErrorMessage'
  458. : '.toHaveAccessibleErrorMessage';
  459. // Enforce Valid Id
  460. const errormessageId = htmlElement.getAttribute('aria-errormessage');
  461. const errormessageIdInvalid = !!errormessageId && /\s+/.test(errormessageId);
  462. if (errormessageIdInvalid) {
  463. return {
  464. pass: false,
  465. message: () => {
  466. return getMessage(
  467. this,
  468. this.utils.matcherHint(method, 'element'),
  469. "Expected element's `aria-errormessage` attribute to be empty or a single, valid ID",
  470. '',
  471. 'Received',
  472. `aria-errormessage="${errormessageId}"`,
  473. )
  474. },
  475. }
  476. }
  477. // See `aria-invalid` spec at https://www.w3.org/TR/wai-aria-1.2/#aria-invalid
  478. const ariaInvalidVal = htmlElement.getAttribute(ariaInvalidName);
  479. const fieldValid =
  480. !htmlElement.hasAttribute(ariaInvalidName) ||
  481. validStates.includes(ariaInvalidVal);
  482. // Enforce Valid `aria-invalid` Attribute
  483. if (fieldValid) {
  484. return {
  485. pass: false,
  486. message: () => {
  487. return getMessage(
  488. this,
  489. this.utils.matcherHint(method, 'element'),
  490. 'Expected element to be marked as invalid with attribute',
  491. `${ariaInvalidName}="${String(true)}"`,
  492. 'Received',
  493. htmlElement.hasAttribute('aria-invalid')
  494. ? `${ariaInvalidName}="${htmlElement.getAttribute(ariaInvalidName)}`
  495. : null,
  496. )
  497. },
  498. }
  499. }
  500. const error = normalize(
  501. htmlElement.ownerDocument.getElementById(errormessageId)?.textContent ?? '',
  502. );
  503. return {
  504. pass:
  505. expectedAccessibleErrorMessage === undefined
  506. ? Boolean(error)
  507. : expectedAccessibleErrorMessage instanceof RegExp
  508. ? expectedAccessibleErrorMessage.test(error)
  509. : this.equals(error, expectedAccessibleErrorMessage),
  510. message: () => {
  511. return getMessage(
  512. this,
  513. this.utils.matcherHint(method, 'element'),
  514. `Expected element ${to} have accessible error message`,
  515. expectedAccessibleErrorMessage ?? '',
  516. 'Received',
  517. error,
  518. )
  519. },
  520. }
  521. }
  522. function toHaveAccessibleName(htmlElement, expectedAccessibleName) {
  523. checkHtmlElement(htmlElement, toHaveAccessibleName, this);
  524. const actualAccessibleName = computeAccessibleName(htmlElement);
  525. const missingExpectedValue = arguments.length === 1;
  526. let pass = false;
  527. if (missingExpectedValue) {
  528. // When called without an expected value we only want to validate that the element has an
  529. // accessible name, whatever it may be.
  530. pass = actualAccessibleName !== '';
  531. } else {
  532. pass =
  533. expectedAccessibleName instanceof RegExp
  534. ? expectedAccessibleName.test(actualAccessibleName)
  535. : this.equals(actualAccessibleName, expectedAccessibleName);
  536. }
  537. return {
  538. pass,
  539. message: () => {
  540. const to = this.isNot ? 'not to' : 'to';
  541. return getMessage(
  542. this,
  543. this.utils.matcherHint(
  544. `${this.isNot ? '.not' : ''}.${toHaveAccessibleName.name}`,
  545. 'element',
  546. '',
  547. ),
  548. `Expected element ${to} have accessible name`,
  549. expectedAccessibleName,
  550. 'Received',
  551. actualAccessibleName,
  552. )
  553. },
  554. }
  555. }
  556. function printAttribute(stringify, name, value) {
  557. return value === undefined ? name : `${name}=${stringify(value)}`
  558. }
  559. function getAttributeComment(stringify, name, value) {
  560. return value === undefined
  561. ? `element.hasAttribute(${stringify(name)})`
  562. : `element.getAttribute(${stringify(name)}) === ${stringify(value)}`
  563. }
  564. function toHaveAttribute(htmlElement, name, expectedValue) {
  565. checkHtmlElement(htmlElement, toHaveAttribute, this);
  566. const isExpectedValuePresent = expectedValue !== undefined;
  567. const hasAttribute = htmlElement.hasAttribute(name);
  568. const receivedValue = htmlElement.getAttribute(name);
  569. return {
  570. pass: isExpectedValuePresent
  571. ? hasAttribute && this.equals(receivedValue, expectedValue)
  572. : hasAttribute,
  573. message: () => {
  574. const to = this.isNot ? 'not to' : 'to';
  575. const receivedAttribute = hasAttribute
  576. ? printAttribute(this.utils.stringify, name, receivedValue)
  577. : null;
  578. const matcher = this.utils.matcherHint(
  579. `${this.isNot ? '.not' : ''}.toHaveAttribute`,
  580. 'element',
  581. this.utils.printExpected(name),
  582. {
  583. secondArgument: isExpectedValuePresent
  584. ? this.utils.printExpected(expectedValue)
  585. : undefined,
  586. comment: getAttributeComment(
  587. this.utils.stringify,
  588. name,
  589. expectedValue,
  590. ),
  591. },
  592. );
  593. return getMessage(
  594. this,
  595. matcher,
  596. `Expected the element ${to} have attribute`,
  597. printAttribute(this.utils.stringify, name, expectedValue),
  598. 'Received',
  599. receivedAttribute,
  600. )
  601. },
  602. }
  603. }
  604. function getExpectedClassNamesAndOptions(params) {
  605. const lastParam = params.pop();
  606. let expectedClassNames, options;
  607. if (typeof lastParam === 'object') {
  608. expectedClassNames = params;
  609. options = lastParam;
  610. } else {
  611. expectedClassNames = params.concat(lastParam);
  612. options = {exact: false};
  613. }
  614. return {expectedClassNames, options}
  615. }
  616. function splitClassNames(str) {
  617. if (!str) {
  618. return []
  619. }
  620. return str.split(/\s+/).filter(s => s.length > 0)
  621. }
  622. function isSubset$1(subset, superset) {
  623. return subset.every(item => superset.includes(item))
  624. }
  625. function toHaveClass(htmlElement, ...params) {
  626. checkHtmlElement(htmlElement, toHaveClass, this);
  627. const {expectedClassNames, options} = getExpectedClassNamesAndOptions(params);
  628. const received = splitClassNames(htmlElement.getAttribute('class'));
  629. const expected = expectedClassNames.reduce(
  630. (acc, className) => acc.concat(splitClassNames(className)),
  631. [],
  632. );
  633. if (options.exact) {
  634. return {
  635. pass: isSubset$1(expected, received) && expected.length === received.length,
  636. message: () => {
  637. const to = this.isNot ? 'not to' : 'to';
  638. return getMessage(
  639. this,
  640. this.utils.matcherHint(
  641. `${this.isNot ? '.not' : ''}.toHaveClass`,
  642. 'element',
  643. this.utils.printExpected(expected.join(' ')),
  644. ),
  645. `Expected the element ${to} have EXACTLY defined classes`,
  646. expected.join(' '),
  647. 'Received',
  648. received.join(' '),
  649. )
  650. },
  651. }
  652. }
  653. return expected.length > 0
  654. ? {
  655. pass: isSubset$1(expected, received),
  656. message: () => {
  657. const to = this.isNot ? 'not to' : 'to';
  658. return getMessage(
  659. this,
  660. this.utils.matcherHint(
  661. `${this.isNot ? '.not' : ''}.toHaveClass`,
  662. 'element',
  663. this.utils.printExpected(expected.join(' ')),
  664. ),
  665. `Expected the element ${to} have class`,
  666. expected.join(' '),
  667. 'Received',
  668. received.join(' '),
  669. )
  670. },
  671. }
  672. : {
  673. pass: this.isNot ? received.length > 0 : false,
  674. message: () =>
  675. this.isNot
  676. ? getMessage(
  677. this,
  678. this.utils.matcherHint('.not.toHaveClass', 'element', ''),
  679. 'Expected the element to have classes',
  680. '(none)',
  681. 'Received',
  682. received.join(' '),
  683. )
  684. : [
  685. this.utils.matcherHint(`.toHaveClass`, 'element'),
  686. 'At least one expected class must be provided.',
  687. ].join('\n'),
  688. }
  689. }
  690. function getStyleDeclaration(document, css) {
  691. const styles = {};
  692. // The next block is necessary to normalize colors
  693. const copy = document.createElement('div');
  694. Object.keys(css).forEach(property => {
  695. copy.style[property] = css[property];
  696. styles[property] = copy.style[property];
  697. });
  698. return styles
  699. }
  700. function isSubset(styles, computedStyle) {
  701. return (
  702. !!Object.keys(styles).length &&
  703. Object.entries(styles).every(([prop, value]) => {
  704. const isCustomProperty = prop.startsWith('--');
  705. const spellingVariants = [prop];
  706. if (!isCustomProperty) spellingVariants.push(prop.toLowerCase());
  707. return spellingVariants.some(
  708. name =>
  709. computedStyle[name] === value ||
  710. computedStyle.getPropertyValue(name) === value,
  711. )
  712. })
  713. )
  714. }
  715. function printoutStyles(styles) {
  716. return Object.keys(styles)
  717. .sort()
  718. .map(prop => `${prop}: ${styles[prop]};`)
  719. .join('\n')
  720. }
  721. // Highlights only style rules that were expected but were not found in the
  722. // received computed styles
  723. function expectedDiff(diffFn, expected, computedStyles) {
  724. const received = Array.from(computedStyles)
  725. .filter(prop => expected[prop] !== undefined)
  726. .reduce(
  727. (obj, prop) =>
  728. Object.assign(obj, {[prop]: computedStyles.getPropertyValue(prop)}),
  729. {},
  730. );
  731. const diffOutput = diffFn(printoutStyles(expected), printoutStyles(received));
  732. // Remove the "+ Received" annotation because this is a one-way diff
  733. return diffOutput.replace(`${chalk.red('+ Received')}\n`, '')
  734. }
  735. function toHaveStyle(htmlElement, css) {
  736. checkHtmlElement(htmlElement, toHaveStyle, this);
  737. const parsedCSS =
  738. typeof css === 'object' ? css : parseCSS(css, toHaveStyle, this);
  739. const {getComputedStyle} = htmlElement.ownerDocument.defaultView;
  740. const expected = getStyleDeclaration(htmlElement.ownerDocument, parsedCSS);
  741. const received = getComputedStyle(htmlElement);
  742. return {
  743. pass: isSubset(expected, received),
  744. message: () => {
  745. const matcher = `${this.isNot ? '.not' : ''}.toHaveStyle`;
  746. return [
  747. this.utils.matcherHint(matcher, 'element', ''),
  748. expectedDiff(this.utils.diff, expected, received),
  749. ].join('\n\n')
  750. },
  751. }
  752. }
  753. function toHaveFocus(element) {
  754. checkHtmlElement(element, toHaveFocus, this);
  755. return {
  756. pass: element.ownerDocument.activeElement === element,
  757. message: () => {
  758. return [
  759. this.utils.matcherHint(
  760. `${this.isNot ? '.not' : ''}.toHaveFocus`,
  761. 'element',
  762. '',
  763. ),
  764. '',
  765. ...(this.isNot
  766. ? [
  767. 'Received element is focused:',
  768. ` ${this.utils.printReceived(element)}`,
  769. ]
  770. : [
  771. 'Expected element with focus:',
  772. ` ${this.utils.printExpected(element)}`,
  773. 'Received element with focus:',
  774. ` ${this.utils.printReceived(
  775. element.ownerDocument.activeElement,
  776. )}`,
  777. ]),
  778. ].join('\n')
  779. },
  780. }
  781. }
  782. // Returns the combined value of several elements that have the same name
  783. // e.g. radio buttons or groups of checkboxes
  784. function getMultiElementValue(elements) {
  785. const types = uniq(elements.map(element => element.type));
  786. if (types.length !== 1) {
  787. throw new Error(
  788. 'Multiple form elements with the same name must be of the same type',
  789. )
  790. }
  791. switch (types[0]) {
  792. case 'radio': {
  793. const theChosenOne = elements.find(radio => radio.checked);
  794. return theChosenOne ? theChosenOne.value : undefined
  795. }
  796. case 'checkbox':
  797. return elements
  798. .filter(checkbox => checkbox.checked)
  799. .map(checkbox => checkbox.value)
  800. default:
  801. // NOTE: Not even sure this is a valid use case, but just in case...
  802. return elements.map(element => element.value)
  803. }
  804. }
  805. function getFormValue(container, name) {
  806. const elements = [...container.querySelectorAll(`[name="${escape(name)}"]`)];
  807. /* istanbul ignore if */
  808. if (elements.length === 0) {
  809. return undefined // shouldn't happen, but just in case
  810. }
  811. switch (elements.length) {
  812. case 1:
  813. return getSingleElementValue(elements[0])
  814. default:
  815. return getMultiElementValue(elements)
  816. }
  817. }
  818. // Strips the `[]` suffix off a form value name
  819. function getPureName(name) {
  820. return /\[\]$/.test(name) ? name.slice(0, -2) : name
  821. }
  822. function getAllFormValues(container) {
  823. const names = Array.from(container.elements).map(element => element.name);
  824. return names.reduce(
  825. (obj, name) => ({
  826. ...obj,
  827. [getPureName(name)]: getFormValue(container, name),
  828. }),
  829. {},
  830. )
  831. }
  832. function toHaveFormValues(formElement, expectedValues) {
  833. checkHtmlElement(formElement, toHaveFormValues, this);
  834. if (!formElement.elements) {
  835. // TODO: Change condition to use instanceof against the appropriate element classes instead
  836. throw new Error('toHaveFormValues must be called on a form or a fieldset')
  837. }
  838. const formValues = getAllFormValues(formElement);
  839. return {
  840. pass: Object.entries(expectedValues).every(([name, expectedValue]) =>
  841. isEqualWith(formValues[name], expectedValue, compareArraysAsSet),
  842. ),
  843. message: () => {
  844. const to = this.isNot ? 'not to' : 'to';
  845. const matcher = `${this.isNot ? '.not' : ''}.toHaveFormValues`;
  846. const commonKeyValues = Object.keys(formValues)
  847. .filter(key => expectedValues.hasOwnProperty(key))
  848. .reduce((obj, key) => ({...obj, [key]: formValues[key]}), {});
  849. return [
  850. this.utils.matcherHint(matcher, 'element', ''),
  851. `Expected the element ${to} have form values`,
  852. this.utils.diff(expectedValues, commonKeyValues),
  853. ].join('\n\n')
  854. },
  855. }
  856. }
  857. function isStyleVisible(element) {
  858. const {getComputedStyle} = element.ownerDocument.defaultView;
  859. const {display, visibility, opacity} = getComputedStyle(element);
  860. return (
  861. display !== 'none' &&
  862. visibility !== 'hidden' &&
  863. visibility !== 'collapse' &&
  864. opacity !== '0' &&
  865. opacity !== 0
  866. )
  867. }
  868. function isAttributeVisible(element, previousElement) {
  869. let detailsVisibility;
  870. if (previousElement) {
  871. detailsVisibility =
  872. element.nodeName === 'DETAILS' && previousElement.nodeName !== 'SUMMARY'
  873. ? element.hasAttribute('open')
  874. : true;
  875. } else {
  876. detailsVisibility =
  877. element.nodeName === 'DETAILS' ? element.hasAttribute('open') : true;
  878. }
  879. return !element.hasAttribute('hidden') && detailsVisibility
  880. }
  881. function isElementVisible(element, previousElement) {
  882. return (
  883. isStyleVisible(element) &&
  884. isAttributeVisible(element, previousElement) &&
  885. (!element.parentElement || isElementVisible(element.parentElement, element))
  886. )
  887. }
  888. function toBeVisible(element) {
  889. checkHtmlElement(element, toBeVisible, this);
  890. const isInDocument =
  891. element.ownerDocument === element.getRootNode({composed: true});
  892. const isVisible = isInDocument && isElementVisible(element);
  893. return {
  894. pass: isVisible,
  895. message: () => {
  896. const is = isVisible ? 'is' : 'is not';
  897. return [
  898. this.utils.matcherHint(
  899. `${this.isNot ? '.not' : ''}.toBeVisible`,
  900. 'element',
  901. '',
  902. ),
  903. '',
  904. `Received element ${is} visible${
  905. isInDocument ? '' : ' (element is not in the document)'
  906. }:`,
  907. ` ${this.utils.printReceived(element.cloneNode(false))}`,
  908. ].join('\n')
  909. },
  910. }
  911. }
  912. // form elements that support 'disabled'
  913. const FORM_TAGS$2 = [
  914. 'fieldset',
  915. 'input',
  916. 'select',
  917. 'optgroup',
  918. 'option',
  919. 'button',
  920. 'textarea',
  921. ];
  922. /*
  923. * According to specification:
  924. * If <fieldset> is disabled, the form controls that are its descendants,
  925. * except descendants of its first optional <legend> element, are disabled
  926. *
  927. * https://html.spec.whatwg.org/multipage/form-elements.html#concept-fieldset-disabled
  928. *
  929. * This method tests whether element is first legend child of fieldset parent
  930. */
  931. function isFirstLegendChildOfFieldset(element, parent) {
  932. return (
  933. getTag(element) === 'legend' &&
  934. getTag(parent) === 'fieldset' &&
  935. element.isSameNode(
  936. Array.from(parent.children).find(child => getTag(child) === 'legend'),
  937. )
  938. )
  939. }
  940. function isElementDisabledByParent(element, parent) {
  941. return (
  942. isElementDisabled(parent) && !isFirstLegendChildOfFieldset(element, parent)
  943. )
  944. }
  945. function isCustomElement(tag) {
  946. return tag.includes('-')
  947. }
  948. /*
  949. * Only certain form elements and custom elements can actually be disabled:
  950. * https://html.spec.whatwg.org/multipage/semantics-other.html#disabled-elements
  951. */
  952. function canElementBeDisabled(element) {
  953. const tag = getTag(element);
  954. return FORM_TAGS$2.includes(tag) || isCustomElement(tag)
  955. }
  956. function isElementDisabled(element) {
  957. return canElementBeDisabled(element) && element.hasAttribute('disabled')
  958. }
  959. function isAncestorDisabled(element) {
  960. const parent = element.parentElement;
  961. return (
  962. Boolean(parent) &&
  963. (isElementDisabledByParent(element, parent) || isAncestorDisabled(parent))
  964. )
  965. }
  966. function isElementOrAncestorDisabled(element) {
  967. return (
  968. canElementBeDisabled(element) &&
  969. (isElementDisabled(element) || isAncestorDisabled(element))
  970. )
  971. }
  972. function toBeDisabled(element) {
  973. checkHtmlElement(element, toBeDisabled, this);
  974. const isDisabled = isElementOrAncestorDisabled(element);
  975. return {
  976. pass: isDisabled,
  977. message: () => {
  978. const is = isDisabled ? 'is' : 'is not';
  979. return [
  980. this.utils.matcherHint(
  981. `${this.isNot ? '.not' : ''}.toBeDisabled`,
  982. 'element',
  983. '',
  984. ),
  985. '',
  986. `Received element ${is} disabled:`,
  987. ` ${this.utils.printReceived(element.cloneNode(false))}`,
  988. ].join('\n')
  989. },
  990. }
  991. }
  992. function toBeEnabled(element) {
  993. checkHtmlElement(element, toBeEnabled, this);
  994. const isEnabled = !isElementOrAncestorDisabled(element);
  995. return {
  996. pass: isEnabled,
  997. message: () => {
  998. const is = isEnabled ? 'is' : 'is not';
  999. return [
  1000. this.utils.matcherHint(
  1001. `${this.isNot ? '.not' : ''}.toBeEnabled`,
  1002. 'element',
  1003. '',
  1004. ),
  1005. '',
  1006. `Received element ${is} enabled:`,
  1007. ` ${this.utils.printReceived(element.cloneNode(false))}`,
  1008. ].join('\n')
  1009. },
  1010. }
  1011. }
  1012. // form elements that support 'required'
  1013. const FORM_TAGS$1 = ['select', 'textarea'];
  1014. const ARIA_FORM_TAGS = ['input', 'select', 'textarea'];
  1015. const UNSUPPORTED_INPUT_TYPES = [
  1016. 'color',
  1017. 'hidden',
  1018. 'range',
  1019. 'submit',
  1020. 'image',
  1021. 'reset',
  1022. ];
  1023. const SUPPORTED_ARIA_ROLES = [
  1024. 'combobox',
  1025. 'gridcell',
  1026. 'radiogroup',
  1027. 'spinbutton',
  1028. 'tree',
  1029. ];
  1030. function isRequiredOnFormTagsExceptInput(element) {
  1031. return FORM_TAGS$1.includes(getTag(element)) && element.hasAttribute('required')
  1032. }
  1033. function isRequiredOnSupportedInput(element) {
  1034. return (
  1035. getTag(element) === 'input' &&
  1036. element.hasAttribute('required') &&
  1037. ((element.hasAttribute('type') &&
  1038. !UNSUPPORTED_INPUT_TYPES.includes(element.getAttribute('type'))) ||
  1039. !element.hasAttribute('type'))
  1040. )
  1041. }
  1042. function isElementRequiredByARIA(element) {
  1043. return (
  1044. element.hasAttribute('aria-required') &&
  1045. element.getAttribute('aria-required') === 'true' &&
  1046. (ARIA_FORM_TAGS.includes(getTag(element)) ||
  1047. (element.hasAttribute('role') &&
  1048. SUPPORTED_ARIA_ROLES.includes(element.getAttribute('role'))))
  1049. )
  1050. }
  1051. function toBeRequired(element) {
  1052. checkHtmlElement(element, toBeRequired, this);
  1053. const isRequired =
  1054. isRequiredOnFormTagsExceptInput(element) ||
  1055. isRequiredOnSupportedInput(element) ||
  1056. isElementRequiredByARIA(element);
  1057. return {
  1058. pass: isRequired,
  1059. message: () => {
  1060. const is = isRequired ? 'is' : 'is not';
  1061. return [
  1062. this.utils.matcherHint(
  1063. `${this.isNot ? '.not' : ''}.toBeRequired`,
  1064. 'element',
  1065. '',
  1066. ),
  1067. '',
  1068. `Received element ${is} required:`,
  1069. ` ${this.utils.printReceived(element.cloneNode(false))}`,
  1070. ].join('\n')
  1071. },
  1072. }
  1073. }
  1074. const FORM_TAGS = ['form', 'input', 'select', 'textarea'];
  1075. function isElementHavingAriaInvalid(element) {
  1076. return (
  1077. element.hasAttribute('aria-invalid') &&
  1078. element.getAttribute('aria-invalid') !== 'false'
  1079. )
  1080. }
  1081. function isSupportsValidityMethod(element) {
  1082. return FORM_TAGS.includes(getTag(element))
  1083. }
  1084. function isElementInvalid(element) {
  1085. const isHaveAriaInvalid = isElementHavingAriaInvalid(element);
  1086. if (isSupportsValidityMethod(element)) {
  1087. return isHaveAriaInvalid || !element.checkValidity()
  1088. } else {
  1089. return isHaveAriaInvalid
  1090. }
  1091. }
  1092. function toBeInvalid(element) {
  1093. checkHtmlElement(element, toBeInvalid, this);
  1094. const isInvalid = isElementInvalid(element);
  1095. return {
  1096. pass: isInvalid,
  1097. message: () => {
  1098. const is = isInvalid ? 'is' : 'is not';
  1099. return [
  1100. this.utils.matcherHint(
  1101. `${this.isNot ? '.not' : ''}.toBeInvalid`,
  1102. 'element',
  1103. '',
  1104. ),
  1105. '',
  1106. `Received element ${is} currently invalid:`,
  1107. ` ${this.utils.printReceived(element.cloneNode(false))}`,
  1108. ].join('\n')
  1109. },
  1110. }
  1111. }
  1112. function toBeValid(element) {
  1113. checkHtmlElement(element, toBeValid, this);
  1114. const isValid = !isElementInvalid(element);
  1115. return {
  1116. pass: isValid,
  1117. message: () => {
  1118. const is = isValid ? 'is' : 'is not';
  1119. return [
  1120. this.utils.matcherHint(
  1121. `${this.isNot ? '.not' : ''}.toBeValid`,
  1122. 'element',
  1123. '',
  1124. ),
  1125. '',
  1126. `Received element ${is} currently valid:`,
  1127. ` ${this.utils.printReceived(element.cloneNode(false))}`,
  1128. ].join('\n')
  1129. },
  1130. }
  1131. }
  1132. function toHaveValue(htmlElement, expectedValue) {
  1133. checkHtmlElement(htmlElement, toHaveValue, this);
  1134. if (
  1135. htmlElement.tagName.toLowerCase() === 'input' &&
  1136. ['checkbox', 'radio'].includes(htmlElement.type)
  1137. ) {
  1138. throw new Error(
  1139. 'input with type=checkbox or type=radio cannot be used with .toHaveValue(). Use .toBeChecked() for type=checkbox or .toHaveFormValues() instead',
  1140. )
  1141. }
  1142. const receivedValue = getSingleElementValue(htmlElement);
  1143. const expectsValue = expectedValue !== undefined;
  1144. let expectedTypedValue = expectedValue;
  1145. let receivedTypedValue = receivedValue;
  1146. if (expectedValue == receivedValue && expectedValue !== receivedValue) {
  1147. expectedTypedValue = `${expectedValue} (${typeof expectedValue})`;
  1148. receivedTypedValue = `${receivedValue} (${typeof receivedValue})`;
  1149. }
  1150. return {
  1151. pass: expectsValue
  1152. ? isEqualWith(receivedValue, expectedValue, compareArraysAsSet)
  1153. : Boolean(receivedValue),
  1154. message: () => {
  1155. const to = this.isNot ? 'not to' : 'to';
  1156. const matcher = this.utils.matcherHint(
  1157. `${this.isNot ? '.not' : ''}.toHaveValue`,
  1158. 'element',
  1159. expectedValue,
  1160. );
  1161. return getMessage(
  1162. this,
  1163. matcher,
  1164. `Expected the element ${to} have value`,
  1165. expectsValue ? expectedTypedValue : '(any)',
  1166. 'Received',
  1167. receivedTypedValue,
  1168. )
  1169. },
  1170. }
  1171. }
  1172. function toHaveDisplayValue(htmlElement, expectedValue) {
  1173. checkHtmlElement(htmlElement, toHaveDisplayValue, this);
  1174. const tagName = htmlElement.tagName.toLowerCase();
  1175. if (!['select', 'input', 'textarea'].includes(tagName)) {
  1176. throw new Error(
  1177. '.toHaveDisplayValue() currently supports only input, textarea or select elements, try with another matcher instead.',
  1178. )
  1179. }
  1180. if (tagName === 'input' && ['radio', 'checkbox'].includes(htmlElement.type)) {
  1181. throw new Error(
  1182. `.toHaveDisplayValue() currently does not support input[type="${htmlElement.type}"], try with another matcher instead.`,
  1183. )
  1184. }
  1185. const values = getValues(tagName, htmlElement);
  1186. const expectedValues = getExpectedValues(expectedValue);
  1187. const numberOfMatchesWithValues = expectedValues.filter(expected =>
  1188. values.some(value =>
  1189. expected instanceof RegExp
  1190. ? expected.test(value)
  1191. : this.equals(value, String(expected)),
  1192. ),
  1193. ).length;
  1194. const matchedWithAllValues = numberOfMatchesWithValues === values.length;
  1195. const matchedWithAllExpectedValues =
  1196. numberOfMatchesWithValues === expectedValues.length;
  1197. return {
  1198. pass: matchedWithAllValues && matchedWithAllExpectedValues,
  1199. message: () =>
  1200. getMessage(
  1201. this,
  1202. this.utils.matcherHint(
  1203. `${this.isNot ? '.not' : ''}.toHaveDisplayValue`,
  1204. 'element',
  1205. '',
  1206. ),
  1207. `Expected element ${this.isNot ? 'not ' : ''}to have display value`,
  1208. expectedValue,
  1209. 'Received',
  1210. values,
  1211. ),
  1212. }
  1213. }
  1214. function getValues(tagName, htmlElement) {
  1215. return tagName === 'select'
  1216. ? Array.from(htmlElement)
  1217. .filter(option => option.selected)
  1218. .map(option => option.textContent)
  1219. : [htmlElement.value]
  1220. }
  1221. function getExpectedValues(expectedValue) {
  1222. return expectedValue instanceof Array ? expectedValue : [expectedValue]
  1223. }
  1224. function toBeChecked(element) {
  1225. checkHtmlElement(element, toBeChecked, this);
  1226. const isValidInput = () => {
  1227. return (
  1228. element.tagName.toLowerCase() === 'input' &&
  1229. ['checkbox', 'radio'].includes(element.type)
  1230. )
  1231. };
  1232. const isValidAriaElement = () => {
  1233. return (
  1234. roleSupportsChecked(element.getAttribute('role')) &&
  1235. ['true', 'false'].includes(element.getAttribute('aria-checked'))
  1236. )
  1237. };
  1238. if (!isValidInput() && !isValidAriaElement()) {
  1239. return {
  1240. pass: false,
  1241. message: () =>
  1242. `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`,
  1243. }
  1244. }
  1245. const isChecked = () => {
  1246. if (isValidInput()) return element.checked
  1247. return element.getAttribute('aria-checked') === 'true'
  1248. };
  1249. return {
  1250. pass: isChecked(),
  1251. message: () => {
  1252. const is = isChecked() ? 'is' : 'is not';
  1253. return [
  1254. this.utils.matcherHint(
  1255. `${this.isNot ? '.not' : ''}.toBeChecked`,
  1256. 'element',
  1257. '',
  1258. ),
  1259. '',
  1260. `Received element ${is} checked:`,
  1261. ` ${this.utils.printReceived(element.cloneNode(false))}`,
  1262. ].join('\n')
  1263. },
  1264. }
  1265. }
  1266. function supportedRolesSentence() {
  1267. return toSentence(
  1268. supportedRoles().map(role => `role="${role}"`),
  1269. {lastWordConnector: ' or '},
  1270. )
  1271. }
  1272. function supportedRoles() {
  1273. return roles.keys().filter(roleSupportsChecked)
  1274. }
  1275. function roleSupportsChecked(role) {
  1276. return roles.get(role)?.props['aria-checked'] !== undefined
  1277. }
  1278. function toBePartiallyChecked(element) {
  1279. checkHtmlElement(element, toBePartiallyChecked, this);
  1280. const isValidInput = () => {
  1281. return (
  1282. element.tagName.toLowerCase() === 'input' && element.type === 'checkbox'
  1283. )
  1284. };
  1285. const isValidAriaElement = () => {
  1286. return element.getAttribute('role') === 'checkbox'
  1287. };
  1288. if (!isValidInput() && !isValidAriaElement()) {
  1289. return {
  1290. pass: false,
  1291. message: () =>
  1292. 'only inputs with type="checkbox" or elements with role="checkbox" and a valid aria-checked attribute can be used with .toBePartiallyChecked(). Use .toHaveValue() instead',
  1293. }
  1294. }
  1295. const isPartiallyChecked = () => {
  1296. const isAriaMixed = element.getAttribute('aria-checked') === 'mixed';
  1297. if (isValidInput()) {
  1298. return element.indeterminate || isAriaMixed
  1299. }
  1300. return isAriaMixed
  1301. };
  1302. return {
  1303. pass: isPartiallyChecked(),
  1304. message: () => {
  1305. const is = isPartiallyChecked() ? 'is' : 'is not';
  1306. return [
  1307. this.utils.matcherHint(
  1308. `${this.isNot ? '.not' : ''}.toBePartiallyChecked`,
  1309. 'element',
  1310. '',
  1311. ),
  1312. '',
  1313. `Received element ${is} partially checked:`,
  1314. ` ${this.utils.printReceived(element.cloneNode(false))}`,
  1315. ].join('\n')
  1316. },
  1317. }
  1318. }
  1319. // See algoritm: https://www.w3.org/TR/accname-1.1/#mapping_additional_nd_description
  1320. function toHaveDescription(htmlElement, checkWith) {
  1321. deprecate(
  1322. 'toHaveDescription',
  1323. 'Please use toHaveAccessibleDescription.',
  1324. );
  1325. checkHtmlElement(htmlElement, toHaveDescription, this);
  1326. const expectsDescription = checkWith !== undefined;
  1327. const descriptionIDRaw = htmlElement.getAttribute('aria-describedby') || '';
  1328. const descriptionIDs = descriptionIDRaw.split(/\s+/).filter(Boolean);
  1329. let description = '';
  1330. if (descriptionIDs.length > 0) {
  1331. const document = htmlElement.ownerDocument;
  1332. const descriptionEls = descriptionIDs
  1333. .map(descriptionID => document.getElementById(descriptionID))
  1334. .filter(Boolean);
  1335. description = normalize(descriptionEls.map(el => el.textContent).join(' '));
  1336. }
  1337. return {
  1338. pass: expectsDescription
  1339. ? checkWith instanceof RegExp
  1340. ? checkWith.test(description)
  1341. : this.equals(description, checkWith)
  1342. : Boolean(description),
  1343. message: () => {
  1344. const to = this.isNot ? 'not to' : 'to';
  1345. return getMessage(
  1346. this,
  1347. this.utils.matcherHint(
  1348. `${this.isNot ? '.not' : ''}.toHaveDescription`,
  1349. 'element',
  1350. '',
  1351. ),
  1352. `Expected the element ${to} have description`,
  1353. this.utils.printExpected(checkWith),
  1354. 'Received',
  1355. this.utils.printReceived(description),
  1356. )
  1357. },
  1358. }
  1359. }
  1360. // See aria-errormessage spec https://www.w3.org/TR/wai-aria-1.2/#aria-errormessage
  1361. function toHaveErrorMessage(htmlElement, checkWith) {
  1362. deprecate('toHaveErrorMessage', 'Please use toHaveAccessibleErrorMessage.');
  1363. checkHtmlElement(htmlElement, toHaveErrorMessage, this);
  1364. if (
  1365. !htmlElement.hasAttribute('aria-invalid') ||
  1366. htmlElement.getAttribute('aria-invalid') === 'false'
  1367. ) {
  1368. const not = this.isNot ? '.not' : '';
  1369. return {
  1370. pass: false,
  1371. message: () => {
  1372. return getMessage(
  1373. this,
  1374. this.utils.matcherHint(`${not}.toHaveErrorMessage`, 'element', ''),
  1375. `Expected the element to have invalid state indicated by`,
  1376. 'aria-invalid="true"',
  1377. 'Received',
  1378. htmlElement.hasAttribute('aria-invalid')
  1379. ? `aria-invalid="${htmlElement.getAttribute('aria-invalid')}"`
  1380. : this.utils.printReceived(''),
  1381. )
  1382. },
  1383. }
  1384. }
  1385. const expectsErrorMessage = checkWith !== undefined;
  1386. const errormessageIDRaw = htmlElement.getAttribute('aria-errormessage') || '';
  1387. const errormessageIDs = errormessageIDRaw.split(/\s+/).filter(Boolean);
  1388. let errormessage = '';
  1389. if (errormessageIDs.length > 0) {
  1390. const document = htmlElement.ownerDocument;
  1391. const errormessageEls = errormessageIDs
  1392. .map(errormessageID => document.getElementById(errormessageID))
  1393. .filter(Boolean);
  1394. errormessage = normalize(
  1395. errormessageEls.map(el => el.textContent).join(' '),
  1396. );
  1397. }
  1398. return {
  1399. pass: expectsErrorMessage
  1400. ? checkWith instanceof RegExp
  1401. ? checkWith.test(errormessage)
  1402. : this.equals(errormessage, checkWith)
  1403. : Boolean(errormessage),
  1404. message: () => {
  1405. const to = this.isNot ? 'not to' : 'to';
  1406. return getMessage(
  1407. this,
  1408. this.utils.matcherHint(
  1409. `${this.isNot ? '.not' : ''}.toHaveErrorMessage`,
  1410. 'element',
  1411. '',
  1412. ),
  1413. `Expected the element ${to} have error message`,
  1414. this.utils.printExpected(checkWith),
  1415. 'Received',
  1416. this.utils.printReceived(errormessage),
  1417. )
  1418. },
  1419. }
  1420. }
  1421. var extensions = /*#__PURE__*/Object.freeze({
  1422. __proto__: null,
  1423. toBeChecked: toBeChecked,
  1424. toBeDisabled: toBeDisabled,
  1425. toBeEmpty: toBeEmpty,
  1426. toBeEmptyDOMElement: toBeEmptyDOMElement,
  1427. toBeEnabled: toBeEnabled,
  1428. toBeInTheDOM: toBeInTheDOM,
  1429. toBeInTheDocument: toBeInTheDocument,
  1430. toBeInvalid: toBeInvalid,
  1431. toBePartiallyChecked: toBePartiallyChecked,
  1432. toBeRequired: toBeRequired,
  1433. toBeValid: toBeValid,
  1434. toBeVisible: toBeVisible,
  1435. toContainElement: toContainElement,
  1436. toContainHTML: toContainHTML,
  1437. toHaveAccessibleDescription: toHaveAccessibleDescription,
  1438. toHaveAccessibleErrorMessage: toHaveAccessibleErrorMessage,
  1439. toHaveAccessibleName: toHaveAccessibleName,
  1440. toHaveAttribute: toHaveAttribute,
  1441. toHaveClass: toHaveClass,
  1442. toHaveDescription: toHaveDescription,
  1443. toHaveDisplayValue: toHaveDisplayValue,
  1444. toHaveErrorMessage: toHaveErrorMessage,
  1445. toHaveFocus: toHaveFocus,
  1446. toHaveFormValues: toHaveFormValues,
  1447. toHaveStyle: toHaveStyle,
  1448. toHaveTextContent: toHaveTextContent,
  1449. toHaveValue: toHaveValue
  1450. });
  1451. export { toHaveDescription as A, toHaveErrorMessage as B, toBeInTheDocument as a, toBeEmpty as b, toBeEmptyDOMElement as c, toContainElement as d, extensions as e, toContainHTML as f, toHaveTextContent as g, toHaveAccessibleDescription as h, toHaveAccessibleErrorMessage as i, toHaveAccessibleName as j, toHaveAttribute as k, toHaveClass as l, toHaveStyle as m, toHaveFocus as n, toHaveFormValues as o, toBeVisible as p, toBeDisabled as q, toBeEnabled as r, toBeRequired as s, toBeInTheDOM as t, toBeInvalid as u, toBeValid as v, toHaveValue as w, toHaveDisplayValue as x, toBeChecked as y, toBePartiallyChecked as z };