Test Web Apps That Use The Browser FileSystem API

How to stub the browser FileSystem APIs from a Cypress test.

Let's say you have a web application that calls browser FileSystem API to read a file. How do you write an end-to-end test for this app?

App opens FileSystem dialog to read a local file

The application above shows the system file selection which lets the user pick a local file. Its contents is then pasted into the output text area.

public/app.js
1
2
3
4
5
6
7
8
document.getElementById('read-file').addEventListener('click', async () => {
let fileHandle
// Destructure the one-element array.
;[fileHandle] = await window.showOpenFilePicker()
const file = await fileHandle.getFile()
const contents = await file.text()
document.getElementById('output').textContent = contents
})

🎁 You can find the application and the tests in the repo bahmutov/cypress-browser-file-system-example.

Whenever you need to deal with the standard browser APIs in Cypress, take advantage of its unique architecture - you can access each browser object from the test, and then spy or stub its methods. I have shown examples in other blog posts Spy On DOM Methods And Properties, Stubbing The Non-configurable, Stub navigator API in end-to-end tests, Stub window.open, and a few others. In our case, we want to stub the window.showOpenFilePicker method. Let's do it.

cypress/integration/spec.js
1
2
3
4
5
6
7
it('shows file contents', () => {
cy.visit('/', {
onBeforeLoad(win) {
cy.stub(win, 'showOpenFilePicker')
},
})
cy.get('button').click()

In general, you need to stub a method before the application calls it. I like stubbing things early to ensure the application sees the stubbed method from the moment it loads. Using cy.visit onBeforeLoad callback is a good place to set up the stubs.

What should the stub return? It should resolve (which means async result) with a "file handle" object. That object should have a method that resolves with some text. So I will use three different stubs.

cypress/integration/spec.js
1
2
3
4
5
6
7
8
9
10
11
12
cy.visit('/', {
onBeforeLoad(win) {
const file = {
text: cy.stub().resolves('Hello, world!'),
}
const fileHandle = {
getFile: cy.stub().resolves(file),
}
cy.stub(win, 'showOpenFilePicker')
.resolves([fileHandle])
},
})

Nice, but if we want to see the calls in the Command Log and assert they have happened, let's give each stub an alias.

cypress/integration/spec.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
it('shows file contents', () => {
cy.visit('/', {
onBeforeLoad(win) {
const file = {
text: cy.stub().resolves('Hello, world!').as('text'),
}
const fileHandle = {
getFile: cy.stub().resolves(file).as('file'),
}
cy.stub(win, 'showOpenFilePicker')
.resolves([fileHandle])
.as('showOpenFilePicker')
},
})
cy.get('button').click()
cy.get('#output').should('have.text', 'Hello, world!')
cy.get('@text').should('be.called')
})

Beautiful - the test runs.

The test stubs the browser file system open feature

What happens if the user cancels selecting the file? Hmm, our application does not handle it at all!

The FileSystem throws an exception if the user cancels selecting the local file

We need to handle the errors in our application, at least let's put try / catch around the code

public/app.js
1
2
3
4
5
6
7
8
try {
;[fileHandle] = await window.showOpenFilePicker()
const file = await fileHandle.getFile()
const contents = await file.text()
document.getElementById('output').textContent = contents
} catch (err) {
alert('Error: ' + err.message)
}

Let's test it. Our stub will reject with an error, and we will check if the alert method is called with excepted message.

1
2
3
4
5
6
7
8
9
10
it('shows alert when the user cancels', () => {
cy.visit('/', {
onBeforeLoad(win) {
cy.stub(win, 'alert').as('alert')
cy.stub(win, 'showOpenFilePicker').rejects(new Error('User cancelled'))
},
})
cy.get('button').click()
cy.get('@alert').should('be.calledWith', 'Error: User cancelled')
})

The test runs and confirms our application now behaves correctly.

Testing the error handling

Nice.