Playwright's setInputFiles() bypasses the OS file picker by setting files directly on the element, which is why you can automate file uploads without the browser ever opening a dialog. Downloads work differently: you capture the download event with page.waitForEvent('download'), but you must register it before triggering the download, not after: if the download fires while you're still setting up the listener, the event is gone. This article covers single and multiple file uploads, in-memory file buffers, drag-and-drop via DataTransfer, the filechooser event for custom upload buttons, download capture and content verification, and fixture-based cleanup.

Why native file dialogs can't be automated with clicks

The native file picker (the OS dialog that opens when you click "Choose File") runs outside the browser's rendering engine. It belongs to the operating system. Playwright controls the browser; it has no mechanism to reach into a Windows Explorer or macOS Finder window and select a file.

This is intentional from a security standpoint. If JavaScript could programmatically trigger and complete a file picker dialog, it would be a trivial path to stealing files from a user's machine. Browsers sandbox that interaction specifically to prevent it.

So the standard approach for automating file uploads sidesteps the dialog entirely. Instead of simulating a click that opens the dialog, you interact directly with the underlying element and tell it which file to use. Playwright's setInputFiles() method does exactly this.

setInputFiles(): the standard approach

Most file upload forms use an element, even when it's visually hidden behind a styled button. setInputFiles() sets the files on that input programmatically, bypassing the dialog altogether.

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

test('uploads a single file', async ({ page }) => {
  await page.goto('https://lab.becomeqa.com/file-upload');

  const filePath = path.join(__dirname, 'fixtures/sample.pdf');

  await page.locator('input[type="file"]').setInputFiles(filePath);

  await page.getByRole('button', { name: 'Upload' }).click();

  await expect(page.getByText('Upload successful')).toBeVisible();
});

The path.join(__dirname, ...) pattern is important here. Absolute paths are more reliable than relative ones. __dirname gives you the directory of the current test file, so the fixture path resolves correctly regardless of where you run the test from.

If the file input has a name or id attribute, or is associated with a label, you can use a more descriptive locator:

// By label text
await page.getByLabel('Attach document').setInputFiles(filePath);

// By name attribute
await page.locator('input[name="resume"]').setInputFiles(filePath);

setInputFiles() respects the accept attribute on the input. If the input is restricted to images (accept="image/*") and you try to set a .pdf, Playwright will not throw, but the browser may reject the file silently. Always use files that match the input's accepted types.

Uploading multiple files at once

When the input allows multiple files (), pass an array of paths to setInputFiles():

test('uploads multiple files at once', async ({ page }) => {
  await page.goto('https://lab.becomeqa.com/file-upload');

  const files = [
    path.join(__dirname, 'fixtures/document-a.pdf'),
    path.join(__dirname, 'fixtures/document-b.pdf'),
    path.join(__dirname, 'fixtures/image.png'),
  ];

  await page.locator('input[type="file"]').setInputFiles(files);

  await page.getByRole('button', { name: 'Upload' }).click();

  // Verify all three files appear in the confirmation
  await expect(page.getByText('document-a.pdf')).toBeVisible();
  await expect(page.getByText('document-b.pdf')).toBeVisible();
  await expect(page.getByText('image.png')).toBeVisible();
});

To clear the selection and start over, pass an empty array:

// Remove all selected files
await page.locator('input[type="file"]').setInputFiles([]);

This is useful when a test flow involves changing the file selection before submitting, or when you need to reset the input state between steps.

If you need to construct test files dynamically rather than reading from disk, setInputFiles() also accepts a buffer:

test('uploads a file created in memory', async ({ page }) => {
  await page.goto('https://lab.becomeqa.com/file-upload');

  await page.locator('input[type="file"]').setInputFiles({
    name: 'generated-report.txt',
    mimeType: 'text/plain',
    buffer: Buffer.from('Test report content\nLine 2\nLine 3'),
  });

  await page.getByRole('button', { name: 'Upload' }).click();

  await expect(page.getByText('generated-report.txt')).toBeVisible();
});

The buffer approach is ideal when the file content matters to the test but you don't want to maintain files on disk for every variation.

Drag-and-drop file upload

Some upload interfaces use a drop zone rather than a file input: a

that listens for dragover and drop events. There's no to call setInputFiles() on.

The approach here is to dispatch the drag events manually using page.dispatchEvent() with a DataTransfer object populated via page.evaluate():

