window.onbeforeunload and Cypress

Prevent Test Runner hanging when the application uses confirmation dialog in window.onbeforeunload callback

🧭 You can find the application and the tests for this blog post in the repo onbeforeunload-example

The application

Imagine we have a page

public/index.html
1
2
3
4
5
6
<html>
<body>
<p>hello world</p>
</body>
<script src="app.js"></script>
</html>

The application code uses window.onbeforeunload callback to ask the user to confirm before navigating away from the page.

public/app.js
1
2
3
4
5
6
7
8
window.onbeforeunload = function (e) {
console.log('app window.onbeforeunload')
// will pop up a confirmation dialog
// asking the user before navigating away from the page
// or even simply reloading
e.returnValue = 'ask user'
return
}

We want to reload the page from Cypress test. The following test seems to work

cypress/integration/prevent-spec.js
1
2
3
4
5
6
7
/// <reference types="cypress" />
describe('App with window.onbeforeunload', () => {
it('should reload', () => {
cy.visit('/')
cy.reload()
})
})

The page reloads

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.

cypress/integration/prevent-spec.js
1
2
3
4
5
6
7
/// <reference types="cypress" />
describe('App with window.onbeforeunload', () => {
it('should reload', () => {
cy.visit('/').pause()
cy.reload()
})
})

Notice the next page load after the cy.reload() command times out

The page reload 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.

Cannot close the Electron browser after the test fails

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.

Page reload 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"?

Clicking Reload allows the test to continue successfully

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?

Click Cancel and fail the test

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.

cypress/integration/prevent-spec.js
1
2
3
4
5
6
7
/// <reference types="cypress" />
describe('App with window.onbeforeunload', () => {
it('should reload', () => {
cy.visit('/')
cy.reload()
})
})

DevTools shows that the browser skips the confirmation popup

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.

Clicking the Continue button counts as human interaction with the page

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.

cypress/integration/prevent-spec.js
1
2
3
4
5
6
7
8
9
10
11
12
/// <reference types="cypress" />
describe('App with window.onbeforeunload', () => {
it('should reload', () => {
cy.visit('/').pause()
// try removing application's window.onbeforeunload
cy.window().then((win) => {
// ⛔️ DOES NOT REMOVE IT
delete win.onbeforeunload
})
cy.reload()
})
})

The window.onbeforeunload stills runs and still causes problems. Instead set it to null and it won't run.

cypress/integration/prevent-spec.js
1
2
3
4
5
6
7
8
9
10
11
12
/// <reference types="cypress" />
describe('App with window.onbeforeunload', () => {
it('should reload', () => {
cy.visit('/').pause()
// try removing application's window.onbeforeunload
cy.window().then((win) => {
// ✅ removes it
win.onbeforeunload = null
})
cy.reload()
})
})

The video below shows that the app's onbeforeunload callback function does not run at all.

Remove the handler by setting it to null

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.

cypress/integration/prevent-spec.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/// <reference types="cypress" />
// whenever the app is about to load any window
// let's prevent "window.onbeforeunload =" assignment
Cypress.on('window:before:load', (win) => {
Object.defineProperty(win, 'onbeforeunload', {
value: undefined,
writable: false,
})
})

describe('App with window.onbeforeunload', () => {
it('should reload', () => {
cy.visit('/').pause()
cy.reload()
})
})

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.

public/app.js
1
2
3
4
5
6
7
8
window.onbeforeunload = function (e) {
console.log('app window.onbeforeunload')
// will pop up a confirmation dialog
// asking the user before navigating away from the page
// or even simply reloading
e.returnValue = 'ask user'
return
}

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
/// <reference types="cypress" />
Cypress.on('window:before:load', (win) => {
let userCallback, ourCallback
Object.defineProperty(win, 'onbeforeunload', {
get() {
return ourCallback
},
set(cb) {
userCallback = cb
console.log('user callback', cb)

ourCallback = (e) => {
console.log('proxy beforeunload event', e)
const result = userCallback(e)
e.returnValue = null
return result
}

// https://developer.mozilla.org/en-US/docs/Web/API/Window/beforeunload_event
win.addEventListener('beforeunload', ourCallback)
},
})
})

describe('App with window.onbeforeunload', () => {
it('asks to confirm before reload', () => {
cy.visit('/').pause()
cy.reload()
})
})

The code runs ... and it does not work.

Setting the return value to null does not prevent the confirmation popup

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
2
3
4
5
6
7
ourCallback = (e) => {
console.log('proxy beforeunload event', e)
const result = userCallback({})
return result
}
// https://developer.mozilla.org/en-US/docs/Web/API/Window/beforeunload_event
win.addEventListener('beforeunload', ourCallback)

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
ourCallback = (e) => {
console.log('proxy beforeunload event', e)

// prevent the application code from assigning value
Object.defineProperty(e, 'returnValue', {
get() {
return ''
},
set(x) {
// do nothing
},
})

const result = userCallback(e)
return result
}

// https://developer.mozilla.org/en-US/docs/Web/API/Window/beforeunload_event
win.addEventListener('beforeunload', ourCallback)

Nice, the application's callback is called, but the browser does not show the cursed popup.

Prevent the returnValue assignment

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
/// <reference types="cypress" />
beforeEach(() => {
// before we are inside a hook executing as part of the test
// we can use cy.on methods and create stubs, something
// we could not do from Cypress.on callbacks
const returnValueStub = cy.stub().as('returnValue')

cy.on('window:before:load', (win) => {
let userCallback, ourCallback
Object.defineProperty(win, 'onbeforeunload', {
get() {
return ourCallback
},
set(cb) {
userCallback = cb
console.log('user callback', cb)

ourCallback = (e) => {
console.log('proxy beforeunload event', e)

// prevent the application code from assigning value
Object.defineProperty(e, 'returnValue', {
get() {
return ''
},
set: returnValueStub,
})

const result = userCallback(e)
return result
}

// https://developer.mozilla.org/en-US/docs/Web/API/Window/beforeunload_event
win.addEventListener('beforeunload', ourCallback)
},
})
})
})

describe('App with window.onbeforeunload', () => {
it('sets returnValue', () => {
cy.visit('/')
// reload twice just for fun
cy.reload().reload()
cy.get('@returnValue')
.should('have.been.calledTwice')
.and('be.calledWithExactly', 'ask user')
})
})

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
2
3
4
5
6
7
8
9
10
describe('App with window.onbeforeunload', () => {
it('sets returnValue', () => {
cy.visit('/')
// reload twice just for fun
cy.reload().reload()
cy.get('@returnValue')
.should('have.been.calledTwice')
.and('be.calledWithExactly', 'ask user')
})
})

Confirming the returnValue assignment

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
2
3
4
5
6
7
8
9
10
describe('App with window.onbeforeunload', () => {
it('debugs event listeners', () => {
cy.visit('/')
cy.pause() // add to pause the test
cy.reload()
cy.get('@returnValue')
.should('have.been.calledOnce')
.and('be.calledWithExactly', 'ask user')
})
})
  • 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.

All event listeners attached to the window object

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

Application's event listener comes from app.js

  • 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 the window object and tell the DevTools debugger to break as soon as it reaches the app's callback function.

Debug event listener callback

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.