Playwright architecture, selector reliability, and advanced interaction patterns.

File Uploads & Downloads

File transfer is a deliberate browser security boundary, and that boundary shapes how you automate it. You cannot script a real operating-system file picker, and you should not try—any approach that drives native dialogs is fragile and breaks the moment the OS or browser changes. Playwright sidesteps the dialog on both ends: uploads attach File buffers straight to the input element, and downloads are captured as an event you save and inspect programmatically. This guide, part of Advanced Interactions & Test Assertions, establishes the upload and download patterns that behave identically in headed local runs and headless CI containers.

The recurring rule mirrors the rest of the suite: synchronize on observable state, not elapsed time. An upload is done when the server acknowledges it and the UI confirms it; a download is done when the bytes are on disk and pass a size or content check. Arbitrary waitForTimeout() calls have no place in either flow.

Upload and download data paths in Playwright Upload injects files into the input then waits for the server response; download captures the event, saves to disk, and verifies bytes. Upload setInputFiles waitForResponse 200 OK assert success Download click trigger + download event saveAs(path) verify bytes size > 0
Uploads end at a confirmed server response; downloads end at verified bytes on disk. Neither relies on a fixed delay.

Why the file dialog is unreachable, and what Playwright does instead

Page JavaScript has no ambient read access to the filesystem. The only way a document obtains a File object is if the user hands one over through a trusted gesture—choosing it in the native picker, or dragging it from a file manager onto the page. That gesture happens in operating-system chrome, outside the rendering engine entirely, which is exactly why no amount of dispatchEvent() in the page can populate an <input type="file">. The files property of a file input is read-only to untrusted script, and the isTrusted flag on synthesized events prevents the browser from treating a fabricated change as a genuine selection.

Playwright does not fight this from inside the page. It talks to the browser over the DevTools/remote protocol at a privilege level above the document, and asks the browser itself to attach the file descriptors to the input node. From the page's point of view the result is indistinguishable from a real selection: the input's files list is populated with genuine File objects, and the browser dispatches trusted input and change events so React, Vue, and Angular change handlers fire exactly as they would for a human. That is why setInputFiles() is not a simulation—it is the real code path with the dialog removed.

Downloads work the same way in reverse. When a response carries Content-Disposition: attachment, or a page calls URL.createObjectURL() on a Blob and clicks a synthetic anchor with a download attribute, the browser's download manager takes over. Normally it would prompt for a save location; with acceptDownloads enabled (the default in recent versions of the test runner) Playwright instead streams the bytes to a temporary directory it controls and hands your test a Download object referencing them. The file lives in that temporary location only as long as the owning browser context does, which is the single most common cause of "the file was there a second ago" confusion.

Understanding this split matters because it tells you where to assert. The upload side is a network fact: the multipart POST either reached the server and was parsed, or it did not. The download side is a filesystem fact: bytes either landed and match expectations, or they did not. Assertions that straddle both—"click and hope the toast appears"—are the ones that go flaky first.

Prerequisites

You need Playwright 1.40 or later, a project scaffolded with @playwright/test, and a fixtures/ directory of small, committed sample files. Keep fixtures tiny: a 4 KB PDF and a 200-byte CSV exercise every code path a 40 MB file does, without inflating repository size or CI clone time. Generate genuinely large payloads at runtime with a buffer instead of committing them.

Downloads write to disk, so decide early where. Set downloadsPath in playwright.config.ts if you want a predictable root, and add both that directory and your fixture output folder to .gitignore. If your suite already uses shared setup, the fixture composition patterns in Playwright Config & Fixtures are the right place to hang a reusable temporary-directory helper rather than repeating path.join() calls in every spec.

Uploads with setInputFiles