test('uploads a file via drag and drop', async ({ page }) => {
  await page.goto('https://lab.becomeqa.com/file-upload');

  const dropZone = page.locator('.drop-zone');
  const filePath = path.join(__dirname, 'fixtures/sample.pdf');

  // Read the file content and create a DataTransfer with it
  const fileContent = require('fs').readFileSync(filePath);
  const base64 = fileContent.toString('base64');

  await dropZone.evaluate(
    (element, { base64Content, fileName, mimeType }) => {
      const dataTransfer = new DataTransfer();
      const byteCharacters = atob(base64Content);
      const byteArray = new Uint8Array(byteCharacters.length);
      for (let i = 0; i < byteCharacters.length; i++) {
        byteArray[i] = byteCharacters.charCodeAt(i);
      }
      const file = new File([byteArray], fileName, { type: mimeType });
      dataTransfer.items.add(file);

      element.dispatchEvent(new DragEvent('dragenter', { dataTransfer, bubbles: true }));
      element.dispatchEvent(new DragEvent('dragover', { dataTransfer, bubbles: true }));
      element.dispatchEvent(new DragEvent('drop', { dataTransfer, bubbles: true }));
    },
    { base64Content: base64, fileName: 'sample.pdf', mimeType: 'application/pdf' }
  );

  await expect(page.getByText('sample.pdf')).toBeVisible();
});

This is more involved than setInputFiles(), but it's the correct approach for JavaScript-driven drop zones. If the drop zone also has a hidden file input that activates after drop, the simpler setInputFiles() approach may work instead. Inspect the DOM to find out.

Before reaching for the drag-and-drop approach, inspect the drop zone's HTML. Many "drag and drop" upload areas still contain a hidden that you can target with setInputFiles(). Check with DevTools first; it saves significant complexity.

Listening for filechooser events

Some upload buttons are styled

The ordering matters: register the filechooser listener before clicking the button. If the dialog fires and resolves before you start listening, you'll wait forever.

fileChooser.setFiles() accepts the same arguments as setInputFiles(): a single path, an array of paths, or a buffer object. For multiple files:

const fileChooserPromise = page.waitForEvent('filechooser');
await page.getByRole('button', { name: 'Attach Files' }).click();
const fileChooser = await fileChooserPromise;

await fileChooser.setFiles([
  path.join(__dirname, 'fixtures/attachment-1.pdf'),
  path.join(__dirname, 'fixtures/attachment-2.pdf'),
]);

You can also check whether the file input accepts multiple files before setting them:

const fileChooser = await fileChooserPromise;
console.log('Accepts multiple:', fileChooser.isMultiple());
await fileChooser.setFiles(filePath);

File downloads: capturing the download event

Downloads work through a different mechanism. When the browser triggers a file download, Playwright fires a download event on the page. You intercept it with page.waitForEvent('download') and get a Download object you can work with.

test('downloads a report file', async ({ page }) => {
  await page.goto('https://lab.becomeqa.com/reports');

  // Register the listener before triggering the download
  const downloadPromise = page.waitForEvent('download');

  await page.getByRole('button', { name: 'Export CSV' }).click();

  const download = await downloadPromise;

  // Save the file to a known location
  const savePath = path.join(__dirname, 'downloads/report.csv');
  await download.saveAs(savePath);

  // Verify the file exists
  const { existsSync } = require('fs');
  expect(existsSync(savePath)).toBe(true);
});

By default, Playwright saves downloads to a temporary directory that gets cleaned up when the browser context closes. saveAs() copies the file to a location you control so you can inspect it after the download completes.

The download object exposes the suggested filename before you save the file:

const download = await downloadPromise;
console.log('Suggested filename:', download.suggestedFilename());

await download.saveAs(
  path.join(__dirname, `downloads/${download.suggestedFilename()}`)
);

suggestedFilename() returns what the server sent in the Content-Disposition header, which is exactly what a user would see as the default filename in the save dialog.

Verifying download content

Capturing the download is step one. Asserting that the content is correct is what makes the test meaningful.

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

test('exported CSV contains the correct data', async ({ page }) => {
  await page.goto('https://lab.becomeqa.com/reports');

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

  // Assert the filename
  expect(download.suggestedFilename()).toMatch(/^report-\d{4}-\d{2}-\d{2}\.csv$/);

  // Save and read the content
  const savePath = path.join(__dirname, 'downloads/report.csv');
  await download.saveAs(savePath);

  const content = fs.readFileSync(savePath, 'utf-8');

  // Assert header row
  expect(content).toContain('id,destination,status,notes');

  // Assert the file is not empty beyond the header
  const rows = content.trim().split('\n');
  expect(rows.length).toBeGreaterThan(1);
});

For binary files, you can check the size rather than content:

