Home / Blog / Playwright file upload and download
Playwright file upload and download: the cases setInputFiles does not cover
Upload with setInputFiles on the <input type="file">,
including one a styled zone hides. If the input appears only on a click, wait for the
filechooser event. If the zone has no input, locator.drop()
puts a file on it. Download by starting the wait before the click, then saveAs
to a path you control and assert on what is in the file.
The documented calls cover the first upload form you meet. They run out at the styled drop zone that has nothing in it to address, and at the download test that never opens the file it downloaded.
Everything below was run before this page shipped, against Playwright 1.62 in Chrome, on a
small application built for it: a plain file input, a multiple-file input, an import that
rejects the wrong type and anything over its size limit, a drop zone with a hidden input, a
drop zone with no input element anywhere in it, a CSV served with
Content-Disposition: attachment, an export built in the browser from a blob, and
a download link with target="_blank". The caption under each sample says what the
run did.
How do you upload a file in Playwright?
locator.setInputFiles() puts the file on the input, and four shapes of argument
cover almost every upload: a path, an array of paths, an empty array, and an object holding a
buffer. The documentation
is exact about what it wants on the other end — "It expects first argument to
point to an input element with the type file. Multiple files can be passed in the
array. If some of the file paths are relative, they are resolved relative to the current
working directory. Empty array clears the selected files."
Every sample below was written for Playwright 1.62 and run in Chrome on Playwright 1.62.1.
import { test, expect } from '@playwright/test';
import path from 'path';
const FIXTURES = path.join(__dirname, '..', 'fixtures');
test('the four shapes setInputFiles takes', async ({ page }) => {
await page.goto('/');
// One file. Build the path from __dirname, never from the repository root.
await page.getByLabel('Import file').setInputFiles(path.join(FIXTURES, 'contacts.csv'));
await expect(page.locator('#single-out')).toHaveText('contacts.csv (55 bytes)');
// Several files, on an input that accepts them.
await page.getByLabel('Attachments').setInputFiles([
path.join(FIXTURES, 'contacts.csv'),
path.join(FIXTURES, 'orders.csv'),
]);
await expect(page.locator('#multi-out')).toHaveText('2 files: contacts.csv, orders.csv');
// An empty array clears the selection.
await page.getByLabel('Attachments').setInputFiles([]);
await expect(page.locator('#multi-out')).toHaveText('0 files');
// A file that exists only for the length of this test.
await page.getByLabel('Import file').setInputFiles({
name: 'generated.csv',
mimeType: 'text/csv',
buffer: Buffer.from('name,email\nada,ada@example.com\n'),
});
await expect(page.locator('#single-out')).toHaveText('generated.csv (31 bytes)');
});
Playwright 1.62 · TypeScript · passes
A buffer needs no fixture file in the repository, no path, and no argument about whether a 40MB sample spreadsheet belongs in version control — the file exists for the length of the test and nowhere else. Where the contents of the import matter more than the bytes on disk, generating it beside the assertion is a test data decision.
The failure this section owes you
Relative paths resolve against the current working directory. That is the directory the runner was started from, and it has nothing to do with where your spec file sits.
In our run, with the working directory at the project root,
setInputFiles('fixtures/contacts.csv') found the file. The same call written
setInputFiles('contacts.csv') threw ENOENT: no such file or directory
and printed the project root with the bare filename glued on, rather than the
directory the spec file lives in. A suite written that way passes on the machine
it was written on and fails the first time somebody runs it from a subdirectory, or a runner
picks its own working directory. Build the path from __dirname and the question
never comes up.
What if the page has no file input?
Some widgets create the <input> when you click, hand it to the browser and
throw it away. There is nothing to locate before the click and nothing left after it. The
documentation names this case: "If you don't have input element in hand (it is created
dynamically), you can handle the page.on('filechooser') event or use a
corresponding waiting method upon your action".
Use the waiting method, and note the shape of it, because the download half of this page uses the same one: assign the wait before the action and await it after. The documentation's own sample carries the instruction as a comment: "Start waiting for file chooser before clicking. Note no await."
import { test, expect } from '@playwright/test';
import path from 'path';
test('an input that does not exist until the click', async ({ page }) => {
await page.goto('/');
// Start the wait before the click. No await on this line.
const chooserPromise = page.waitForEvent('filechooser');
await page.getByRole('button', { name: 'Choose a file' }).click();
const chooser = await chooserPromise;
expect(chooser.isMultiple()).toBe(false);
await chooser.setFiles(path.join(__dirname, '..', 'fixtures', 'orders.csv'));
await expect(page.locator('#dynamic-out')).toHaveText('orders.csv (11 bytes)');
});
Playwright 1.62 · TypeScript · passes
A FileChooser comes back, and it carries more than somewhere to put
files. isMultiple() reports whether the widget accepts more than one, which
catches a multiple attribute quietly
dropped in a refactor. In our run it returned false on the single input and
true on the multiple one. element() hands back the input itself if
you want to read its attributes.
Starting the wait after the click is how this goes wrong. The click fires the chooser, the chooser is gone by the time anything is listening, and the test then sits on an idle page until its wait expires. That wait is not auto-waiting, and the two fail with different text; which wait actually fired is a separate article.
How do you test a drag-and-drop upload zone?
A drop zone is a <div> with drag handlers on it, and what sits inside that
div decides which of two answers you need. Open the element panel before you write anything.
First: look for the input the zone is hiding
A zone that also lets a user click to browse has a real <input type="file">
in the DOM, put out of sight with CSS. That is the whole widget in most component libraries,
and it is the markup our demo application serves.
<div class="zone" id="upload-zone">
<p>Drop a file here, or <span id="browse">browse</span></p>
<input id="file" type="file" style="display: none">
</div>
The drop zone in the demo application, served as written
An invisible element is normally the end of a test, because Playwright waits for an element to
be visible before it acts on it. This method does not.
The documentation's actionability table
lists twenty actions against five checks, and the locator.setInputFiles() row
carries nothing under Visible, Stable, Receives Events, Enabled or Editable. No checks means
nothing has to be true about the element before the call runs, visibility included.
In our run, against an input with
display: none, expect(input).toBeHidden() passed,
setInputFiles filled it and the application's change handler fired
with the file. A click() on the same node, in the same test, expired without
doing anything.
import { test, expect } from '@playwright/test';
test('the input the drop zone hides', async ({ page }) => {
await page.goto('/');
const input = page.locator('#file');
await expect(input).toBeHidden();
await input.setInputFiles({
name: 'contacts.csv',
mimeType: 'text/csv',
buffer: Buffer.from('name,email\nada,ada@example.com\n'),
});
await expect(page.locator('#styled-out')).toHaveText('contacts.csv (31 bytes)');
});
test('a zone with no input in it', async ({ page }) => {
await page.goto('/');
await page.locator('#pure-zone').drop({
files: { name: 'orders.csv', mimeType: 'text/csv', buffer: Buffer.from('id,qty\n1,2\n') },
});
await expect(page.locator('#pure-out')).toHaveText('Uploaded orders.csv (11 bytes)');
});
Playwright 1.62 · TypeScript · both pass
A hidden input usually has no label and no accessible name, so getByLabel has
nothing to work with and you fall down the ranking to a CSS or test-id locator. That is a
reasonable place to end up here and
the locator ranking says when it is not.
Verdict: this is the first thing to try and it is the cheapest test to
maintain, because it drives the same input a real user's file picker drives. If clicking the
zone opens a picker instead, that is the filechooser case above and not a
drag-and-drop problem at all.
Then: the zone with no input anywhere in it
Some zones read event.dataTransfer.files in a drop handler and post
the bytes themselves. There is no input, so there is nothing for
setInputFiles to attach to, and pointing it at the zone in our run threw
locator.setInputFiles: Error: Node is not an HTMLInputElement.
locator.drop(), added in Playwright 1.60, is the method for this.
The reference
describes it as "Simulate an external drag-and-drop of files or clipboard-like data onto
this locator", and says what it does underneath: "Dispatches the native
dragenter, dragover, and drop events at the center of
the target element with a synthetic [DataTransfer] carrying the provided files and/or data
entries. Works cross-browser by constructing the [DataTransfer] in the page context." It
takes the same file shapes setInputFiles does: a path, an array of paths, or the
in-memory object. The second test in the block above is that buffer form landing on a zone
with nothing in it. Dropping one path and dropping an array of two both worked in the same
run.
One caveat, and it is documented: "If the target element's dragover listener
does not call preventDefault(), the target is considered to have rejected the
drop: Playwright dispatches dragleave and this method throws." Aimed at an
<h1> with no handlers at all, our run got
locator.drop: Drop target did not accept the drop — its dragover handler did not call
preventDefault(), which means either the locator is on the wrong
element or the application does not accept drops there.
The older route still works. dispatchEvent accepts a JSHandle as an
event property, so you can build a DataTransfer in the page with
page.evaluateHandle, add a File to it, and dispatch
dragover and then drop with it attached. We ran that too and the zone
took the file. It is roughly eight lines where drop() is one, and it has to know
which events the widget listens for. The documentation's own sample for that route carries the
limit on it: "Note you can only create DataTransfer in Chromium and Firefox".
Verdict: use locator.drop(). Reach for the handmade
DataTransfer only if you are pinned below 1.60, and delete it when you upgrade.
We have now run it on all three engines, and the reason to prefer it is stronger than brevity.
The same zone, the same file buffer and the same data map on 1.62.1 in Chromium,
Firefox and WebKit produced identical logs: dragenter, dragover,
drop, and a 12-byte note.txt of type text/plain arriving in
dataTransfer.files every time. A zone whose dragover does not call
preventDefault() threw in all three, with the same message in each.
In WebKit the handmade route cannot be built at all: the documentation's own limit, quoted
above, rules it out. There, drop() is the only way onto a drop zone with no input in
it.
Can Playwright open the operating system's file dialog?
No. Playwright drives a browser, and the window your operating system paints when a file input is clicked is not part of the browser's document. There is no method for it and no setting that turns one on.
Nothing needs one. setInputFiles sets the input's files directly, and the
filechooser event hands your test a FileChooser object at the moment
a chooser would have appeared. In every run behind this page, headed and headless, no dialog
was ever drawn.
The same boundary rules out a few neighbouring things: a file picker inside a browser extension, a drag from your desktop into the browser window, and whatever the operating system does with the file after your application has finished with it. Every file on this page moves in or out of a browser, mobile browsers included, and none of it touches a native iOS or Android app.
How do you download a file in Playwright?
Same shape as the file chooser: start the wait, do the thing that triggers the download, then
await the promise. page.waitForEvent('download') gives you a Download
object with the suggested filename, the URL and the file itself.
import { test, expect } from '@playwright/test';
test('the report downloads', async ({ page }, testInfo) => {
await page.goto('/');
// Start the wait before the click. Await this line instead and the click
// never runs, so the event never fires and the wait expires on an idle page.
const downloadPromise = page.waitForEvent('download');
await page.getByRole('link', { name: 'Download report' }).click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe('report.csv');
await download.saveAs(testInfo.outputPath('report.csv'));
});
Playwright 1.62 · TypeScript · passes
Use saveAs rather than path(), because of where the browser put
the file. With no downloadsPath set, path() in our
run returned a file inside test-results/.playwright-artifacts-0/; under the
config below, one in the downloads directory that config names. Both were named
with a GUID, which the reference warns about: "Note that the download's file name is a random GUID,
use download.suggestedFilename() to get suggested file name."
saveAs copies it to a path and a name you chose, and it can be called
while the download is still running.
If you have no idea what starts the download, page.on('download', ...) catches it
wherever it comes from. The guide attaches a warning to that form:
"Note that handling the event forks the control flow and makes the script harder to
follow."
Two shapes of download look like they need special handling and do not. An export
built in the browser — a Blob, an object URL and an anchor with a
download attribute — fires the same event; in our run
suggestedFilename() came back as the anchor's filename and url()
started with blob:, so there is no HTTP request to assert against and the file on
disk is all you have. A link with target="_blank" also fires it: a tab opened and
closed, the event arrived on the page we had clicked from, and both
page.waitForEvent and context.waitForEvent caught it.
Wait on the page you clicked. Move the wait to the context only when the download really is initiated somewhere else, because a context-level wait will also catch a download some other test in the same context started. If the export endpoint is slow or flaky enough to be a problem in its own right, stub its response — intercepting responses is that page's subject.
Where does the downloaded file actually go?
Into a temporary folder the browser controls, under a name you did not choose, for as long as the browser context lives. Four facts follow from that.
You do not need acceptDownloads: true. It is already the default:
"Whether to automatically download all the attachments. Defaults to true where
all the downloads are accepted." Plenty of suites carry the line anyway, and the config
documentation is part of the reason — its own network-options sample sets
acceptDownloads: false, so a reader copying that block starts from the value
almost nobody wants. Setting it to false does not silence the event: our run still
got a Download with the right suggested filename, but path() threw
and failure() returned the string
Pass { acceptDownloads: true } when you are creating your browser context.
downloadsPath decides the directory, and its documentation
covers both cases: "If specified, accepted downloads are
downloaded into this directory. Otherwise, temporary directory is created and is deleted when
browser is closed. In either case, the downloads are deleted when the browser context they were
created in is closed."
The file dies with the context. In our run, with a
context created in the test, path() pointed at a file that existed; after
context.close() the file was gone and the directory was still there. Calling
path() or saveAs after that threw
Target page, context or browser has been closed, while
suggestedFilename() and url() kept answering, because those are
metadata your test already holds. Setting downloadsPath to a directory of our own
changed the location and nothing else: the file landed there and the directory was empty again
after the context closed. Under the test runner each test gets its own context, so the file is
already gone by the time a later step or an afterAll hook goes looking for it.
saveAs is the way out. Copy the file somewhere the
runner owns while the context is still open, and everything after that is ordinary file
handling.
downloadsPath is a launch option rather than a context one, and a test-runner user
never calls launch() themselves, so it goes in a nested block, which
the use-options guide spells
out: "Any options
accepted by browserType.launch(), browser.newContext() or
browserType.connect() can be put into launchOptions,
contextOptions or connectOptions respectively in the
use section."
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './tests',
use: {
baseURL: 'http://localhost:39861',
channel: 'chrome',
// Already the default. This line changes nothing; set it to false only to
// test what your application does when the browser refuses the download.
acceptDownloads: true,
// downloadsPath is a launch option, so it goes inside launchOptions.
launchOptions: {
downloadsPath: 'downloads',
},
},
});
Playwright 1.62 · TypeScript · the config the samples above ran under
How do you assert on the contents of a downloaded file?
A test that checks download.suggestedFilename() and stops has proved that a
button is wired to an endpoint. It passes when the export is empty, when it has a header row
and nothing under it, and when it contains last month's data. Our demo application has a second
export link that serves a header row and no rows under the same filename, and
expect(download.suggestedFilename()).toBe('report.csv') passed against it.
There are three levels of assertion here and only the third one is a test of the export.
- The download happened. Nearly free and nearly worthless on its own. Keep it as the first line of a longer assertion, because when it fails you know the button is broken and not the report.
- The file is the right file. Suggested filename, size on disk, and for a binary format the first few bytes. No parser needed for any of it.
- The contents are right.
saveAsinto a path fromtestInfo.outputPath(), read it withfs, and assert on a row count, a specific cell, or a total the fixture data implies.
import { test, expect } from '@playwright/test';
import fs from 'fs';
test('the report has the three orders in it', async ({ page }, testInfo) => {
await page.goto('/');
const downloadPromise = page.waitForEvent('download');
await page.getByRole('link', { name: 'Download report' }).click();
const download = await downloadPromise;
const saved = testInfo.outputPath('report.csv');
await download.saveAs(saved);
const rows = fs.readFileSync(saved, 'utf8').trim().split('\n');
expect(rows[0]).toBe('order_id,customer,total');
expect(rows).toHaveLength(4);
expect(rows[2]).toBe('1002,Globex,45.50');
});
Playwright 1.62 · TypeScript · passes
Change the link that spec clicks to the empty export, and it fails on the row count and prints the file it actually got.
Error: expect(received).toHaveLength(expected)
Expected length: 4
Received length: 1
Received array: ["order_id,customer,total"]
14 | const rows = fs.readFileSync(saved, 'utf8').trim().split('\n');
15 | expect(rows[0]).toBe('order_id,customer,total');
> 16 | expect(rows).toHaveLength(4);
| ^
17 | expect(rows[2]).toBe('1002,Globex,45.50');
18 | });
Playwright 1.62 · that spec with its click moved to the empty export link
download.createReadStream() reads the file without copying it anywhere first, and
the reference is candid about when to bother: "If you don't need a readable stream, it's
usually simpler to read the file from disk after the download completed."
CSV and JSON are ordinary code. PDF and XLSX are not, and without a parsing library the floor
is level two: the file is not zero bytes, it is roughly the size a real report
is, and it starts with the signature bytes of the format it claims to be. That catches the
export that returns an error page with a .pdf name. We have not used a PDF or spreadsheet parser on client work in a way we can point at
here, so this page names none.
Read the file. Asserting that the request was made is enough only where the export is a static asset your build produced, because then the bytes were decided before the test ran. Anything a query, a template or a date range computes needs level three, and it needs it on the row the business would notice.
A suite that asserts a download happened and never opens the file reports green on a broken export, and the team that eventually finds out finds out from a customer. Every export test in a repository owes the next reader a convention, and conventions get set once, when an end-to-end suite gets built properly.
What breaks in CI?
A path that only exists on a laptop. An absolute fixture path, or a relative
one that assumed somebody's working directory, is the same bug the upload section opened with,
and CI is where it surfaces because the runner chooses its own. Build every fixture path from
__dirname.
The file lands inside the container. In a container the download is written
to the container's filesystem and goes away with it, so a human looking at a failure an hour
later has nothing to open. Attach it to the report with testInfo.attach(), or
write it under the run's output directory and upload that as a build artifact.
The suite fills the disk. Not from Playwright's own downloads — those go with
the context. It fills from the copies the suite made with saveAs
into a directory somebody invented, which nothing cleans and nothing counts.
Two workers writing the same filename. Every downloaded
report.csv carries the same suggested name, so parallel tests saving into a shared
directory overwrite each other and the loser asserts on the winner's file. It presents as
flakiness and it has no timing in it. testInfo.outputPath() answers this and the
previous failure at once: "Returns a path inside the testInfo.outputDir where
the test can safely put a temporary file. Guarantees that tests running in parallel will not
interfere with each other." In our run it resolved to a directory named after the spec
file and the test title, one per test, and the runner owns and cleans it.
Questions
How do you upload a file in Playwright when there is no <input> on the page?
Look for the input first: a drop zone that also lets a user click to browse almost always has an input of type file hidden with CSS, and setInputFiles fills a hidden input because the documentation's actionability table lists no checks against it. If the input is created by the click and thrown away, wait for the filechooser event and call setFiles on what it hands you. If there is no input anywhere in the zone, locator.drop() puts the file on it; we ran that against a zone with nothing in it in Chromium, Firefox and WebKit on 1.62.1 and the file arrived identically in all three, and pointing setInputFiles at the same zone threw "Node is not an HTMLInputElement". WebKit is the reason to reach for drop() rather than a handmade DataTransfer: the documentation limits that older route to Chromium and Firefox.
Can Playwright interact with the operating system's file dialog?
No. Playwright drives a browser, and the window your operating system paints is not part of the browser's document, so there is no method for it and no setting that turns one on. Nothing needs one: setInputFiles sets the input's files directly, and the filechooser event hands your test a FileChooser object at the moment a chooser would have appeared. In every run behind this page, headed and headless, no dialog was ever drawn.
Where does Playwright save downloaded files?
Into a temporary folder, under a random name, for as long as the browser context lives. The downloadsPath option changes the directory: "If specified, accepted downloads are downloaded into this directory. Otherwise, temporary directory is created and is deleted when browser is closed. In either case, the downloads are deleted when the browser context they were created in is closed." We checked the deletion rather than recalling it: after context.close() the file was gone, the directory remained, and path() threw "Target page, context or browser has been closed". Copy the file out with saveAs while the context is still open.
How do you check what is inside a downloaded file?
Call saveAs with a path from testInfo.outputPath(), then read it with fs and assert on what is in it: a row count, a specific cell, or a total the fixture data implies. A test that checks only the suggested filename passes when the export has a header row and nothing under it, which we reproduced against a deliberately empty export. For PDF and XLSX without a parsing library the floor is size, the format's signature bytes and not empty, and that still catches the error page returned with a .pdf name.
How many of your export tests would still pass if the file came back empty?
One assertion on one download is an afternoon's work, and you have the code for it above. Two hundred of them, written by six people over three years with no convention behind any of them, is a job for a convention somebody owns. Send us the export test you trust least and we will tell you what it is currently proving.