locator.setInputFiles() writes File objects directly onto an <input type="file"> node, skipping the OS dialog and the brittle click that would open it. It works the same headed or headless, accepts a single path or an array, and can also take in-memory buffers when you want to upload generated content without a fixture on disk. Always resolve fixture paths with path.join(__dirname, ...) so the spec runs identically across macOS, Linux, and containerized CI; bare relative strings resolve against the process working directory and break in CI.

import { test, expect } from '@playwright/test';
import path from 'path';
import { fileURLToPath } from 'url';

const dir = path.dirname(fileURLToPath(import.meta.url));

test('single upload confirmed by server and UI', async ({ page }) => {
  await page.goto('/upload');

  const input = page.locator('input[type="file"]');
  await input.waitFor({ state: 'visible' });

  // Register the network wait BEFORE the action that triggers it,
  // or the response can arrive before the listener exists.
  const uploaded = page.waitForResponse(
    (res) => res.url().includes('/upload') && res.status() === 200,
  );

  await input.setInputFiles(path.join(dir, 'fixtures', 'document.pdf'));

  await uploaded; // server acknowledged the multipart payload
  await expect(page.locator('.upload-success')).toBeVisible();
});

Prefer the locator form locator.setInputFiles() over the older page.setInputFiles(selector, files); the locator carries actionability waiting so you do not race a not-yet-rendered input. To clear a selection, pass an empty array—await input.setInputFiles([])—which resets files and fires change again, the reliable way to test a "remove attachment" affordance. Because uploads frequently sit inside larger forms, the readiness and validation patterns in Form Automation & Input Handling apply directly when an upload field is one part of a submission.

Two attributes on the input change what the call will accept. If the element lacks multiple, passing an array of more than one path throws immediately rather than silently keeping the last file—useful, because it surfaces a markup assumption instead of hiding it. If the element carries webkitdirectory, Playwright expects a directory path and uploads its contents recursively, preserving webkitRelativePath on each entry so the server sees the folder structure. The accept attribute, by contrast, is advisory only: it filters what the native picker offers a human, and setInputFiles() ignores it entirely. That is a feature—it lets you assert that your server-side and client-side type validation actually rejects a .exe renamed to .png, which no human-driven test could easily produce.

When a form takes several files at once—an attachment list, a gallery import—the array form and per-file assertions deserve their own treatment. The walkthrough on handling multiple file uploads in Playwright covers batched paths, mixed file types, and asserting that every item rendered.

Choosing an upload entry point

Real applications rarely expose a bare, visible file input. Design systems hide the input behind a styled button, wrap it in a drop zone, or render no input at all until a modal opens. The decision of which API to reach for depends entirely on what exists in the DOM at the moment you act, not on what the user appears to click.

Choosing an upload API from the page markup A four-row matrix mapping each kind of upload markup to the Playwright API that handles it and the reason that API wins. Page markup Recommended API Why it wins visible file input setInputFiles(path) no dialog at all input hidden by CSS setInputFiles anyway skips visibility button, input added late filechooser event no node to target drop zone, no input dispatch DataTransfer last resort only
Read the markup first: three of the four cases resolve to setInputFiles, and only a genuinely input-less drop zone justifies synthesizing a DataTransfer.

The row that surprises people most is the second one. A display: none or zero-opacity input is still a fully functional node, and setInputFiles() deliberately bypasses the visibility half of actionability precisely so that it works on those. Component libraries from Material to shadcn ship exactly this pattern, and reaching for the file chooser event there adds fragility for no benefit. The mechanics of locating the concealed node, dealing with inputs inside shadow roots, and asserting the resulting preview are worked through in uploading files to hidden input elements, which is the page to read the first time a setInputFiles() call times out on a styled upload button.

Uploading from in-memory buffers

Some tests need a file that no fixture can reasonably represent: a 25 MB payload to trip a size limit, a file whose declared MIME type contradicts its bytes, or a CSV whose contents are derived from the test's own data. Passing an object with name, mimeType, and buffer instead of a path constructs the File in memory and never touches the disk, which is both faster and immune to fixture drift.

