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 The approach here is to dispatch the drag events manually using This is more involved than Some upload buttons are styled The ordering matters: register the You can also check whether the file input accepts multiple files before setting them: Downloads work through a different mechanism. When the browser triggers a file download, Playwright fires a By default, Playwright saves downloads to a temporary directory that gets cleaned up when the browser context closes. The Capturing the download is step one. Asserting that the content is correct is what makes the test meaningful. For binary files, you can check the size rather than content: For structured formats like JSON, deserialize and assert the shape: 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: Tests that use this fixture get a fresh, isolated directory for each run: For upload fixtures, you can generate temporary test files in the fixture setup and clean them up after: This pattern keeps test infrastructure honest: if a test fails and the cleanup never runs in a Click the button first, wait for the input to appear, then call Yes. Route the download URL and return an error status: Yes, it uses the default action timeout (usually 30 seconds). If your download takes longer, pass a custom timeout: If the download opens in a new page, listen on the browser context instead: The file lives in Playwright's internal temp directory until the browser context closes, then it's deleted. Always call dragover and drop events. There's no to call setInputFiles() on.
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();
});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. that you can target with setInputFiles(). Check with DevTools first; it saves significant complexity.Listening for filechooser events
or page.waitForEvent('filechooser') lets you intercept the dialog that opens when such a button is clicked:
test('handles a custom upload button', async ({ page }) => {
await page.goto('https://lab.becomeqa.com/file-upload');
const filePath = path.join(__dirname, 'fixtures/sample.pdf');
// Set up the listener BEFORE the click
const fileChooserPromise = page.waitForEvent('filechooser');
await page.getByRole('button', { name: 'Choose File' }).click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(filePath);
await expect(page.getByText('sample.pdf')).toBeVisible();
});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'),
]);const fileChooser = await fileChooserPromise;
console.log('Accepts multiple:', fileChooser.isMultiple());
await fileChooser.setFiles(filePath);File downloads: capturing the download event
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);
});saveAs() copies the file to a location you control so you can inspect it after the download completes.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
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);
});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
});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');
});Cleaning up test files with fixtures
// 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/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
});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 });
},
});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?
setInputFiles(). If the button opens the native dialog instead, use page.waitForEvent('filechooser') before the click.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.page.waitForEvent('download') time out?
page.waitForEvent('download', { timeout: 60000 }).const download = await browserContext.waitForEvent('download'). This catches downloads from any page in the context.saveAs()?
saveAs() if you need to inspect the file.