- The application
- The strange behavior
- Chrome browser
- Use the DevTools console
- Solution 1: remove window.onbeforeunload
- Solution 2: prevent window.onbeforeunload registration
- Solution 3: prevent confirmation prompt
- Confirm the returnValue
- Debugging
- Final thoughts
🧭 You can find the application and the tests for this blog post in the repo onbeforeunload-example
The application
Imagine we have a page
1 | <html> |
The application code uses window.onbeforeunload
callback to ask the user to confirm before navigating away from the page.
1 | window.onbeforeunload = function (e) { |
We want to reload the page from Cypress test. The following test seems to work
1 | /// <reference types="cypress" /> |
The strange behavior
But sometimes the tests do not work. Let's say we add a cy.pause command and simply click the "Continue" button.
1 | /// <reference types="cypress" /> |
Notice the next page load after the cy.reload()
command times out
Even worse, I cannot close the Electron browser - in the video below I click the browser close button multiple times without success. The only way to close the browser is to click the big red button "Stop" in the Cypress Desktop GUI window.
Multiple users have noted this weird behavior during Cypress tests, see the issue #2118. What is going on?
Chrome browser
If you read my Debugging Cypress Geolocation Problem you know that I love trying the same test in different browsers to see if they behave differently. Let's run the same test in Chrome browser.
Interesting, Cypress can prevent the user prompt in the Electron browser, but Chrome shows it. What happens when the user clicks "Reload"?
Interesting - if the user clicks the "Reload" button, the test continues and the cy.reload()
command succeeds. Everything is good. What happens if the user clicks "Cancel" button instead?
If the user clicks the "Cancel" button, the reload times out. Also the same dialog pops up again when we try to close the browser window. This is what was causing the Electron to stay open too, we just did not see the popup, since it was hidden.
Use the DevTools console
Let's open the DevTools console and run the successful test without the cy.pause()
command.
1 | /// <reference types="cypress" /> |
As a security measure, the browser skips showing the confirmation popup if the user has never interacted with the page. When we used cy.pause()
and clicked the "Continue" button we interacted with the page, thus the confirmation popup is shown, blocking the test.
Let's see how we can solve this window.onbeforeunload
problem.
Solution 1: remove window.onbeforeunload
If the app's window.onbeforeunload
callback can cause problems, we can prevent it from running. The one thing that DOES NOT WORK is trying to remove it after it has been registered by using delete
operator.
1 | /// <reference types="cypress" /> |
The window.onbeforeunload
stills runs and still causes problems. Instead set it to null
and it won't run.
1 | /// <reference types="cypress" /> |
The video below shows that the app's onbeforeunload
callback function does not run at all.
Solution 2: prevent window.onbeforeunload registration
With the previous approach, every time we are about to reload or leave the page we would need to make sure we delete the app's handler first. This becomes problematic, since the application itself might navigate. The handler would also prevent us from closing the test browser window. Thus a better approach would be to prevent the handler registration in the first place.
We can accomplish this by using custom JavaScript property descriptors. We know the application might call window.onbeforeunload
property setter, so we can be ready.
1 | /// <reference types="cypress" /> |
By using Cypress.on
we guarantee that every test in this spec file "prepares" the window
object for possible future property assignment window.onbeforeunload = ...
and we stop it. The application does its thing, and yet the handler is ignored.
Solution 3: prevent confirmation prompt
The previous solutions work, but they skip part of the application's code, which is unfortunate. What if we want the application to run window.onbeforeunload
code? What if we want to confirm the application asks the user for the confirmation before navigating away from the page or before reloading the page? We need to run the app's callback, but prevent the confirmation dialog from actually showing up.
Ok, so the browser pops the confirmation dialog because the application's code assigns the property returnValue
to some string.
1 | window.onbeforeunload = function (e) { |
So let's wrap the application's callback with our function. Our callback will try to "reset" the event's property returnValue
, hoping the browser forgives us and does not show the blocking popup.
1 | /// <reference types="cypress" /> |
The code runs ... and it does not work.
Neither e.returnValue = ''
nor delete e.returnValue
prevent the popup. Thus we need something else - we need to ignore the assignment completely. Here is the simplest way - give the application's code a dummy object!
1 | ourCallback = (e) => { |
The above works, but what if the application's code checks the event's properties and needs the real BeforeUnloadEvent
instance? Let's prevent the e.returnValue = ...
the same way we prevented the window.onbeforeunload = ...
assignment.
1 | ourCallback = (e) => { |
Nice, the application's callback is called, but the browser does not show the cursed popup.
Confirm the returnValue
The last piece of the testing puzzle is to confirm the value the application assigns to the e.returnValue = ...
property. In a new spec I will have
1 | /// <reference types="cypress" /> |
Notice how we switched from using Cypress.on(...)
to cy.on
inside a beforeEach
hook. We want to create a function stub with cy.stub thus we need to be inside a Cypress test or a hook function.
Our e.returnValue
setter method is the stub we created with const returnValueStub = cy.stub().as('returnValue')
. The test reloads the page twice just for fun, and then checks that the returnValue
stub was called twice with expected argument.
1 | describe('App with window.onbeforeunload', () => { |
Debugging
So if you hit a problem with onbeforeunload
in Cypress, here are the debugging steps I might suggest
- add
cy.pause()
before the action that triggers the page unload. This will allow you to inspect the application's state and possible put a debugger breakpoint into the application's event handler.
1 | describe('App with window.onbeforeunload', () => { |
- from the DevTools console you can execute the following debugging code to see all event listeners attached to an object using
getEventListeners(window)
. Important: you need to set the DevTools console context to the application's frame.
Some of these event listeners are attached by Cypress, so you can ignore them. You can find the code for the event listener of interest to you by only looking at the attached listeners from your application's code
- you can step through the desired listener by using
debug(callback function)
from the DevTools console and hitting the Cypress "Continue" button. For example, below I get the event listeners from thewindow
object and tell the DevTools debugger to break as soon as it reaches the app's callback function.
Final thoughts
I think this blog post shows the power of Cypress and its execution model where the test code runs in the same browser tab with the application code. The test code even has a huge advantage: it runs before any of the application's code is executed. The test code can prepare the "window" and any other special objects for the application's assignment and calls. We can stub browser APIs and even restrict some properties by using our own object property descriptors.
For more examples of this approach read Stub navigator API in end-to-end tests, Turning code coverage into live stream. I also recommend reading the blog post When Can The Test Start? that shows how to spy on the click event listener registrations to know when the application is ready to receive user clicks.