import { test, expect } from '@playwright/test';

test('rejects an oversized upload without committing a large fixture', async ({ page }) => {
  await page.goto('/upload');

  // 25 MB of zero bytes: allocated in the test process, never written to disk.
  const oversized = Buffer.alloc(25 * 1024 * 1024);

  await page.locator('input[type="file"]').setInputFiles({
    name: 'huge-report.pdf',   // what the server sees as the filename
    mimeType: 'application/pdf', // declared type, independent of the bytes
    buffer: oversized,           // the actual payload
  });

  // The client-side guard should fire before any network request leaves.
  await expect(page.getByRole('alert')).toHaveText(/exceeds the 10 MB limit/i);
});

test('server rejects a mislabelled executable', async ({ page }) => {
  await page.goto('/upload');

  // Bytes say "MZ" (a Windows executable header) while the name says .png.
  await page.locator('input[type="file"]').setInputFiles({
    name: 'avatar.png',
    mimeType: 'image/png',
    buffer: Buffer.from('MZ\x90\x00 not really an image'),
  });

  const rejected = page.waitForResponse((res) => res.url().includes('/avatar') && res.status() === 415);
  await page.getByRole('button', { name: 'Save avatar' }).click();
  await rejected; // sniffing on the server caught the mismatch
});

Buffer uploads are the practical way to test the negative paths that matter for security review. Keep the allocation inside the test rather than at module scope so it is released between workers, and avoid buffers above roughly 50 MB—the payload crosses the driver protocol, and very large transfers are slow enough to skew your suite's timing budget.

Intercepting the file chooser

Occasionally there is genuinely no input to target. Electron-style wrappers, some canvas-based editors, and applications that create the input inside a click handler and remove it immediately afterwards leave nothing for a locator to find. For these, Playwright surfaces the dialog itself as a filechooser event: the browser is about to open the native picker, and your test intercepts that intent and supplies the files instead.

import { test, expect } from '@playwright/test';
import path from 'path';
import { fileURLToPath } from 'url';

const dir = path.dirname(fileURLToPath(import.meta.url));

test('supplies files through the intercepted chooser', async ({ page }) => {
  await page.goto('/editor');

  // Same register-before-trigger rule: the listener must exist before the click.
  const chooserPromise = page.waitForEvent('filechooser');
  await page.getByRole('button', { name: 'Import image' }).click();
  const chooser = await chooserPromise;

  // The chooser exposes the element that opened it and its multiple flag.
  expect(chooser.isMultiple()).toBe(false);

  // setFiles resolves the dialog; without it the picker stays open and the
  // test hangs until the action timeout expires.
  await chooser.setFiles(path.join(dir, 'fixtures', 'logo.png'));

  await expect(page.getByRole('img', { name: 'logo.png' })).toBeVisible();
});

Two details make this reliable. First, the event fires on the page, so a chooser opened from inside an iframe still surfaces on the owning page object rather than the frame. Second, an intercepted chooser must be resolved—call setFiles() on it, even with an empty array to simulate a cancelled dialog. Leaving it unresolved parks the browser in a modal state and every subsequent action on that page times out, which reads in the report as an unrelated failure several lines later.

Treat the file chooser as the fallback, not the default. It couples your test to a transient UI moment rather than to a durable DOM node, and it cannot express "attach three files to a form and submit once" nearly as cleanly as an array passed to setInputFiles().

Drag-dropped uploads

Some interfaces accept files dropped onto a zone rather than chosen through an input. Those build a synthetic DataTransfer in the page and dispatch a drop event carrying it, which overlaps with the gesture machinery in Drag & Drop Workflows. Where a hidden <input type="file"> exists behind the drop zone—common in component libraries—prefer setInputFiles() on that input over simulating the drop, since it is more stable. Reach for the synthetic DataTransfer only when the application reads event.dataTransfer.files directly and never populates an input, and expect to assert on the resulting preview rather than on the drop event itself.

