Skip to content

Commit

Permalink
chore: Use addQuery from Cypress 12 (#238)
Browse files Browse the repository at this point in the history
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+.
  • Loading branch information
BlueWinds authored Dec 13, 2022
1 parent 8d66009 commit 8c93575
Show file tree
Hide file tree
Showing 6 changed files with 59 additions and 107 deletions.
3 changes: 1 addition & 2 deletions cypress.config.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
const {defineConfig} = require('cypress')

module.exports = defineConfig({
video: false,

e2e: {},
video: false,
})
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
9 changes: 5 additions & 4 deletions src/__tests__/add-commands.js
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
4 changes: 2 additions & 2 deletions src/add-commands.js
Original file line number Diff line number Diff line change
@@ -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 => {
Expand Down
129 changes: 47 additions & 82 deletions src/index.js
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -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) {
Expand All @@ -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()
}
})
},
}
}
Expand Down
17 changes: 2 additions & 15 deletions src/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 */

0 comments on commit 8c93575

Please sign in to comment.