File Uploads and Downloads in Playwright

Uploads with setInputFiles

The <input type="file"> element is the one form control fill cannot touch, for good reason: letting scripts write arbitrary paths into it would let any page read files off your disk. Playwright goes through the browser's automation protocol instead, with setInputFiles:

await page.getByLabel('Avatar').setInputFiles('fixtures/avatar.png');

No file dialog opens. Playwright assigns the file to the input and fires the change and input events, so the page's upload handler runs exactly as it would after a manual pick. Relative paths resolve against the current working directory, so tests that run from different directories are safer with an absolute path built from the test file's location:

import path from 'node:path';

await page.getByLabel('Avatar')
  .setInputFiles(path.join(__dirname, 'fixtures', 'avatar.png'));

An input with the multiple attribute takes an array, and an empty array clears the selection:

await page.getByLabel('Attachments').setInputFiles([
  path.join(__dirname, 'fixtures', 'report.pdf'),
  path.join(__dirname, 'fixtures', 'summary.csv'),
]);

await page.getByLabel('Attachments').setInputFiles([]);

For small files, skip the fixture directory entirely and build the file in memory with { name, mimeType, buffer }:

await page.getByLabel('Import CSV').setInputFiles({
  name: 'monitors.csv',
  mimeType: 'text/csv',
  buffer: Buffer.from('name,url\nAPI,https://api.example.com/health\n'),
});

This keeps the file's content visible right in the test, which pays off when the assertion later checks that the imported rows show up. Binary fixtures in the repo make sense for images and PDFs; generated buffers win for anything textual.

Hidden inputs behind styled buttons

Native file inputs are hard to style, so almost every real app hides the input and shows a styled button or drop zone instead:

<input type="file" id="upload" hidden />
<button onclick="document.getElementById('upload').click()">
  Choose a file
</button>

The instinct is to click the visible button, but that opens the path toward dialog handling for no benefit. Target the hidden input directly:

// Anti-pattern: clicking the styled button, then scrambling to handle the picker
await page.getByRole('button', { name: 'Choose a file' }).click();

// Recommended: set the files on the input itself
await page.locator('#upload').setInputFiles('fixtures/report.pdf');

setInputFiles is exempt from the visibility requirement that blocks fill and click on hidden elements, precisely because hiding the input is the standard pattern. The input just has to exist in the DOM. Finding it is usually a matter of page.locator('input[type="file"]'), scoped to the upload component if the page has several.

The filechooser event

Some apps create the file input on the fly inside the click handler, or bury it in a third-party widget you cannot reliably select. For those, Playwright intercepts the file picker itself. When a click would open the browser's file dialog, the page emits a filechooser event instead of showing the dialog:

const fileChooserPromise = page.waitForEvent('filechooser');
await page.getByRole('button', { name: 'Choose a file' }).click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles('fixtures/report.pdf');

Order matters. Start waiting before the click, or the event fires while nobody is listening and the waitForEvent call times out. The FileChooser object also tells you whether the dialog accepts multiple files via fileChooser.isMultiple().

Prefer the direct setInputFiles route whenever a real input exists; the filechooser pattern is the fallback for when it does not, not the default.

Drag-and-drop upload zones

Drop zones raise the question of what you are actually testing. In nearly every implementation, the zone is a styled wrapper around a hidden file input, and dropping a file feeds the same handler that the input's change event does. If the goal is to test the upload pipeline, set the files on the input and move on.

To test the drop interaction itself, dispatch a synthetic drop event carrying a DataTransfer built inside the page:

const dataTransfer = await page.evaluateHandle(() => {
  const dt = new DataTransfer();
  const file = new File(['name,url\nAPI,https://api.example.com\n'], 'monitors.csv', {
    type: 'text/csv',
  });
  dt.items.add(file);
  return dt;
});

await page.getByTestId('drop-zone').dispatchEvent('drop', { dataTransfer });

The DataTransfer and File are constructed in the browser context because they cannot cross the boundary from Node. If the zone only reveals itself during a drag, dispatch dragenter and dragover with the same dataTransfer first. This exercises the page's drop handler and any highlight styling logic, though not the OS-level drag itself, which no browser automation reaches.

Asserting the upload worked

Setting files on an input proves nothing by itself. Assert the user-visible evidence that the app processed the upload, whichever form it takes:

await expect(page.getByText('avatar.png uploaded')).toBeVisible();

await expect(page.getByRole('listitem').filter({ hasText: 'report.pdf' })).toBeVisible();

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

These are ordinary web-first assertions, so they wait out the network round trip without explicit synchronization. For slow uploads, pass a longer timeout to the assertion rather than sleeping. If the upload hits an API you do not control in tests, the network interception guide shows how to mock the endpoint and keep the test hermetic.

Downloads with waitForEvent

Downloads follow the same listen-then-trigger shape as the filechooser:

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

expect(download.suggestedFilename()).toBe('monitors-export.csv');

The Download object represents the browser-side download. suggestedFilename() returns the name the browser would save it under, which comes from the Content-Disposition header or the link's download attribute, and asserting it catches regressions in export naming.

To inspect the content, put the file somewhere you control:

const filePath = path.join(testInfo.outputPath(), download.suggestedFilename());
await download.saveAs(filePath);

download.path() also returns a location, but it points at a temporary file that disappears when the browser context closes. saveAs into the test's output directory is the durable option, and testInfo.outputPath() keeps parallel workers from overwriting each other.

One configuration note: the @playwright/test runner accepts downloads by default. Only when you create contexts manually with browser.newContext() do you need to pass acceptDownloads: true, and its absence is the usual culprit when waitForEvent('download') hangs in a hand-rolled script.

Verifying generated files end to end

A saved file only becomes a real assertion once you read it back. For a CSV export, parse it and check structure and content:

import fs from 'node:fs/promises';

test('exports monitors as CSV', async ({ page }, testInfo) => {
  await page.goto('https://app.example.com/monitors');

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

  const filePath = path.join(testInfo.outputPath(), 'export.csv');
  await download.saveAs(filePath);

  const content = await fs.readFile(filePath, 'utf-8');
  const lines = content.trim().split('\n');

  expect(lines[0]).toBe('name,url,status');
  expect(lines.length).toBeGreaterThan(1);
  expect(content).toContain('https://api.example.com/health');
});

For binary formats like PDF exports, full parsing is rarely worth the dependency. A size floor plus a check on the format's signature bytes catches the common failure mode, an empty or truncated file:

const stats = await fs.stat(filePath);
expect(stats.size).toBeGreaterThan(1024);

const header = await fs.readFile(filePath);
expect(header.subarray(0, 5).toString()).toBe('%PDF-');

If the export is fetched from a plain HTTP endpoint rather than generated in the browser, skipping the UI and hitting the endpoint with Playwright's request context is often the sharper test; the API testing guide covers that route.

Run it in production

Report exports and document uploads are the flows users touch monthly, which means a regression can sit unnoticed for weeks. Hyperping Browser Checks run your Playwright upload and download scripts on a schedule against production, so the CSV export that broke on Friday's deploy pages you Friday, not at month's end.

Get started

Start monitoring in the next 5 minutes.

Stop letting customers discover your outages first. Set up monitoring, status pages, on-call, and alerts before your next coffee break.

14 days free trial. No card required.