Thenable Commands in Cypress.

By: John Detlefs
Posted: February 06, 2021

If you're unfamiliar with the tool, Cypress is a popular SaaS product that enables testing of web applications in the browser in a very familiar syntax for web developers. Like many software developers, you may be so eager to get to playing with your new tool that you may have given the docs a little bit less attention than they deserved. One thing I learned the hard way is that `.then()` is not an indication that commands exactly correspond to ES6 Promises.

It might satisfy you to read the docs: Commands are Promises and Commands are not Promises to effectively understand the nuances of what it takes to do your job. I didn't take the time to read the docs initially and regret it. Regardless, after learning that they weren't promises, I thought it was really magical that when I created a custom command, Cypress was automatically able make the command chainable to a then.

In this post I will attempt to provide some clarification to how this wizardry works so that you can have a deeper understanding of the machinations that helps Cypress give you the testing experience you deserve.

thenable: A command that returns a defined a value or invokes commands that potentially return a defined a value.

For background you have to understand that before Cypress ever runs any of the tests you have written, it prepares an environment that includes the commands you have defined in support/index.js . In between when those commands are loaded and when those commands are used to execute actual tests, they are appended a thenFn which is used to return the result of the previous command if it exists, waiting a specified 'timeout' amount before erroring.

const getRet = () => {
      let ret = fn.apply(ctx, args)

      if (cy.isCy(ret)) {
        ret = undefined
      }

      if (ret && invokedCyCommand && !ret.then) {
        $errUtils.throwErrByPath('then.callback_mixes_sync_and_async', {
          onFail: options._log,
          args: { value: $utils.stringify(ret) },
        })
      }

      return ret
    }

return Promise
    .try(getRet)
    .timeout(options.timeout)
    .then((ret) => {
      // if ret is undefined then
      // resolve with the existing subject
      if (_.isUndefined(ret)) {
        return subject
      }

      return ret
    })

This function getRet is an attempt to get the returned value, which can be either a synchronous value or a command. In cypress commands need to be chained, rather than have a mismatch of synchronous code. The invokedCyCommand boolean indicates that in executing the function a command has been enqueued. If the returned value does not have a .then attribute while there is still a command enqueued its stands to reason that there is a (bad) mix of asynchronous and synchronous code. You could fix the error by returning a promise; in doing so you satisfy the condition that your returned subject is thenable.

The ability to return a promise to circumvent this error is an unavoidable bug, not a feature. In order to avoid this Cypress would have to use a different name other than 'then' for their chained commands, and ultimately the costs would outweigh the benefits.

// Assume commands Foo and and Bar return a defined value 
// Because they return a defined value they are thenable

// Dont do this
const Baz = () => {
	cy.Foo()
	cy.Bar()
}
Cypress.Commands.add("Baz", Baz)

// Do this
const Baz = () => {
	cy.Foo().then((returnedSubjectOfFoo) => {
		// Potentially might want to do something with the returned value of foo
		cy.Bar().then((returnedSubjectOfBar) => {
			return goalReturnedSubject // Return the value that you want the command Baz to yield as the returned subject
		})
	})
}

This construct is meant to encourage you to nest your calls to commands. If you write a command that invokes thenable commands and you expect your command to be thenable, the return value of your command should appear inside the body of the then of one of those commands. You might notice that I have failed to explain how Cypress manages to return the appropriate subject for a nested chain like this. Fortunately, you don't have know. From what I can tell, it involves judicious management of custom events, closures relying on javascript's this, type assertions, and some complex work in the chainer class definition.

If you're looking to improve your understanding into how subjects are yielded, the code in connectors is a good place to start. I hope that as a result of reading this, you might understand a bit better how then becomes attached to cypress commands and how to write thenable commands that behave the way you would expect.