Playwright & Web Automation Hub

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.

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. 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.

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.

Drag-dropped uploads

Some interfaces accept files dropped onto a zone rather than chosen through an input. Those build a synthetic DataTransfer and dispatch a drop, 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.

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.

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.

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.

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.

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. Always set acceptDownloads: true on contexts used for download tests. 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. Following these turns file transfer—usually a flaky corner of a suite—into one of its most reliable parts.

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.

Back to overview