File uploads have their own section in the network and advanced interactions chapter — this one covers the opposite direction, which works completely differently in Cypress than it does in Playwright or WebdriverIO.
SauceDemo has a real download to test against: after completing checkout, the order confirmation page has a "Generate PDF" button. Every example below is verified, passing code against that real button — downloadable at the bottom of this chapter.
Why This Isn't an Event You Listen For
Playwright and WebdriverIO both expose a download event you can await, because they control the browser through an external protocol that can observe browser-level events like a download starting and finishing. Cypress, running inside the browser itself, doesn't have that same hook — there's no cy.waitForDownload() command built in.
What Cypress can do reliably is what actually matters for a test: confirm that a file with the expected name landed in the download directory, and — if you need to go further — read its actual contents and assert on those.
Configuring the Download Directory
By default, Cypress downloads files to a cypress/downloads folder relative to your project root. You can change this in config if needed:
// cypress.config.ts
export default defineConfig({
e2e: {
downloadsFolder: 'cypress/downloads',
},
})
The Complication: SauceDemo's Filename Isn't Fixed
The straightforward case — trigger the download, then check a known filename exists with cy.readFile() — doesn't actually work here, and running into that is a useful lesson on its own. SauceDemo's PDF is named swag-labs-order-<timestamp>.pdf, a fresh timestamp on every single download. There is no fixed filename to assert against, which means cy.readFile('cypress/downloads/order.pdf') would never find anything, no matter how long you set the timeout.
This is common enough with real downloads — invoices, exports, and generated reports are frequently timestamped or otherwise unique per download — that it's worth solving properly rather than treating this one case as a one-off. The fix is a small Node task in setupNodeEvents that finds the newest file matching a known prefix, since cy.readFile() itself has no glob or wildcard support:
// cypress.config.ts
import { defineConfig } from 'cypress'
import fs from 'fs'
import path from 'path'
export default defineConfig({
e2e: {
baseUrl: 'https://www.saucedemo.com',
setupNodeEvents(on, config) {
on('task', {
findDownload(prefix: string) {
const dir = path.join(config.projectRoot, 'cypress', 'downloads')
if (!fs.existsSync(dir)) return null
const matches = fs
.readdirSync(dir)
.filter((f) => f.startsWith(prefix))
.map((f) => ({ name: f, mtime: fs.statSync(path.join(dir, f)).mtimeMs }))
.sort((a, b) => b.mtime - a.mtime)
return matches.length > 0 ? matches[0].name : null
},
})
return config
},
},
})
Verifying the Download
With the task in place, a test finds the actual file by prefix instead of guessing at an exact name, then reads it the same way you would any other file:
it('downloads the order confirmation PDF', () => {
// ... complete login, add an item to cart, and checkout ...
checkoutPage.completeHeader.should('have.text', 'Thank you for your order!')
cy.get('[data-test="generate-pdf-order"]').click()
cy.wait(1000) // give the download a moment to land on disk
cy.task('findDownload', 'swag-labs-order-').then((filename) => {
expect(filename, 'a PDF matching swag-labs-order-* should exist').to.be.a('string')
cy.readFile(`cypress/downloads/${filename}`, 'binary', { timeout: 10000 }).then((contents) => {
// a binary PDF isn't meaningfully assertable as text — a size sanity
// check confirms it's a real file, not an empty or broken download
expect(contents.length).to.be.greaterThan(100)
})
})
})
For a text-based download (CSV, JSON) where the filename is fixed and the content matters, the same cy.readFile() pattern works without the task — just read the file directly and assert on its contents:
cy.readFile('cypress/downloads/orders-export.csv', { timeout: 10000 }).then((contents) => {
expect(contents).to.include('Order ID,Total,Status')
})
Cleaning Up Between Test Runs
Downloaded files persist in the downloads folder across test runs unless you clean them up, which matters more than usual here — since the filename check above matches by prefix, a stale file from a previous run would satisfy the assertion even if the current run's download silently failed. Clear the folder at the start of a run that tests downloads:
before(() => {
cy.task('clearDownloads') // a second task, same pattern as findDownload — deletes the folder's contents
})
Download the Working Test
The download test above is real, passing code — part of the same tabs-and-downloads.cy.ts covered in the previous chapter — download it there.