test('downloaded PDF is not empty', async ({ page }) => {
  await page.goto('https://lab.becomeqa.com/reports');

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

  const savePath = path.join(__dirname, 'downloads/report.pdf');
  await download.saveAs(savePath);

  const stats = fs.statSync(savePath);

  expect(download.suggestedFilename()).toBe('report.pdf');
  expect(stats.size).toBeGreaterThan(1000); // A real PDF is more than 1KB
});

For structured formats like JSON, deserialize and assert the shape:

test('exported JSON has the expected structure', async ({ page }) => {
  await page.goto('https://lab.becomeqa.com/reports');

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

  const savePath = path.join(__dirname, 'downloads/export.json');
  await download.saveAs(savePath);

  const content = JSON.parse(fs.readFileSync(savePath, 'utf-8'));

  expect(Array.isArray(content)).toBe(true);
  expect(content[0]).toHaveProperty('destination');
  expect(content[0]).toHaveProperty('status');
});

Do not assert exact file sizes for generated files. Timestamps, IDs, or server-generated content can cause byte-level differences between runs. Assert minimum size for binary files and assert content structure for text formats.

Cleaning up test files with fixtures

Upload and download tests leave files on disk. If you're running tests in parallel or against a CI environment, leftover files from one run can contaminate another. The right tool for this is a Playwright fixture that handles cleanup automatically.

Create a fixture that provides a clean downloads directory and removes it after each test:

// fixtures/file-test.ts
import { test as base } from '@playwright/test';
import fs from 'fs';
import path from 'path';

type FileTestFixtures = {
  downloadDir: string;
  testFilePath: (filename: string) => string;
};

export const test = base.extend<FileTestFixtures>({
  downloadDir: async ({}, use) => {
    const dir = path.join(__dirname, `../downloads/test-${Date.now()}`);
    fs.mkdirSync(dir, { recursive: true });

    await use(dir);

    // Cleanup after the test
    fs.rmSync(dir, { recursive: true, force: true });
  },

  testFilePath: async ({ downloadDir }, use) => {
    const getPath = (filename: string) => path.join(downloadDir, filename);
    await use(getPath);
  },
});

export { expect } from '@playwright/test';

Tests that use this fixture get a fresh, isolated directory for each run:

// tests/download.spec.ts
import { test, expect } from '../fixtures/file-test';
import path from 'path';

test('downloads report to isolated directory', async ({ page, downloadDir, testFilePath }) => {
  await page.goto('https://lab.becomeqa.com/reports');

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

  const savePath = testFilePath(download.suggestedFilename());
  await download.saveAs(savePath);

  const content = require('fs').readFileSync(savePath, 'utf-8');
  expect(content).toContain('destination');
  // downloadDir and its contents are removed automatically after this test
});

For upload fixtures, you can generate temporary test files in the fixture setup and clean them up after:

export const test = base.extend<{
  uploadFixturePath: string;
}>({
  uploadFixturePath: async ({}, use) => {
    const filePath = path.join(__dirname, `../fixtures/temp-${Date.now()}.txt`);
    fs.writeFileSync(filePath, 'Temporary test file content');

    await use(filePath);

    fs.rmSync(filePath, { force: true });
  },
});

This pattern keeps test infrastructure honest: if a test fails and the cleanup never runs in a beforeAll/afterAll, the fixture teardown still runs because Playwright calls fixture teardown even when tests fail.

FAQ

What if the file input is hidden or has display: none? setInputFiles() works on hidden inputs. It bypasses the visual state of the element. If you get an error about visibility, use { force: true } as an option: await page.locator('input[type="file"]').setInputFiles(filePath, { force: true }). How do I handle a file upload that requires clicking a button first to reveal the input?

Click the button first, wait for the input to appear, then call setInputFiles(). If the button opens the native dialog instead, use page.waitForEvent('filechooser') before the click.

Can I test that a download fails or is blocked?

Yes. Route the download URL and return an error status: await page.route('/export/csv', route => route.fulfill({ status: 500 })). Then verify that the UI shows an appropriate error message. The download event won't fire in this case.

Does page.waitForEvent('download') time out?

Yes, it uses the default action timeout (usually 30 seconds). If your download takes longer, pass a custom timeout: page.waitForEvent('download', { timeout: 60000 }).

How do I handle downloads in a separate browser tab?

If the download opens in a new page, listen on the browser context instead: const download = await browserContext.waitForEvent('download'). This catches downloads from any page in the context.

What happens to the download temp directory if I don't call saveAs()?

The file lives in Playwright's internal temp directory until the browser context closes, then it's deleted. Always call saveAs() if you need to inspect the file.

→ See also: Network Interception, Mocking, and Stubbing in Playwright | Custom Fixtures in Playwright: The Pattern That Makes Tests Read Like Prose | API Testing with Playwright: Beyond the UI | Playwright Assertions: The Complete Guide