Last active
October 20, 2023 14:43
-
-
Save lencioni/7ba04e0f92558f49454c19c44cf3bc5c to your computer and use it in GitHub Desktop.
cy.waitUntilSettled()
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* We often run into a problem with functions that select DOM nodes like | |
* `cy.get`, where in between the `cy.get` call and the next item in the chain, | |
* the DOM element that `cy.get` found ends up being removed from the DOM. This | |
* can affect code as simple as: | |
* | |
* cy.get('button').click(); | |
* | |
* When it fails sporadically, it uses the following error message: | |
* | |
* cy...() failed because the element you are chaining off of has become | |
* detached or removed from the dom | |
* | |
* More info: | |
* | |
* - https://on.cypress.io/element-has-detached-from-dom | |
* - https://github.com/cypress-io/cypress/issues/695 | |
* | |
* This most frequently happens for us on pages that use deferred hydration | |
* like in PageSlot. | |
* | |
* This command will wait until the DOM has not changed. | |
* | |
* To use this command, import this module into your project's Cypress support | |
* file: | |
* | |
* // frontend/[project]/cypress/support/index.ts | |
* import ':cypress/commands/waitUntilSettled'; | |
* | |
* Then you will be able to use the command: | |
* | |
* cy.waitUntilSettled(); | |
* | |
* On some pages, waitUntilSettled() may consistently throw the 'DOM did not settle' | |
* error unless we pass in a higher number of maxTries. The default maxTries value exists | |
* to prevent an infinite loop when we recursively check if the DOM has changed. | |
* Allowing consumers to set their own maxTries helps us make this utility work | |
* on pages where the number of sections that need to be wrapped by PageSlot components | |
* on page load is greater than the default maxTries. | |
*/ | |
// Typing custom Cypress commands per the official docs | |
// https://docs.cypress.io/api/cypress-api/custom-commands.html#5-Write-TypeScript-definitions | |
// Currently requires 'global' workaround | |
declare global { | |
// eslint-disable-next-line @typescript-eslint/no-namespace | |
namespace Cypress { | |
interface Chainable { | |
/** | |
* Waits until the DOM has not been changed. Useful for handling pages | |
* that use deferred hydration as in PageSlot. | |
*/ | |
waitUntilSettled(maxTries?: number): Cypress.Chainable; | |
} | |
} | |
} | |
Cypress.Commands.add('waitUntilSettled', (maxTries = 20) => { | |
let didDOMChange = false; | |
let consecutiveIdleCallbacksWithUnchangedDOM = 0; | |
const handleMutation = () => { | |
didDOMChange = true; | |
}; | |
// First install a MutationObserver on the document that will fire the | |
// callback on any DOM node changes, and set the `didDOMChange` boolean to | |
// true. Later we will wait a little bit and see if it has been fired. | |
const observer = new MutationObserver(handleMutation); | |
cy.document().then((doc) => { | |
observer.observe(doc.body, { childList: true, subtree: true }); | |
}); | |
function waitAndSee(iteration: number) { | |
didDOMChange = false; | |
// Here we wait until the next idle callback, and then check our | |
// `didDOMChange` boolean. If it changed, we repeat this process, and if it | |
// has not changed, we allow the test to continue. | |
// | |
// Since requestIdleCallback will wait until the CPU is idle before it | |
// resolves, and there might be a lot of work happening on the page, the | |
// amount of time that takes could be greater than the Cypress timeout | |
// (default of 4000ms). To make sure that we don't blow past that timeout | |
// and fail the test, we want to control the timeouts ourselves here. | |
// | |
// Here we are going to use two different timeouts: one for the Cypress | |
// .then() and another, smaller timeout for our requestIdleCallback. I | |
// decided to make sure that the requestIdleCallback timeout is quite a bit | |
// lower than the .then() timeout because if there is a long blocking task, | |
// it will need to finish before the idle callback will have a chance to | |
// land. Given that we are calling this in a loop and checking to see if the | |
// DOM has changed, I think this will be okay because if there was DOM work | |
// being done and there is still more work to be done, we'll just recurse on | |
// to the next iteration and check again in a few seconds. | |
const thenTimeout = 8000; | |
// eslint-disable-next-line rulesdir/no-two-argument-thenable | |
cy.window({ log: false }) | |
.then( | |
{ timeout: thenTimeout }, | |
(win) => | |
new Cypress.Promise((resolve) => | |
win.requestIdleCallback(resolve, { timeout: thenTimeout / 2 }), | |
), | |
) | |
.then(() => { | |
if (didDOMChange) { | |
if (iteration >= maxTries) { | |
throw new Error('DOM did not settle'); | |
} | |
consecutiveIdleCallbacksWithUnchangedDOM = 0; | |
waitAndSee(iteration + 1); | |
} else if (consecutiveIdleCallbacksWithUnchangedDOM <= 1) { | |
consecutiveIdleCallbacksWithUnchangedDOM += 1; | |
waitAndSee(iteration); | |
} | |
}); | |
} | |
waitAndSee(0); | |
}); |
The MutationObserver
watches over the whole DOM tree in this function. What about adding a way to limit the observation to a subtree of interest. There certainly can be unrelated changes happening on a complex web app elsewhere in the DOM not being tested. So being able to dictate which general area to observe and not just the default doc.body
could be useful.
Can you please add a line that declares a license? Like:
// Copyright 2022 Joe Lencioni, MIT license
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Bravo! 👏🥳🙇♂️