From 8c93575723e9d18c770278dbed7d37970be8436a Mon Sep 17 00:00:00 2001 From: Blue F Date: Tue, 13 Dec 2022 09:16:47 -0800 Subject: [PATCH] chore: Use addQuery from Cypress 12 (#238) Use Commands.addQuery rather than Commands.add `addQuery` cleans up code and fixes "Detached DOM" errors. BREAKING CHANGE: Use addQuery interface, which is only present in Cypress 12+. --- cypress.config.js | 3 +- package.json | 4 +- src/__tests__/add-commands.js | 9 +-- src/add-commands.js | 4 +- src/index.js | 129 +++++++++++++--------------------- src/utils.js | 17 +---- 6 files changed, 59 insertions(+), 107 deletions(-) diff --git a/cypress.config.js b/cypress.config.js index 39ba054..1a3d306 100644 --- a/cypress.config.js +++ b/cypress.config.js @@ -1,7 +1,6 @@ const {defineConfig} = require('cypress') module.exports = defineConfig({ - video: false, - e2e: {}, + video: false, }) diff --git a/package.json b/package.json index 2c2f772..3f9703f 100644 --- a/package.json +++ b/package.json @@ -44,13 +44,13 @@ "@testing-library/dom": "^8.1.0" }, "devDependencies": { - "cypress": "^10.0.0", + "cypress": "^12.0.0", "kcd-scripts": "^11.2.0", "npm-run-all": "^4.1.5", "typescript": "^4.3.5" }, "peerDependencies": { - "cypress": "^2.1.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0" + "cypress": "^12.0.0" }, "eslintConfig": { "extends": "./node_modules/kcd-scripts/eslint.js", diff --git a/src/__tests__/add-commands.js b/src/__tests__/add-commands.js index 3d0f9ec..a1ddf20 100644 --- a/src/__tests__/add-commands.js +++ b/src/__tests__/add-commands.js @@ -2,16 +2,17 @@ import {commands} from '../' test('adds commands to Cypress', () => { const addMock = jest.fn().mockName('Cypress.Commands.add') - global.Cypress = {Commands: {add: addMock}} + const addQueryMock = jest.fn().mockName('Cypress.Commands.addQuery') + global.Cypress = {Commands: {add: addMock, addQuery: addQueryMock}} global.cy = {} require('../add-commands') - expect(addMock).toHaveBeenCalledTimes(commands.length + 1) // we're also adding a configuration command + expect(addQueryMock).toHaveBeenCalledTimes(commands.length) + expect(addMock).toHaveBeenCalledTimes(1) // we're also adding a configuration command commands.forEach(({name}, index) => { - expect(addMock.mock.calls[index]).toMatchObject([ + expect(addQueryMock.mock.calls[index]).toMatchObject([ name, - {}, // We get a new function that is `command.bind(null, cy)` i.e. global `cy` passed into the first argument. // The commands themselves will be tested separately in the Cypress end-to-end tests. expect.any(Function), diff --git a/src/add-commands.js b/src/add-commands.js index 199bb8c..890808a 100644 --- a/src/add-commands.js +++ b/src/add-commands.js @@ -1,7 +1,7 @@ import {configure, commands} from './' -commands.forEach(({name, command, options = {}}) => { - Cypress.Commands.add(name, options, command) +commands.forEach(({name, command}) => { + Cypress.Commands.addQuery(name, command) }) Cypress.Commands.add('configureCypressTestingLibrary', config => { diff --git a/src/index.js b/src/index.js index 54710d2..b48450a 100644 --- a/src/index.js +++ b/src/index.js @@ -1,5 +1,5 @@ import {configure as configureDTL, queries} from '@testing-library/dom' -import {getContainer} from './utils' +import {getFirstElement} from './utils' function configure({fallbackRetryWithoutPreviousSubject, ...config}) { return configureDTL(config) @@ -9,66 +9,79 @@ const findRegex = /^find/ const queryNames = Object.keys(queries).filter(q => findRegex.test(q)) const commands = queryNames.map(queryName => { - return createCommand(queryName, queryName.replace(findRegex, 'get')) + return createQuery(queryName, queryName.replace(findRegex, 'get')) }) -function createCommand(queryName, implementationName) { +function createQuery(queryName, implementationName) { return { name: queryName, - options: {prevSubject: ['optional']}, - command: (prevSubject, ...args) => { + command(...args) { const lastArg = args[args.length - 1] - const defaults = { - timeout: Cypress.config().defaultCommandTimeout, - log: true, - } - const options = - typeof lastArg === 'object' ? {...defaults, ...lastArg} : defaults + const options = typeof lastArg === 'object' ? {...lastArg} : {} - const queryImpl = queries[implementationName] - const baseCommandImpl = container => { - return queryImpl(getContainer(container), ...args) - } - const commandImpl = container => baseCommandImpl(container) + this.set('timeout', options.timeout) + const queryImpl = queries[implementationName] const inputArr = args.filter(filterInputs) - const getSelector = () => `${queryName}(${queryArgument(args)})` - - const win = cy.state('window') + const selector = `${queryName}(${queryArgument(args)})` const consoleProps = { // TODO: Would be good to completely separate out the types of input into their own properties input: inputArr, - Selector: getSelector(), - 'Applied To': getContainer( - options.container || prevSubject || win.document, - ), + Selector: selector, } - if (options.log) { - options._log = Cypress.log({ - type: prevSubject ? 'child' : 'parent', + const log = + options.log !== false && + Cypress.log({ name: queryName, + type: + this.get('prev').get('chainerId') === this.get('chainerId') + ? 'child' + : 'parent', message: inputArr, timeout: options.timeout, consoleProps: () => consoleProps, }) - } - const getValue = ( - container = options.container || prevSubject || win.document, - ) => { - const value = commandImpl(container) + const withinSubject = cy.state('withinSubjectChain') + + let error + this.set('onFail', err => { + if (error) { + err.message = error.message + } + }) + + return subject => { + const container = getFirstElement( + options.container || + subject || + cy.getSubjectFromChain(withinSubject) || + cy.state('window').document, + ) + consoleProps['Applied To'] = container + + let value + + try { + value = queryImpl(container, ...args) + } catch (e) { + error = e + value = Cypress.$() + value.selector = selector + } const result = Cypress.$(value) - if (value && options._log) { - options._log.set('$el', result) + + if (value && log) { + log.set('$el', result) } // Overriding the selector of the jquery object because it's displayed in the long message of .should('exist') failure message // Hopefully it makes it clearer, because I find the normal response of "Expected to find element '', but never found it" confusing - result.selector = getSelector() + result.selector = selector consoleProps.elements = result.length if (result.length === 1) { @@ -86,54 +99,6 @@ function createCommand(queryName, implementationName) { return result } - - let error - - // Errors will be thrown by @testing-library/dom, but a query might be followed by `.should('not.exist')` - // We just need to capture the error thrown by @testing-library/dom and return an empty jQuery NodeList - // to allow Cypress assertions errors to happen naturally. If an assertion fails, we'll have a helpful - // error message handy to pass on to the user - const catchQueryError = err => { - error = err - const result = Cypress.$() - result.selector = getSelector() - return result - } - - const resolveValue = () => { - // retry calling "getValue" until following assertions pass or this command times out - return Cypress.Promise.try(getValue) - .catch(catchQueryError) - .then(value => { - return cy.verifyUpcomingAssertions(value, options, { - onRetry: resolveValue, - onFail: () => { - // We want to override Cypress's normal non-existence message with @testing-library/dom's more helpful ones - if (error) { - options.error.message = error.message - } - }, - }) - }) - } - - return resolveValue() - .then(subject => { - // Remove the error that occurred because it is irrelevant now - if (consoleProps.error) { - delete consoleProps.error - } - if (options._log) { - options._log.snapshot() - } - - return subject - }) - .finally(() => { - if (options._log) { - options._log.end() - } - }) }, } } diff --git a/src/utils.js b/src/utils.js index ae8a20f..d9db357 100644 --- a/src/utils.js +++ b/src/utils.js @@ -5,19 +5,6 @@ function getFirstElement(jqueryOrElement) { return jqueryOrElement } -function getContainer(container) { - // Cypress 10 deprecated cy.state('subject') usage and suggest to use new cy.currentSubject. - // https://docs.cypress.io/guides/references/changelog#10-5-0 - // Below change ensures we do not get spam of warnings and are backward compatible with older cypress versions. - const subject = cy.currentSubject ? cy.currentSubject() : cy.state('subject'); - const withinContainer = cy.state('withinSubject') +export {getFirstElement} - if (!subject && withinContainer) { - return getFirstElement(withinContainer) - } - return getFirstElement(container) -} - -export {getFirstElement, getContainer} - -/* globals Cypress, cy */ +/* globals Cypress */