Home / Blog / Playwright file upload and download

Playwright file upload and download: the cases setInputFiles does not cover

Quick answer

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.

  1. 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.
  2. 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.
  3. The contents are right. saveAs into a path from testInfo.outputPath(), read it with fs, 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.