Test Child Window Closed Scenario

Testing how the parent window is watching the child window closed property.

Imagine you have a parent window that calls window.open. If the parent and child windows are from the same domain, the parent window can check the child window object and "see" when the user closes the child window or tab. A common code is using setInterval to poll the childWindow.closed property.

1
2
3
4
5
6
7
8
9
10
11
12
// parent window
const childWindow = window.open(
'child.html',
'_blank',
'width=400,height=250',
)
const timer = setInterval(() => {
if (childWindow.closed) {
console.log('child window has closed')
clearInterval(timer)
}
}, 100)

The parent window might show an overlay, asking the user to work in the child window before closing it.

Parent shows an overlay while the child window is open

How would we test this scenario in Cypress?

🎁 You can find the full source code used in this blog post in the repo bahmutov/child-window-closed.

The first test

First, let's stub the window.open to prevent Cypress from "losing" the window under test

cypress/e2e/spec.js
1
2
3
4
5
6
7
8
9
it('opens the child window', () => {
const mockWindow = {}
cy.visit('index.html').then((win) => {
cy.stub(win, 'open').returns(mockWindow).as('open')
})
cy.contains('a', 'link').click()
cy.get('@open').should('have.been.calledWith', 'child.html')
cy.get('.overlay').should('be.visible')
})

Testing the parent window calls "window.open" correctly

What about closing the window? When the user closes the opened child window, the browser sets the closed: true property. Thus we can set the same property ourselves from the test. First, let me show incorrect way of doing so.

1
2
3
4
5
6
7
8
9
10
// 🚨 INCORRECT CODE, JUST FOR DEMO
const mockWindow = {}
cy.visit('index.html').then((win) => {
cy.stub(win, 'open').returns(mockWindow).as('open')
})
cy.contains('a', 'link').click()
cy.get('@open').should('have.been.calledWith', 'child.html')
cy.get('.overlay').should('be.visible').wait(1000)
mockWindow.closed = true
cy.get('.overlay').should('not.be.visible')

The test is green, but notice how the overlay is blinking and NOT staying open for 1 second

There is a race condition between checking if the .overlay is visible and setting the mockWindow.closed = true statement. A clear error is shown if we check if the overlay is visible after 1 second delay.

1
2
3
4
// 🚨 INCORRECT CODE, JUST FOR DEMO
cy.get('.overlay').should('be.visible').wait(1000).should('be.visible')
mockWindow.closed = true
cy.get('.overlay').should('not.be.visible')

Overlay does not stay visible as we expect

Let's fix the test. We want to set the mockWindow.closed = true after the second has passed. Thus we use cy.then command.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// ✅ CORRECT TEST
it('opens the child window', () => {
const mockWindow = {}
cy.visit('index.html').then((win) => {
cy.stub(win, 'open').returns(mockWindow).as('open')
})
cy.contains('a', 'link').click()
cy.get('@open').should('have.been.calledWith', 'child.html')
cy.get('.overlay')
.should('be.visible')
.wait(1000)
.should('be.visible')
.then(() => {
// set parent window is watching the "window.closed" property
mockWindow.closed = true
})
cy.get('.overlay').should('not.be.visible')
})

Now the test shows the correct behavior. The mock window is "opened", the overlay becomes visible and stays for one second, then hides.

Using cy.invoke

In the previous example, we have created a race condition by mixing asynchronous queue of Cypress commands and immediate JavaScript statement mockWindow.closed = true. We solved it by moving the assignment into a Cypress cy.then(callback) command. Let me show you another way of making such assignment using Cypress command cy.invoke to avoid thinking about the order of commands.

cypress/e2e/spec2.cy.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
it('opens the child window (using cy.invoke)', () => {
const mockWindow = {}
const mockWindowController = {
close() {
// prevent calling multiple times
if (mockWindow.changed) {
throw new Error('Cannot close a closed window')
}
mockWindow.closed = true
},
}
cy.visit('index.html').then((win) => {
cy.stub(win, 'open').returns(mockWindow).as('open')
})
cy.contains('a', 'link').click()
cy.get('@open').should('have.been.calledWith', 'child.html')
cy.get('.overlay').should('be.visible').wait(1000)
// set parent window is watching the "window.closed" property
cy.wrap(mockWindowController).invoke('close')
cy.get('.overlay').should('not.be.visible')
})

The above test works correctly too.

The key is the statement line cy.wrap(mockWindowController).invoke('close') which makes the assignment from inside the method mockWindowController.close called during Cypress command execution, preserving the order. Unfortunately, we had to create a dummy object "mockWindowController" just to be able to "invoke" mockWindow.closed = true method. Fortunately, there is a shortcut.

Using cy.invoke and Reflect

There is a special JavaScript API Reflect that can be used to interact with objects dynamically. The global Reflect has set(object, method, ...args) method we can use to set the closed property on the mockWindow object right from Cypress cy.invoke.

cypress/e2e/spec3.cy.js
1
2
3
4
5
6
7
8
9
10
11
12
it('opens the child window (using Reflect.set)', () => {
const mockWindow = {}
cy.visit('index.html').then((win) => {
cy.stub(win, 'open').returns(mockWindow).as('open')
})
cy.contains('a', 'link').click()
cy.get('@open').should('have.been.calledWith', 'child.html')
cy.get('.overlay').should('be.visible').wait(1000)
// set parent window is watching the "window.closed" property
cy.wrap(Reflect).invoke('set', mockWindow, 'closed', true)
cy.get('.overlay').should('not.be.visible')
})

The key is the statement cy.wrap(Reflect).invoke('set', mockWindow, 'closed', true) which makes it all work.

Using Reflect.set method to change an object's property in Cypress command queue

Interesting. We cy.wrap(Reflect) to be able to invoke its method .set(...) after all previous commands have finished (checking the overlay, waiting one second, checking if the overlay is still visible). After setting the mockWindow.closed to true, we check the overlay, and yes, the parent window has hidden it.

See also