Downloads via the download event

Downloads are the mirror image of uploads. First set acceptDownloads: true on the context so the browser hands the file to your test instead of routing it through OS save behavior. Then capture the download event, pairing it with its trigger inside Promise.all() so the listener is registered before the click fires—the same register-before-trigger rule that governs uploads and the file chooser.

Sequence of a captured download A sequence diagram across four lifelines showing listener registration, the click, the attachment response, temp streaming, event resolution, and saveAs. test runner page download manager disk register listener click export attachment header stream to temp download resolves saveAs target path
The listener is registered one step before the click; every later step is the browser reporting progress back, which is why nothing in the flow needs a timeout.
import { test, expect } from '@playwright/test';
import path from 'path';
import { fileURLToPath } from 'url';
import fs from 'fs/promises';

const dir = path.dirname(fileURLToPath(import.meta.url));

test('download captured and verified on disk', async ({ browser }) => {
  const context = await browser.newContext({ acceptDownloads: true });
  const page = await context.newPage();
  await page.goto('/export');

  // Atomic: listener in place before the click that starts the download.
  const [download] = await Promise.all([
    page.waitForEvent('download'),
    page.getByRole('button', { name: 'Export CSV' }).click(),
  ]);

  const target = path.join(dir, 'downloads', download.suggestedFilename());
  await download.saveAs(target);

  // Assert size > 0, never a hardcoded byte count — content changes.
  const stats = await fs.stat(target);
  expect(stats.size).toBeGreaterThan(0);
  expect(stats.isFile()).toBe(true);

  await context.close();
});

Avoid asserting stats.size against a literal like 15420; file sizes shift with content and turn a real test into a maintenance burden. Assert greater than zero by default, and only check an exact length when you fully control the exported fixture. Pair the size check with a web-first assertion on the UI—many exports also flip a "ready" state—using the patterns collected in Web-First Assertions so the two facts are asserted independently rather than one standing in for the other.

The Download object lifecycle

The Download handle you receive resolves as soon as the browser begins the transfer, not when it finishes. That distinction explains most download flakiness. download.path() waits for completion and returns the temporary file location, or null if the transfer failed; download.failure() returns an error string once the transfer settles, or null on success. download.saveAs() implicitly waits for completion too, which is why the snippet above needs no polling.

States of a Playwright Download object A state machine showing a download moving from event to temp write to a resolved path and saveAs, with cancel and failure branches. cancel() discards download event writing to temp path() resolves saveAs(target) failure() non-null aborted network error temp file removed on context close
Only saveAs moves bytes out of the browser's temporary directory; anything still living there disappears the moment the owning context closes.

The temporary-file rule deserves emphasis because it produces a failure that looks like a race but is not. If you capture download.path(), close the context, and then read the file in an afterEach hook or a reporter, the read fails with ENOENT every time—the browser deletes its temporary artifacts when the context that owns them goes away. Always call saveAs() to a location you control before closing the context, and treat the path returned by path() as read-only and short-lived.

download.cancel() is the tool for testing abort behaviour: call it while the transfer is in flight and failure() resolves to a cancellation message, letting you assert that the UI reports a stopped export rather than a silently missing file. download.delete() removes the temporary copy early, which is worth doing in long-running scraping runs that pull hundreds of files and would otherwise fill the container's disk before the context ever closes.

For deeper validation—parsing the file, checking Content-Disposition, confirming a CSV's rows or a PDF's header bytes—see automating file downloads and verifying contents, which covers reading the saved file back and asserting on its structure rather than just its existence.

Streaming large exports without saving them

When the file is large and you only need to inspect part of it, writing the whole thing to disk is wasted I/O. download.createReadStream() returns a Node readable stream over the temporary file, so you can hash the contents, count lines, or read a header without a saveAs() round trip. This is the pattern that keeps report-export tests fast in suites that download multi-megabyte spreadsheets on every run.

