In Playwright, why must a test call download.saveAs() before its browser context closes?
answer
- The event gives a handle, not bytes
- Files live in a context-owned temporary area
- Closing the context deletes them
- saveAs waits for completion and copies
- suggestedFilename comes from the server
basics
~20 sPlaywright streams a download into a temporary location owned by the browser context and deletes it when that context closes. saveAs copies the bytes to a path you control; without it the file is gone once the test ends.
solid answer
~40 sThe `download` event gives you a `Download` object, not bytes. Playwright stores the transferred file in a temporary location tied to the browser context that produced it and deletes every such file when that context closes -- which, with the built-in `page` fixture, is the end of the test. `download.saveAs(path)` waits for the transfer to finish and copies the file somewhere you own, creating parent directories as needed, so it has to run inside the test rather than in a later hook. `download.path()` returns the managed temporary location, also waiting for completion. `download.suggestedFilename()` gives the name the server proposed via `Content-Disposition`, which is usually what you assert on; `download.failure()` reports a non-null reason when the transfer did not complete.
code
typescript · 17 linesimport fs from 'node:fs';
import { test, expect } from '@playwright/test';
test('exporting the board produces a CSV', async ({ page }, testInfo) => {
await page.goto('/board/team-core');
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'Export CSV' }).click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe('team-core-issues.csv');
const saved = testInfo.outputPath(download.suggestedFilename());
await download.saveAs(saved);
expect(fs.readFileSync(saved, 'utf8')).toContain('ISS-482');
});go deeper
Know the shape: wait for the download event, then call saveAs with a path you choose. Playwright does not drop the file into your project folder on its own.
Explain the lifetime -- a managed temporary copy owned by the browser context, deleted when that context closes -- and why saveAs and path both wait for the transfer to complete.
Show what you actually assert. Filename from suggestedFilename, contents read back from the saved file, and a failure path distinguished with failure() rather than a sleep and a directory glob.
Own where export artifacts go and how long they live, and decide how much download verification belongs in a browser test versus a direct check of the export endpoint.
## The event hands you a handle, not a file `page.on('download')` and `page.waitForEvent('download')` deliver a `Download` object. It is a handle to a transfer, not the bytes and not a path in your project. Playwright streams the response into a temporary location it manages, and the object is how you ask questions about that transfer. The canonical sequence is short but order-sensitive: 1. Create the wait -- `const downloadPromise = page.waitForEvent('download');` 2. Perform the action that starts it, such as clicking **Export CSV** on the board. 3. Await the promise to get the `Download`. 4. Call `download.saveAs(path)` and assert on the result. Creating the promise after the awaited click is the classic mistake: the event has already fired and the wait subscribes to nothing. ## Where the bytes actually sit Playwright writes the file into a temporary directory that belongs to the **browser context** which produced it. Those files are deleted when that context closes. With the built-in `page` fixture, the context closes at the end of the test, so a download that was never saved is gone the moment the test finishes -- including in the failure path, where you might most want it. That is the whole reason `saveAs` exists and the whole reason it must run inside the test rather than in some later reporting step. `download.saveAs(path)` waits for the transfer to complete, copies the file to a path you own, and creates the parent directories if they are missing. Writing into the test's own output directory keeps the artifact next to the run that produced it. ## The Download surface | member | what it does | | --- | --- | | `download.saveAs(path)` | waits for completion, then copies the file to `path` | | `download.path()` | the managed temporary location, after completion | | `download.suggestedFilename()` | the name from `Content-Disposition` or the link's `download` attribute | | `download.url()` | the URL the transfer came from | | `download.failure()` | a reason string, or `null` when it succeeded | | `download.delete()` | removes the temporary copy immediately | | `download.cancel()` | aborts a transfer in progress | | `download.createReadStream()` | a readable stream over the downloaded file | | `download.page()` | the page that started it | ## What is worth asserting - **The filename.** `suggestedFilename()` reflects what the server proposed, which is a real contract with the user and often wrong in interesting ways after a refactor. The last segment of `url()` is not a substitute. - **The contents.** Save the file, read it back, and assert on a row you seeded -- for an issue export, that the CSV contains the key of an issue on the board. - **That the download happened at all.** Awaiting the event is itself the assertion; there is no need to poll a directory or sleep after the click. Points worth knowing about the surrounding behaviour: - The context option `acceptDownloads` defaults to true, so downloads are accepted without extra configuration. - `download.path()` and `saveAs` both wait for completion, so neither needs a sleep in front of it. - `createReadStream()` lets you inspect the contents without ever writing a second copy -- but it still reads through the managed temporary file, so it too must run before the context closes. - `failure()` is how you distinguish "the transfer broke" from "the click never started one", which are very different bugs. ## The mistakes that survive code review A test that clicks export, sleeps, and then globs a directory looks plausible and fails intermittently forever: it depends on a location Playwright does not promise and on a duration nothing guarantees. A test that saves the file in an `afterAll` hook is worse -- by then the context is closed and the temporary copy no longer exists. And a test that asserts only that the click did not throw asserts nothing at all: the export endpoint could return an empty file every time and stay green. Treat the download as a value the test produces: capture it, save it somewhere you control, read it back, assert on it, and let the framework clean up the temporary copy when the context goes away.
- How do you read a download's contents without writing a second copy?`download.createReadStream()` returns a readable stream over the managed file, so the test can buffer or parse it in place. It still waits for the transfer to finish and still reads through the context-owned copy, so it must run before that context closes.
- What tells you a download never completed?`download.failure()` resolves to a reason string when the transfer failed or was aborted and to null when it succeeded. `download.cancel()` aborts one deliberately, after which failure() reports it -- useful for distinguishing a broken transfer from a click that never started one.
saying these in an interview costs you the question
- Assumes the file lands in the project folder automatically
- Saves the download in an afterAll hook instead of the test
- Thinks the download event delivers the file's bytes directly
- Waits for a download by sleeping after the click
- Trusts the URL's last path segment as the filename