Three test cases produce more failed pull requests than any other in Playwright: uploading files, downloading files, and handling popup windows. They're not technically hard, but the official docs give you fragments and you're left to assemble them. Most assemblies are wrong.
I've shipped these patterns on real client projects — a SaaS with profile-photo upload, a fintech with PDF invoice download, and an e-commerce site with Google OAuth in a popup. Here's the exact code, the gotchas I hit, and what I'd tell my own junior engineer.
Table of Contents
- File upload: the standard input
- File upload: drag-and-drop drop zones
- File upload: hidden inputs and custom buttons
- File upload: multiple files
- File download: capturing and verifying
- Popups: target="_blank" and OAuth flows
- Popups: window.open() with no nav
- Browser dialogs vs popup windows
- FAQs
File Upload: The Standard Input
If your form has a real <input type="file"> visible in the DOM, this is one line:
await page.getByLabel('Upload avatar').setInputFiles('tests/fixtures/avatar.png');
That's it. setInputFiles bypasses the OS file dialog entirely — Playwright sets the input's files property directly. The form's change event fires, the upload proceeds.
Where this breaks: people forget the file path is relative to the project root, not the test file. Use absolute paths if you're getting "file not found" errors:
import path from 'path';
await page.getByLabel('Upload').setInputFiles(
path.join(__dirname, '..', 'fixtures', 'avatar.png')
);
File Upload: Drag-and-Drop Drop Zones
Many modern uploaders (Dropzone.js, react-dropzone) hide the file input and listen for drop events on a styled div instead. setInputFiles doesn't help here directly — there's no input to set.
Two approaches work. The first is finding the underlying input, which is usually still in the DOM but hidden:
// Drop zone wrapper:
// <div class="dropzone">
// <input type="file" hidden />
// </div>
await page.locator('.dropzone input[type="file"]').setInputFiles('avatar.png');
// Even if hidden, setInputFiles works
If there's no input at all (rare but possible), simulate the drop event with a real File object:
const buffer = await fs.readFile('tests/fixtures/avatar.png');
const dataTransfer = await page.evaluateHandle(
({ data, name, type }) => {
const dt = new DataTransfer();
const file = new File([new Uint8Array(data)], name, { type });
dt.items.add(file);
return dt;
},
{ data: Array.from(buffer), name: 'avatar.png', type: 'image/png' }
);
await page.locator('.dropzone').dispatchEvent('drop', { dataTransfer });
File Upload: Hidden Inputs and Custom Buttons
Common pattern: a styled "Choose file" button that triggers a hidden input via JavaScript. The button is what users click; the input is the real upload.
Don't click the button — that opens the OS file dialog and Playwright can't see it. Use the file chooser event:
const fileChooserPromise = page.waitForEvent('filechooser');
await page.getByRole('button', { name: 'Choose file' }).click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles('tests/fixtures/avatar.png');
The filechooser event fires regardless of whether there's a visible input. It works for the OS dialog case AND for hidden inputs. This is the most universal pattern — when in doubt, use it.
File Upload: Multiple Files
await page.getByLabel('Upload images').setInputFiles([
'tests/fixtures/photo1.jpg',
'tests/fixtures/photo2.jpg',
'tests/fixtures/photo3.jpg',
]);
The input must have multiple attribute. If you pass an array to a single-file input, only the first file is used.
If you want to test what happens when the user clears the selection, pass an empty array:
await page.getByLabel('Upload images').setInputFiles([]);
File Download: Capturing and Verifying
This is the test case that gets the most-wrong code. The wrong pattern:
// WRONG
await page.getByRole('link', { name: 'Download invoice' }).click();
await page.waitForTimeout(2000);
const files = await fs.readdir('downloads');
expect(files).toContain('invoice.pdf');
Three problems: the timeout is arbitrary, the download path is hardcoded, and there's no guarantee the click triggered a download at all.
The right pattern:
const downloadPromise = page.waitForEvent('download');
await page.getByRole('link', { name: 'Download invoice' }).click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe('invoice-2026-04.pdf');
const path = await download.path();
const stats = await fs.stat(path);
expect(stats.size).toBeGreaterThan(1000); // PDF should be at least 1KB
Three things to know:
waitForEvent('download')registers before the click that triggers the download — same Promise.all pattern as elsewhere.download.path()gives you the temp file location. Playwright cleans it up after the test unless you calldownload.saveAs()first.- You can verify content, not just existence:
const downloadPath = await download.path();
const content = await fs.readFile(downloadPath);
expect(content.length).toBeGreaterThan(0);
// For PDFs - parse with pdf-parse or similar:
import pdfParse from 'pdf-parse';
const data = await pdfParse(content);
expect(data.text).toContain('Invoice #2026-04');
// For CSVs:
const text = content.toString('utf-8');
const lines = text.split('\n');
expect(lines[0]).toBe('id,name,email');
expect(lines.length).toBeGreaterThan(1);
Save permanently if your test needs to inspect the file later or attach it to a report:
await download.saveAs(`./test-results/${download.suggestedFilename()}`);
Edge case: PDF preview that opens in a new tab
Some apps display PDFs in a new tab using the browser's built-in viewer instead of triggering a download. waitForEvent('download') won't fire. Instead, treat it as a popup:
const newPagePromise = page.context().waitForEvent('page');
await page.getByRole('link', { name: 'View invoice' }).click();
const newPage = await newPagePromise;
await newPage.waitForLoadState();
expect(newPage.url()).toContain('.pdf');
Popups: target="_blank" and OAuth Flows
The pattern: user clicks "Sign in with Google," a new window opens, they log in there, the original page receives a postMessage and updates.
test('Google OAuth login', async ({ page, context }) => {
await page.goto('/login');
const popupPromise = context.waitForEvent('page');
await page.getByRole('button', { name: 'Sign in with Google' }).click();
const popup = await popupPromise;
await popup.waitForLoadState();
// Interact in the popup as if it's a regular page:
await popup.getByRole('textbox', { name: 'Email' }).fill('test@gmail.com');
await popup.getByRole('button', { name: 'Next' }).click();
await popup.getByRole('textbox', { name: 'Password' }).fill('test-password');
await popup.getByRole('button', { name: 'Sign in' }).click();
// Popup may close itself; original page updates via postMessage:
await expect(page).toHaveURL('/dashboard');
});
Three things you'll trip on:
context.waitForEvent('page'), notpage.waitForEvent. The popup is a sibling page, not a child of the original.- You need
await popup.waitForLoadState()before interacting. The popup fires the page event before its content loads. - Real Google OAuth blocks automation. Use a test account with security relaxed, or — for CI — mock the OAuth response with
page.route().
Popups: window.open() With No Navigation
Sometimes apps open a small popup that's just an HTML form, no navigation. The pattern is the same; you just don't wait for a load state because there's no separate URL:
const popupPromise = context.waitForEvent('page');
await page.getByRole('button', { name: 'Quick add' }).click();
const popup = await popupPromise;
await popup.getByRole('textbox', { name: 'Note' }).fill('Buy milk');
await popup.getByRole('button', { name: 'Save' }).click();
// Popup closes itself after save:
await popup.waitForEvent('close');
await expect(page.getByRole('list')).toContainText('Buy milk');
Browser Dialogs vs Popup Windows
Different things, frequently confused.
- Popup window = full browser tab opened by the app. Handle with
context.waitForEvent('page'). - Browser dialog = native alert/confirm/prompt. Handle with
page.on('dialog').
// Browser dialog (alert/confirm/prompt):
page.on('dialog', async (dialog) => {
expect(dialog.type()).toBe('confirm');
expect(dialog.message()).toContain('Are you sure?');
await dialog.accept();
});
await page.getByRole('button', { name: 'Delete account' }).click();
// Dialog handler runs automatically
Important: register page.on('dialog') before the action that triggers the dialog. Playwright auto-dismisses unhandled dialogs by default — your test passes but the action never completes, and you're confused.
FAQs
What's the difference between setInputFiles and filechooser.setFiles?
Functionally similar. setInputFiles requires you to have a locator pointing at the input. filechooser.setFiles works regardless of whether the input is in the DOM. I default to filechooser because it's more resilient.
Can I upload a file from a URL instead of a local path?
Not directly. Download the file in your test setup with node-fetch or similar, then pass the local path. Or build a Buffer in memory and use setInputFiles({ name, mimeType, buffer }).
How do I test progress bars during long uploads?
Slow the network in the route handler: page.route('**/upload', async (route) => { await new Promise(r => setTimeout(r, 2000)); await route.continue(); }). Then assert on the progress UI before the response completes.
Why does my download test fail with "download was not initiated"?
The button you're clicking probably renders the file inline (PDF preview, image lightbox) instead of triggering a download. Either change the test to handle the popup pattern, or look for a different button that uses a real download attribute or Content-Disposition: attachment response.
How do I test downloads in headless mode?
Same code. Headless mode handles downloads identically to headed; the only difference is you don't see the download bar in the browser UI.
What about Safari (WebKit)? Different popup behavior?
Safari blocks popups not initiated by direct user gesture. Tests that fire popups via setTimeout or after async work will silently fail in WebKit. Always trigger popups via direct click handlers, not delayed callbacks.
How do I handle the "Save As" dialog when it appears?
Configure the browser to download without prompting: acceptDownloads: true in the Playwright config (it's the default). The OS dialog never appears in automated tests.
What if the popup is the only place login works (no fallback)?
Use context.storageState to save the post-login session, then reuse it across tests. See my multi-user auth state post.
Can I run multiple file uploads in parallel?
Each test runs in its own browser context, so parallel uploads to the same form across multiple tests don't conflict. Within a single test, sequential is fine — concurrent setInputFiles on the same input is undefined behavior.
How do I verify a downloaded ZIP without unzipping it?
Check the magic bytes: const buf = await fs.readFile(path); expect(buf.slice(0, 4).toString('hex')).toBe('504b0304'); — that's the PK\\x03\\x04 ZIP file signature.
Wrap-Up
Three patterns. Twelve gotchas. Ship them once and you stop fighting them. The longer-term tip: build them into shared fixtures, not into individual tests. uploadAvatar(page, 'avatar.png') is a function you write once and reuse 50 times.
If your test suite has dozens of file-upload or download tests and you want to standardize the patterns across the whole codebase, that's exactly the kind of refactor I do in framework setup engagements. Or book a free call.
Related reading:
Tayyab Akmal
AI & QA Automation Engineer
6 years of catching critical bugs in fintech, e-commerce, and SaaS — then building the Playwright and Selenium automation that prevents them from shipping again.