import { test, expect } from '@playwright/test';
import { createHash } from 'crypto';

test('verifies a large export by streaming rather than saving', async ({ page }) => {
  await page.goto('/reports');

  const downloadPromise = page.waitForEvent('download');
  await page.getByRole('button', { name: 'Download full ledger' }).click();
  const download = await downloadPromise;

  // Fail loudly if the transfer itself broke, before touching the bytes.
  expect(await download.failure()).toBeNull();

  const stream = await download.createReadStream();
  const hash = createHash('sha256');
  let bytes = 0;
  let firstLine = '';

  for await (const chunk of stream) {
    if (!firstLine) firstLine = chunk.toString('utf8').split('\n')[0]; // CSV header row
    bytes += chunk.length;
    hash.update(chunk); // incremental: the file never sits in memory whole
  }

  expect(firstLine).toBe('id,account,amount,currency');
  expect(bytes).toBeGreaterThan(1_000_000);
  expect(hash.digest('hex')).toHaveLength(64);
});

Streaming also gives you a stable identity check across runs: hash the export, store the digest, and compare it when the underlying data is fixture-controlled. If the data is live, hash only the header row and assert on row counts instead. Extraction pipelines that turn these exports into records will find the parsing side covered in extracting tables and lists to JSON.

Content validation and network interception

Verifying a transfer often means looking at the response, not just the disk. Reading the upload response body confirms the server parsed your multipart payload; inspecting a download's headers confirms the right MIME type and filename. Both lean on the routing and response APIs in Network Interception Basics—for example, asserting that the upload POST returned the expected file id, or intercepting the download request to validate Content-Type before the bytes ever land. For data-extraction pipelines that download exports to parse downstream, this validation is the boundary between a trustworthy dataset and a silently truncated one.

Interception also lets you fabricate conditions the backend will not produce on demand. Route the upload endpoint to return 413 Payload Too Large and assert the UI surfaces a size error; return a 500 and assert the retry affordance appears; delay the response by a second and assert the progress indicator stays visible for the duration. Each of these is a real user-visible behaviour that would otherwise require coordinating with a backend team to reproduce.

One caveat: do not route-fulfil the download response itself unless you intend to. If a handler returns a body without a Content-Disposition: attachment header, the browser renders it inline instead of downloading it, no download event fires, and the test hangs on waitForEvent() until the timeout. When you must stub a download, include the disposition header explicitly in the fulfilled response.

Failure modes and debugging

Five failures account for nearly every broken file test.

The upload timed out on a hidden input. A setInputFiles() call that hangs is almost always waiting on a locator that matched zero elements, not on visibility—the call tolerates hidden inputs but still requires the node to exist and to be a file input. Check the resolved count with await expect(input).toHaveCount(1) before the call.

Strict mode violation on input[type="file"]. Pages with multiple upload widgets, or a modal rendered alongside the page-level form, match more than one input. Scope the locator to a labelled ancestor rather than adding .first(), which papers over the ambiguity and silently targets the wrong widget when the DOM order changes.

The download event never fired. Either the listener was registered after the click, or the response lacked Content-Disposition: attachment and the browser navigated instead. Check the trace's network tab: a 200 with Content-Type: text/csv and no disposition header renders inline. The Trace Viewer & Debugging workflow shows the request headers alongside the action timeline, which settles this question in seconds.

ENOENT reading the saved file. Either the target directory did not exist—saveAs() creates intermediate directories, but a manual fs.writeFile() to the same path does not—or you read a path() result after the context closed. Save first, close second.

Passing locally, failing in the container. Nine times in ten this is a path that resolved against the working directory, or a fixture excluded by .dockerignore. Print the resolved absolute path in the failing assertion message so CI logs tell you which one it is.

For any of these, attaching the downloaded artifact to the report makes triage far quicker; the attachment API described in Reporters & Test Artifacts will carry the file itself into the HTML report next to the failure.

