# File Operations > **When to use**: Testing file uploads, downloads, drag-and-drop file interactions, file type validation, and download verification. > **Prerequisites**: [core/locators.md](locators.md), [core/assertions-and-waiting.md](assertions-and-waiting.md) ## Quick Reference ```typescript // Upload — single file await page.getByLabel('Upload').setInputFiles('fixtures/resume.pdf'); // Upload — multiple files await page.getByLabel('Upload').setInputFiles(['fixtures/a.png', 'fixtures/b.png']); // Upload — clear selection await page.getByLabel('Upload').setInputFiles([]); // Download — wait and save const download = await page.waitForEvent('download'); await page.getByRole('button', { name: 'Export CSV' }).click(); const path = await download.path(); // temp path await download.saveAs('test-results/export.csv'); // permanent path // File chooser dialog — non-input uploads const fileChooser = await page.waitForEvent('filechooser'); await page.getByRole('button', { name: 'Choose file' }).click(); await fileChooser.setFiles('fixtures/photo.jpg'); ``` ## Patterns ### Single File Upload **Use when**: A form has a standard `` element. **Avoid when**: The upload uses a drag-and-drop zone with no underlying file input. **TypeScript** ```typescript import { test, expect } from '@playwright/test'; import path from 'path'; test('upload a single document', async ({ page }) => { await page.goto('/settings/profile'); const filePath = path.join(__dirname, '../fixtures/avatar.png'); await page.getByLabel('Profile picture').setInputFiles(filePath); // Verify the file name appears in the UI await expect(page.getByText('avatar.png')).toBeVisible(); await page.getByRole('button', { name: 'Save' }).click(); await expect(page.getByText('Profile updated')).toBeVisible(); }); ``` **JavaScript** ```javascript const { test, expect } = require('@playwright/test'); const path = require('path'); test('upload a single document', async ({ page }) => { await page.goto('/settings/profile'); const filePath = path.join(__dirname, '../fixtures/avatar.png'); await page.getByLabel('Profile picture').setInputFiles(filePath); await expect(page.getByText('avatar.png')).toBeVisible(); await page.getByRole('button', { name: 'Save' }).click(); await expect(page.getByText('Profile updated')).toBeVisible(); }); ``` ### Multiple File Upload **Use when**: The file input accepts `multiple` and you need to attach several files at once. **Avoid when**: The UI only allows one file. Use single file upload. **TypeScript** ```typescript import { test, expect } from '@playwright/test'; import path from 'path'; test('upload multiple attachments', async ({ page }) => { await page.goto('/tickets/new'); const fixtures = ['doc1.pdf', 'doc2.pdf', 'screenshot.png'].map( (f) => path.join(__dirname, '../fixtures', f) ); await page.getByLabel('Attachments').setInputFiles(fixtures); // Verify all files are listed await expect(page.getByTestId('file-list').getByRole('listitem')).toHaveCount(3); // Remove one file by clearing and re-setting await page.getByLabel('Attachments').setInputFiles(fixtures.slice(0, 2)); await expect(page.getByTestId('file-list').getByRole('listitem')).toHaveCount(2); }); ``` **JavaScript** ```javascript const { test, expect } = require('@playwright/test'); const path = require('path'); test('upload multiple attachments', async ({ page }) => { await page.goto('/tickets/new'); const fixtures = ['doc1.pdf', 'doc2.pdf', 'screenshot.png'].map( (f) => path.join(__dirname, '../fixtures', f) ); await page.getByLabel('Attachments').setInputFiles(fixtures); await expect(page.getByTestId('file-list').getByRole('listitem')).toHaveCount(3); await page.getByLabel('Attachments').setInputFiles(fixtures.slice(0, 2)); await expect(page.getByTestId('file-list').getByRole('listitem')).toHaveCount(2); }); ``` ### File Chooser Dialog **Use when**: The upload is triggered by a button click that opens the native file picker, not by a visible ``. Common with drag-and-drop libraries and custom upload components. **Avoid when**: There is a visible `` — use `setInputFiles` directly. **TypeScript** ```typescript import { test, expect } from '@playwright/test'; test('upload via file chooser dialog', async ({ page }) => { await page.goto('/documents'); // Register the listener BEFORE the click that opens the dialog const fileChooserPromise = page.waitForEvent('filechooser'); await page.getByRole('button', { name: 'Upload document' }).click(); const fileChooser = await fileChooserPromise; // Verify dialog properties expect(fileChooser.isMultiple()).toBe(false); await fileChooser.setFiles('fixtures/report.pdf'); await expect(page.getByText('report.pdf')).toBeVisible(); }); test('upload multiple via file chooser', async ({ page }) => { await page.goto('/gallery'); const fileChooserPromise = page.waitForEvent('filechooser'); await page.getByRole('button', { name: 'Add photos' }).click(); const fileChooser = await fileChooserPromise; expect(fileChooser.isMultiple()).toBe(true); await fileChooser.setFiles(['fixtures/photo1.jpg', 'fixtures/photo2.jpg']); await expect(page.getByRole('img')).toHaveCount(2); }); ``` **JavaScript** ```javascript const { test, expect } = require('@playwright/test'); test('upload via file chooser dialog', async ({ page }) => { await page.goto('/documents'); const fileChooserPromise = page.waitForEvent('filechooser'); await page.getByRole('button', { name: 'Upload document' }).click(); const fileChooser = await fileChooserPromise; expect(fileChooser.isMultiple()).toBe(false); await fileChooser.setFiles('fixtures/report.pdf'); await expect(page.getByText('report.pdf')).toBeVisible(); }); test('upload multiple via file chooser', async ({ page }) => { await page.goto('/gallery'); const fileChooserPromise = page.waitForEvent('filechooser'); await page.getByRole('button', { name: 'Add photos' }).click(); const fileChooser = await fileChooserPromise; expect(fileChooser.isMultiple()).toBe(true); await fileChooser.setFiles(['fixtures/photo1.jpg', 'fixtures/photo2.jpg']); await expect(page.getByRole('img')).toHaveCount(2); }); ``` ### Drag-and-Drop File Upload **Use when**: The UI has a drop zone that accepts files via the HTML5 Drag and Drop API and has no `` fallback. **Avoid when**: A file input exists — even hidden ones work with `setInputFiles`. **TypeScript** ```typescript import { test, expect } from '@playwright/test'; import fs from 'fs'; import path from 'path'; test('drop file onto upload zone', async ({ page }) => { await page.goto('/upload'); // Read the file into a buffer const filePath = path.join(__dirname, '../fixtures/data.csv'); const buffer = fs.readFileSync(filePath); // Create a DataTransfer with the file and dispatch drop event const dropZone = page.getByTestId('drop-zone'); await dropZone.dispatchEvent('drop', { dataTransfer: { files: [ { name: 'data.csv', mimeType: 'text/csv', buffer }, ], }, }); await expect(page.getByText('data.csv')).toBeVisible(); await expect(page.getByText('Upload complete')).toBeVisible(); }); test('drag-and-drop with hidden input fallback', async ({ page }) => { await page.goto('/upload'); // Many drag-and-drop libraries still use a hidden // Check for it first — this is more reliable than simulating DnD events const hiddenInput = page.locator('input[type="file"]'); if (await hiddenInput.count() > 0) { await hiddenInput.setInputFiles('fixtures/data.csv'); } await expect(page.getByText('data.csv')).toBeVisible(); }); ``` **JavaScript** ```javascript const { test, expect } = require('@playwright/test'); const fs = require('fs'); const path = require('path'); test('drop file onto upload zone', async ({ page }) => { await page.goto('/upload'); const filePath = path.join(__dirname, '../fixtures/data.csv'); const buffer = fs.readFileSync(filePath); const dropZone = page.getByTestId('drop-zone'); await dropZone.dispatchEvent('drop', { dataTransfer: { files: [ { name: 'data.csv', mimeType: 'text/csv', buffer }, ], }, }); await expect(page.getByText('data.csv')).toBeVisible(); await expect(page.getByText('Upload complete')).toBeVisible(); }); test('drag-and-drop with hidden input fallback', async ({ page }) => { await page.goto('/upload'); const hiddenInput = page.locator('input[type="file"]'); if (await hiddenInput.count() > 0) { await hiddenInput.setInputFiles('fixtures/data.csv'); } await expect(page.getByText('data.csv')).toBeVisible(); }); ``` ### File Download — Wait and Verify **Use when**: Testing export buttons, report generation, or any action that triggers a browser download. **Avoid when**: The file is served as a page navigation (opens in a new tab). Use multi-tab patterns instead. **TypeScript** ```typescript import { test, expect } from '@playwright/test'; import fs from 'fs'; test('download and verify file content', async ({ page }) => { await page.goto('/reports'); // Register BEFORE the click that triggers the download const downloadPromise = page.waitForEvent('download'); await page.getByRole('button', { name: 'Export CSV' }).click(); const download = await downloadPromise; // Verify download metadata expect(download.suggestedFilename()).toBe('report-2025.csv'); // Save to a known location const savePath = 'test-results/report.csv'; await download.saveAs(savePath); // Read and verify content const content = fs.readFileSync(savePath, 'utf-8'); expect(content).toContain('Name,Email,Status'); expect(content).toContain('Jane Doe,jane@example.com,Active'); // Verify file size is reasonable const stats = fs.statSync(savePath); expect(stats.size).toBeGreaterThan(100); }); test('download triggered by a link', async ({ page }) => { await page.goto('/files'); const downloadPromise = page.waitForEvent('download'); await page.getByRole('link', { name: 'Download invoice' }).click(); const download = await downloadPromise; expect(download.suggestedFilename()).toMatch(/invoice-\d+\.pdf/); // Use download.path() for the temp file path const tempPath = await download.path(); expect(tempPath).toBeTruthy(); }); ``` **JavaScript** ```javascript const { test, expect } = require('@playwright/test'); const fs = require('fs'); test('download and verify file content', async ({ page }) => { await page.goto('/reports'); const downloadPromise = page.waitForEvent('download'); await page.getByRole('button', { name: 'Export CSV' }).click(); const download = await downloadPromise; expect(download.suggestedFilename()).toBe('report-2025.csv'); const savePath = 'test-results/report.csv'; await download.saveAs(savePath); const content = fs.readFileSync(savePath, 'utf-8'); expect(content).toContain('Name,Email,Status'); expect(content).toContain('Jane Doe,jane@example.com,Active'); const stats = fs.statSync(savePath); expect(stats.size).toBeGreaterThan(100); }); test('download triggered by a link', async ({ page }) => { await page.goto('/files'); const downloadPromise = page.waitForEvent('download'); await page.getByRole('link', { name: 'Download invoice' }).click(); const download = await downloadPromise; expect(download.suggestedFilename()).toMatch(/invoice-\d+\.pdf/); const tempPath = await download.path(); expect(tempPath).toBeTruthy(); }); ``` ### Configuring Download Paths **Use when**: You need downloads to go to a specific directory, or you need to disable the download dialog prompt. **Avoid when**: Default temp paths via `download.path()` or `download.saveAs()` are sufficient. **TypeScript** ```typescript // playwright.config.ts — global download behavior import { defineConfig } from '@playwright/test'; export default defineConfig({ use: { // Accept all downloads without prompting acceptDownloads: true, // default is true }, }); ``` ```typescript import { test, expect } from '@playwright/test'; import fs from 'fs'; import path from 'path'; // Per-test download directory via a custom fixture const downloadTest = test.extend<{ downloadDir: string }>({ downloadDir: async ({}, use, testInfo) => { const dir = path.join('test-results', 'downloads', testInfo.title.replace(/\s+/g, '-')); fs.mkdirSync(dir, { recursive: true }); await use(dir); }, }); downloadTest('save downloads to organized directories', async ({ page, downloadDir }) => { await page.goto('/exports'); const downloadPromise = page.waitForEvent('download'); await page.getByRole('button', { name: 'Export' }).click(); const download = await downloadPromise; const savePath = path.join(downloadDir, download.suggestedFilename()); await download.saveAs(savePath); expect(fs.existsSync(savePath)).toBe(true); }); ``` **JavaScript** ```javascript // playwright.config.js const { defineConfig } = require('@playwright/test'); module.exports = defineConfig({ use: { acceptDownloads: true, }, }); ``` ```javascript const { test, expect } = require('@playwright/test'); const fs = require('fs'); const path = require('path'); const downloadTest = test.extend({ downloadDir: async ({}, use, testInfo) => { const dir = path.join('test-results', 'downloads', testInfo.title.replace(/\s+/g, '-')); fs.mkdirSync(dir, { recursive: true }); await use(dir); }, }); downloadTest('save downloads to organized directories', async ({ page, downloadDir }) => { await page.goto('/exports'); const downloadPromise = page.waitForEvent('download'); await page.getByRole('button', { name: 'Export' }).click(); const download = await downloadPromise; const savePath = path.join(downloadDir, download.suggestedFilename()); await download.saveAs(savePath); expect(fs.existsSync(savePath)).toBe(true); }); ``` ### File Type Validation **Use when**: Testing that the application rejects invalid file types and accepts valid ones. **Avoid when**: The application does no client-side validation and relies entirely on server-side checks (test via API instead). **TypeScript** ```typescript import { test, expect } from '@playwright/test'; test('rejects unsupported file types', async ({ page }) => { await page.goto('/upload'); // Upload an invalid file type await page.getByLabel('Upload image').setInputFiles({ name: 'malware.exe', mimeType: 'application/octet-stream', buffer: Buffer.from('fake-exe-content'), }); await expect(page.getByText('Only JPG, PNG, and GIF files are allowed')).toBeVisible(); await expect(page.getByRole('button', { name: 'Submit' })).toBeDisabled(); }); test('accepts valid file types', async ({ page }) => { await page.goto('/upload'); await page.getByLabel('Upload image').setInputFiles({ name: 'photo.jpg', mimeType: 'image/jpeg', buffer: Buffer.from('fake-jpg-content'), }); await expect(page.getByText('photo.jpg')).toBeVisible(); await expect(page.getByRole('button', { name: 'Submit' })).toBeEnabled(); }); test('validates file size limits', async ({ page }) => { await page.goto('/upload'); // Create a buffer that exceeds the 5MB limit const largeBuffer = Buffer.alloc(6 * 1024 * 1024, 'x'); await page.getByLabel('Upload document').setInputFiles({ name: 'huge-file.pdf', mimeType: 'application/pdf', buffer: largeBuffer, }); await expect(page.getByText('File size must be under 5MB')).toBeVisible(); }); ``` **JavaScript** ```javascript const { test, expect } = require('@playwright/test'); test('rejects unsupported file types', async ({ page }) => { await page.goto('/upload'); await page.getByLabel('Upload image').setInputFiles({ name: 'malware.exe', mimeType: 'application/octet-stream', buffer: Buffer.from('fake-exe-content'), }); await expect(page.getByText('Only JPG, PNG, and GIF files are allowed')).toBeVisible(); await expect(page.getByRole('button', { name: 'Submit' })).toBeDisabled(); }); test('accepts valid file types', async ({ page }) => { await page.goto('/upload'); await page.getByLabel('Upload image').setInputFiles({ name: 'photo.jpg', mimeType: 'image/jpeg', buffer: Buffer.from('fake-jpg-content'), }); await expect(page.getByText('photo.jpg')).toBeVisible(); await expect(page.getByRole('button', { name: 'Submit' })).toBeEnabled(); }); test('validates file size limits', async ({ page }) => { await page.goto('/upload'); const largeBuffer = Buffer.alloc(6 * 1024 * 1024, 'x'); await page.getByLabel('Upload document').setInputFiles({ name: 'huge-file.pdf', mimeType: 'application/pdf', buffer: largeBuffer, }); await expect(page.getByText('File size must be under 5MB')).toBeVisible(); }); ``` ### Large File Handling **Use when**: Testing uploads or downloads of large files where timeouts and progress indicators matter. **Avoid when**: Every test. Large file tests are slow. Run them in a separate suite or tag them for nightly runs. **TypeScript** ```typescript import { test, expect } from '@playwright/test'; import fs from 'fs'; import path from 'path'; test.describe('large file operations', () => { // Increase timeout for the entire describe block test.slow(); // Triples the default timeout test('upload large file with progress tracking', async ({ page }) => { await page.goto('/upload'); // Create a test file on disk (avoid Buffer.alloc for very large files) const largePath = path.join('test-results', 'large-test-file.bin'); const stream = fs.createWriteStream(largePath); for (let i = 0; i < 100; i++) { stream.write(Buffer.alloc(1024 * 1024, 'a')); // 100MB total } stream.end(); await new Promise((resolve) => stream.on('finish', resolve)); await page.getByLabel('Upload file').setInputFiles(largePath); // Wait for progress indicator await expect(page.getByRole('progressbar')).toBeVisible(); // Wait for completion — extended timeout await expect(page.getByText('Upload complete')).toBeVisible({ timeout: 120_000 }); // Cleanup fs.unlinkSync(largePath); }); test('download large file', async ({ page }) => { await page.goto('/exports'); const downloadPromise = page.waitForEvent('download'); await page.getByRole('button', { name: 'Export full dataset' }).click(); const download = await downloadPromise; const savePath = 'test-results/large-export.zip'; await download.saveAs(savePath); // Verify file size is reasonable (at least 10MB) const stats = fs.statSync(savePath); expect(stats.size).toBeGreaterThan(10 * 1024 * 1024); fs.unlinkSync(savePath); }); }); ``` **JavaScript** ```javascript const { test, expect } = require('@playwright/test'); const fs = require('fs'); const path = require('path'); test.describe('large file operations', () => { test.slow(); test('upload large file with progress tracking', async ({ page }) => { await page.goto('/upload'); const largePath = path.join('test-results', 'large-test-file.bin'); const stream = fs.createWriteStream(largePath); for (let i = 0; i < 100; i++) { stream.write(Buffer.alloc(1024 * 1024, 'a')); } stream.end(); await new Promise((resolve) => stream.on('finish', resolve)); await page.getByLabel('Upload file').setInputFiles(largePath); await expect(page.getByRole('progressbar')).toBeVisible(); await expect(page.getByText('Upload complete')).toBeVisible({ timeout: 120_000 }); fs.unlinkSync(largePath); }); test('download large file', async ({ page }) => { await page.goto('/exports'); const downloadPromise = page.waitForEvent('download'); await page.getByRole('button', { name: 'Export full dataset' }).click(); const download = await downloadPromise; const savePath = 'test-results/large-export.zip'; await download.saveAs(savePath); const stats = fs.statSync(savePath); expect(stats.size).toBeGreaterThan(10 * 1024 * 1024); fs.unlinkSync(savePath); }); }); ``` ## Decision Guide | Scenario | Approach | Key API | |---|---|---| | Standard `` | `setInputFiles()` on the locator | `locator.setInputFiles(path)` | | Hidden file input | Find the input even if hidden, call `setInputFiles()` | `page.locator('input[type="file"]').setInputFiles()` | | Custom button opens file picker | Listen for `filechooser` event before clicking | `page.waitForEvent('filechooser')` | | Drag-and-drop zone with no input | Dispatch `drop` event with `DataTransfer` | `locator.dispatchEvent('drop', ...)` | | DnD zone with hidden input fallback | Prefer `setInputFiles()` on the hidden input | Check `input[type="file"]` count first | | Multiple files at once | Pass array to `setInputFiles()` | `setInputFiles([path1, path2])` | | In-memory test file (no disk) | Pass object with `name`, `mimeType`, `buffer` | `setInputFiles({ name, mimeType, buffer })` | | Download — verify filename | `download.suggestedFilename()` | `page.waitForEvent('download')` | | Download — verify content | `download.saveAs()` then read with `fs` | `fs.readFileSync()` | | Download — temp file only | `download.path()` returns temp location | Auto-deleted after test | | Large file upload/download | Use `test.slow()`, increase assertion timeouts | `{ timeout: 120_000 }` | ## Anti-Patterns | Don't Do This | Problem | Do This Instead | |---|---|---| | `await page.waitForTimeout(3000)` after upload | Arbitrary delay; flaky | `await expect(page.getByText('Upload complete')).toBeVisible()` | | `await page.setInputFiles('#file', path)` using CSS selector | Fragile selector; breaks on ID changes | `await page.getByLabel('Upload').setInputFiles(path)` | | Clicking download then immediately reading the file | Race condition; file may not be written yet | Use `page.waitForEvent('download')` then `download.saveAs()` | | Creating large test files in `beforeAll` and never cleaning up | Fills disk in CI, slows down subsequent runs | Clean up in `afterAll` or use fixture teardown | | Using `page.on('filechooser')` for every upload | Unnecessary complexity when `` exists | Use `setInputFiles()` directly | | Hardcoding absolute file paths | Breaks across machines and CI | Use `path.join(__dirname, ...)` or relative to project root | | Testing file upload with empty buffer | Does not test real validation behavior | Use realistic file content or minimum valid file size | | Using `download.path()` for permanent storage | Temp files are cleaned up after test context closes | Use `download.saveAs()` for permanent paths | | Uploading real 100MB files in every test run | Slows entire suite, wastes CI resources | Tag large file tests separately; run on schedule, not every PR | ## Troubleshooting ### "FileChooser event was not emitted" **Cause**: The click did not open a native file picker dialog. The upload component may use a different mechanism. ```typescript // Debug: check if there is a hidden you can target directly const fileInputCount = await page.locator('input[type="file"]').count(); console.log(`Found ${fileInputCount} file inputs`); // If input exists, skip the file chooser approach entirely if (fileInputCount > 0) { await page.locator('input[type="file"]').setInputFiles('fixtures/file.pdf'); } ``` ### "Download event was not emitted" **Cause**: The link opens in a new tab or navigates to the file URL instead of triggering a download. ```typescript // Fix 1: Ensure acceptDownloads is true in config // Fix 2: Check if the link opens a new tab — handle it as a new page const pagePromise = page.context().waitForEvent('page'); await page.getByRole('link', { name: 'Download' }).click(); const newPage = await pagePromise; // Then wait for the download event on the new page const download = await newPage.waitForEvent('download'); ``` ### Upload works locally but fails in CI **Cause**: File paths are wrong in CI, or the fixture files are not included in the repo/build. ```typescript // Fix: always resolve paths relative to the test file import path from 'path'; const fixturePath = path.join(__dirname, '..', 'fixtures', 'test-file.pdf'); // Verify the file exists before uploading import fs from 'fs'; if (!fs.existsSync(fixturePath)) { throw new Error(`Fixture file missing: ${fixturePath}`); } ``` ### `setInputFiles` does nothing — no file appears **Cause**: The input element is detached, inside a Shadow DOM, or in an iframe. ```typescript // Check for iframe const frame = page.frameLocator('iframe[title="Upload"]'); await frame.locator('input[type="file"]').setInputFiles('fixtures/file.pdf'); // Check for Shadow DOM — Playwright pierces open shadow roots automatically // but the input may be in a closed shadow root (rare). Use the file chooser approach instead. ``` ## Related - [core/locators.md](locators.md) -- locator strategies for finding upload/download elements - [core/assertions-and-waiting.md](assertions-and-waiting.md) -- assertion patterns for verifying upload/download results - [core/fixtures-and-hooks.md](fixtures-and-hooks.md) -- creating reusable download directory fixtures - [core/network-mocking.md](network-mocking.md) -- mocking upload endpoints for faster tests - [core/error-and-edge-cases.md](error-and-edge-cases.md) -- testing upload failure states and error handling