CI and headless reliability checklist

A few rules keep file tests deterministic everywhere they run. Use locator.setInputFiles(), not the deprecated page.setInputFiles(selector, files) form. Set acceptDownloads: true explicitly on any context you construct by hand, even though the test runner default already enables it, so the intent survives a refactor. Resolve every path through path.join(__dirname, ...) rather than bare relative strings. Register waitForResponse() for uploads and waitForEvent('download') for downloads before the triggering action, pairing the download with Promise.all(). Use fs/promises throughout rather than synchronous fs.readFileSync(), so file I/O does not block the event loop.

Parallelism adds one more requirement: give every worker its own download directory. Two workers writing downloads/report.csv simultaneously produce a file that passes a size check and fails a content check intermittently, which is the worst kind of flake to diagnose. Derive the directory from test.info().workerIndex or from testInfo.outputPath(), which the runner already scopes per test. The isolation rationale is the same one that governs Browser Contexts & Isolation: shared mutable state between workers is the root cause, and the fix is always partitioning rather than locking.

Containers need disk headroom and a writable temporary location. The official image handles both, but custom images that run as a non-root user sometimes lack write permission on the download root—see Dockerizing Playwright for Headless CI for the image and permission setup. Clean downloaded artifacts between runs so a stale file from a previous build cannot satisfy an assertion, and keep upload and download timeouts slightly above your slowest realistic transfer rather than trusting the global default, following the budgeting approach in Configuring Retries and Timeouts for Stable CI. Following these turns file transfer—usually a flaky corner of a suite—into one of its most reliable parts.

Deep dives beneath this guide

Three focused walkthroughs sit under this page. Handling multiple file uploads in Playwright works through arrays of paths, mixed file types, and asserting that each attachment rendered in the list. Uploading files to hidden input elements is the answer when a styled upload button conceals its input behind CSS or a shadow root and your locator finds nothing. Automating file downloads and verifying contents goes past the existence check into parsing the saved file and asserting on its structure.

Frequently Asked Questions

Why use setInputFiles instead of clicking the file input?

Clicking a file input opens the operating-system picker, which Playwright cannot drive reliably and which behaves differently across platforms and in headless mode. setInputFiles() attaches the files straight to the input element through the DOM, so it works identically headed, headless, and in CI containers.

How do I assert that a downloaded file is correct?

Save it with download.saveAs() to a deterministic path, then read it back with fs/promises. Assert stats.size is greater than zero for a basic check, or parse the content—CSV rows, JSON keys, PDF header bytes—when you need to verify structure. Avoid asserting against a hardcoded byte count, since content size varies.

Why does my download test hang or time out?

Almost always the download event listener was registered after the click that triggered it, so the event fired before anyone was listening. Wrap page.waitForEvent('download') and the trigger click together in Promise.all(), and make sure the context was created with acceptDownloads: true.

Does setInputFiles work on an input hidden with CSS?

Yes. The call deliberately skips the visibility half of actionability, so a display: none or zero-opacity input accepts files normally. It still requires the node to exist and to be a file input, so a hang usually means the locator matched nothing rather than that the element was hidden.

When should I use the filechooser event instead?

Only when no input element exists to target—for example when the application creates and removes the input inside its own click handler. Intercept page.waitForEvent('filechooser') before the click, then resolve it with chooser.setFiles(); an unresolved chooser leaves the page in a modal state and every later action times out.

How do I upload a file that is generated during the test?

Pass an object with name, mimeType, and buffer rather than a path. The File is constructed in memory and never written to disk, which is the practical way to test size limits, mismatched MIME types, and data derived from the test itself without committing large fixtures.

Where does a downloaded file live before I save it?

In a temporary directory owned by the browser context. download.path() returns that location, but the file is deleted when the context closes, so read it or call saveAs() to a path you control before closing. Use download.createReadStream() when you want to inspect the bytes without writing a second copy.

Back to overview