mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-13 17:52:38 +02:00
983 lines
30 KiB
Markdown
983 lines
30 KiB
Markdown
|
|
# File Upload and Download Recipes
|
|||
|
|
|
|||
|
|
> **When to use**: You need to test file uploads (single, multiple, drag-and-drop), file downloads (verify content, filename, type), upload progress, or file type restrictions.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Recipe 1: Single File Upload via Input
|
|||
|
|
|
|||
|
|
### Complete Example
|
|||
|
|
|
|||
|
|
**TypeScript**
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
import { test, expect } from '@playwright/test';
|
|||
|
|
import path from 'path';
|
|||
|
|
|
|||
|
|
test('uploads a single file via file input', async ({ page }) => {
|
|||
|
|
await page.goto('/documents');
|
|||
|
|
|
|||
|
|
// Click the upload button which triggers the hidden file input
|
|||
|
|
const fileInput = page.locator('input[type="file"]');
|
|||
|
|
|
|||
|
|
// Upload a file from the test fixtures directory
|
|||
|
|
await fileInput.setInputFiles(path.resolve(__dirname, '../fixtures/report.pdf'));
|
|||
|
|
|
|||
|
|
// Verify the file name appears in the UI
|
|||
|
|
await expect(page.getByText('report.pdf')).toBeVisible();
|
|||
|
|
|
|||
|
|
// Click the submit/upload button
|
|||
|
|
await page.getByRole('button', { name: 'Upload' }).click();
|
|||
|
|
|
|||
|
|
// Wait for the upload to complete
|
|||
|
|
await expect(page.getByRole('alert')).toContainText('File uploaded successfully');
|
|||
|
|
|
|||
|
|
// Verify the file appears in the documents list
|
|||
|
|
await expect(page.getByRole('link', { name: 'report.pdf' })).toBeVisible();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('uploads a file created in-memory (no fixture file needed)', async ({ page }) => {
|
|||
|
|
await page.goto('/documents');
|
|||
|
|
|
|||
|
|
const fileInput = page.locator('input[type="file"]');
|
|||
|
|
|
|||
|
|
// Create a file from a buffer -- no fixture file required
|
|||
|
|
await fileInput.setInputFiles({
|
|||
|
|
name: 'test-data.csv',
|
|||
|
|
mimeType: 'text/csv',
|
|||
|
|
buffer: Buffer.from('name,email,role\nJane,jane@example.com,admin\nBob,bob@example.com,user'),
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
await expect(page.getByText('test-data.csv')).toBeVisible();
|
|||
|
|
|
|||
|
|
await page.getByRole('button', { name: 'Upload' }).click();
|
|||
|
|
|
|||
|
|
await expect(page.getByRole('alert')).toContainText('File uploaded successfully');
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('clears a selected file before uploading', async ({ page }) => {
|
|||
|
|
await page.goto('/documents');
|
|||
|
|
|
|||
|
|
const fileInput = page.locator('input[type="file"]');
|
|||
|
|
|
|||
|
|
// Select a file
|
|||
|
|
await fileInput.setInputFiles({
|
|||
|
|
name: 'wrong-file.txt',
|
|||
|
|
mimeType: 'text/plain',
|
|||
|
|
buffer: Buffer.from('wrong content'),
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
await expect(page.getByText('wrong-file.txt')).toBeVisible();
|
|||
|
|
|
|||
|
|
// Clear the selection
|
|||
|
|
await fileInput.setInputFiles([]);
|
|||
|
|
|
|||
|
|
// Or click a "Remove" button in the UI
|
|||
|
|
// await page.getByRole('button', { name: 'Remove' }).click();
|
|||
|
|
|
|||
|
|
await expect(page.getByText('wrong-file.txt')).not.toBeVisible();
|
|||
|
|
});
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**JavaScript**
|
|||
|
|
|
|||
|
|
```javascript
|
|||
|
|
const { test, expect } = require('@playwright/test');
|
|||
|
|
const path = require('path');
|
|||
|
|
|
|||
|
|
test('uploads a single file via file input', async ({ page }) => {
|
|||
|
|
await page.goto('/documents');
|
|||
|
|
|
|||
|
|
const fileInput = page.locator('input[type="file"]');
|
|||
|
|
await fileInput.setInputFiles(path.resolve(__dirname, '../fixtures/report.pdf'));
|
|||
|
|
|
|||
|
|
await expect(page.getByText('report.pdf')).toBeVisible();
|
|||
|
|
|
|||
|
|
await page.getByRole('button', { name: 'Upload' }).click();
|
|||
|
|
|
|||
|
|
await expect(page.getByRole('alert')).toContainText('File uploaded successfully');
|
|||
|
|
await expect(page.getByRole('link', { name: 'report.pdf' })).toBeVisible();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('uploads a file created in-memory', async ({ page }) => {
|
|||
|
|
await page.goto('/documents');
|
|||
|
|
|
|||
|
|
const fileInput = page.locator('input[type="file"]');
|
|||
|
|
|
|||
|
|
await fileInput.setInputFiles({
|
|||
|
|
name: 'test-data.csv',
|
|||
|
|
mimeType: 'text/csv',
|
|||
|
|
buffer: Buffer.from('name,email,role\nJane,jane@example.com,admin'),
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
await expect(page.getByText('test-data.csv')).toBeVisible();
|
|||
|
|
await page.getByRole('button', { name: 'Upload' }).click();
|
|||
|
|
await expect(page.getByRole('alert')).toContainText('File uploaded successfully');
|
|||
|
|
});
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Recipe 2: Multiple File Upload
|
|||
|
|
|
|||
|
|
### Complete Example
|
|||
|
|
|
|||
|
|
**TypeScript**
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
import { test, expect } from '@playwright/test';
|
|||
|
|
import path from 'path';
|
|||
|
|
|
|||
|
|
test('uploads multiple files at once', async ({ page }) => {
|
|||
|
|
await page.goto('/documents');
|
|||
|
|
|
|||
|
|
const fileInput = page.locator('input[type="file"]');
|
|||
|
|
|
|||
|
|
// Pass an array to upload multiple files
|
|||
|
|
await fileInput.setInputFiles([
|
|||
|
|
path.resolve(__dirname, '../fixtures/report.pdf'),
|
|||
|
|
path.resolve(__dirname, '../fixtures/photo.jpg'),
|
|||
|
|
path.resolve(__dirname, '../fixtures/data.csv'),
|
|||
|
|
]);
|
|||
|
|
|
|||
|
|
// Verify all file names appear
|
|||
|
|
await expect(page.getByText('report.pdf')).toBeVisible();
|
|||
|
|
await expect(page.getByText('photo.jpg')).toBeVisible();
|
|||
|
|
await expect(page.getByText('data.csv')).toBeVisible();
|
|||
|
|
|
|||
|
|
// Verify the count indicator
|
|||
|
|
await expect(page.getByText('3 files selected')).toBeVisible();
|
|||
|
|
|
|||
|
|
await page.getByRole('button', { name: 'Upload all' }).click();
|
|||
|
|
|
|||
|
|
await expect(page.getByRole('alert')).toContainText('3 files uploaded');
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('uploads multiple files with in-memory buffers', async ({ page }) => {
|
|||
|
|
await page.goto('/documents');
|
|||
|
|
|
|||
|
|
const fileInput = page.locator('input[type="file"]');
|
|||
|
|
|
|||
|
|
await fileInput.setInputFiles([
|
|||
|
|
{
|
|||
|
|
name: 'notes.txt',
|
|||
|
|
mimeType: 'text/plain',
|
|||
|
|
buffer: Buffer.from('Meeting notes for Q4 planning'),
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
name: 'config.json',
|
|||
|
|
mimeType: 'application/json',
|
|||
|
|
buffer: Buffer.from(JSON.stringify({ theme: 'dark', lang: 'en' })),
|
|||
|
|
},
|
|||
|
|
]);
|
|||
|
|
|
|||
|
|
await expect(page.getByText('notes.txt')).toBeVisible();
|
|||
|
|
await expect(page.getByText('config.json')).toBeVisible();
|
|||
|
|
|
|||
|
|
await page.getByRole('button', { name: 'Upload all' }).click();
|
|||
|
|
await expect(page.getByRole('alert')).toContainText('2 files uploaded');
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('removes one file from a multi-file selection', async ({ page }) => {
|
|||
|
|
await page.goto('/documents');
|
|||
|
|
|
|||
|
|
const fileInput = page.locator('input[type="file"]');
|
|||
|
|
|
|||
|
|
await fileInput.setInputFiles([
|
|||
|
|
{ name: 'keep.txt', mimeType: 'text/plain', buffer: Buffer.from('keep') },
|
|||
|
|
{ name: 'remove.txt', mimeType: 'text/plain', buffer: Buffer.from('remove') },
|
|||
|
|
{ name: 'also-keep.txt', mimeType: 'text/plain', buffer: Buffer.from('keep') },
|
|||
|
|
]);
|
|||
|
|
|
|||
|
|
// Remove a specific file from the preview list
|
|||
|
|
const removeItem = page.getByText('remove.txt').locator('..');
|
|||
|
|
await removeItem.getByRole('button', { name: /remove|delete|×/i }).click();
|
|||
|
|
|
|||
|
|
await expect(page.getByText('remove.txt')).not.toBeVisible();
|
|||
|
|
await expect(page.getByText('keep.txt')).toBeVisible();
|
|||
|
|
await expect(page.getByText('also-keep.txt')).toBeVisible();
|
|||
|
|
await expect(page.getByText('2 files selected')).toBeVisible();
|
|||
|
|
});
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**JavaScript**
|
|||
|
|
|
|||
|
|
```javascript
|
|||
|
|
const { test, expect } = require('@playwright/test');
|
|||
|
|
const path = require('path');
|
|||
|
|
|
|||
|
|
test('uploads multiple files at once', async ({ page }) => {
|
|||
|
|
await page.goto('/documents');
|
|||
|
|
|
|||
|
|
const fileInput = page.locator('input[type="file"]');
|
|||
|
|
|
|||
|
|
await fileInput.setInputFiles([
|
|||
|
|
path.resolve(__dirname, '../fixtures/report.pdf'),
|
|||
|
|
path.resolve(__dirname, '../fixtures/photo.jpg'),
|
|||
|
|
path.resolve(__dirname, '../fixtures/data.csv'),
|
|||
|
|
]);
|
|||
|
|
|
|||
|
|
await expect(page.getByText('report.pdf')).toBeVisible();
|
|||
|
|
await expect(page.getByText('photo.jpg')).toBeVisible();
|
|||
|
|
await expect(page.getByText('data.csv')).toBeVisible();
|
|||
|
|
|
|||
|
|
await page.getByRole('button', { name: 'Upload all' }).click();
|
|||
|
|
await expect(page.getByRole('alert')).toContainText('3 files uploaded');
|
|||
|
|
});
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Recipe 3: Drag-and-Drop File Upload
|
|||
|
|
|
|||
|
|
### Complete Example
|
|||
|
|
|
|||
|
|
**TypeScript**
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
import { test, expect } from '@playwright/test';
|
|||
|
|
import path from 'path';
|
|||
|
|
|
|||
|
|
test('uploads a file via drag-and-drop zone', async ({ page }) => {
|
|||
|
|
await page.goto('/documents');
|
|||
|
|
|
|||
|
|
const dropZone = page.locator('[data-testid="drop-zone"]');
|
|||
|
|
await expect(dropZone).toContainText(/drag.*here|drop.*files/i);
|
|||
|
|
|
|||
|
|
// Drag-and-drop from the OS is not natively supported in Playwright,
|
|||
|
|
// but drop zones always have an underlying input[type=file] -- use that
|
|||
|
|
const fileInput = page.locator('input[type="file"]');
|
|||
|
|
|
|||
|
|
await fileInput.setInputFiles(path.resolve(__dirname, '../fixtures/report.pdf'));
|
|||
|
|
|
|||
|
|
// File preview should appear in the drop zone
|
|||
|
|
await expect(dropZone.getByText('report.pdf')).toBeVisible();
|
|||
|
|
|
|||
|
|
// Trigger upload
|
|||
|
|
await page.getByRole('button', { name: 'Upload' }).click();
|
|||
|
|
|
|||
|
|
await expect(page.getByRole('alert')).toContainText('uploaded successfully');
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('shows visual feedback during drag-over', async ({ page }) => {
|
|||
|
|
await page.goto('/documents');
|
|||
|
|
|
|||
|
|
const dropZone = page.locator('[data-testid="drop-zone"]');
|
|||
|
|
|
|||
|
|
// Simulate dragenter to show visual feedback
|
|||
|
|
await dropZone.dispatchEvent('dragenter', {
|
|||
|
|
dataTransfer: { types: ['Files'], files: [] },
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Drop zone should show an active state
|
|||
|
|
await expect(dropZone).toHaveClass(/active|highlight|drag-over/);
|
|||
|
|
await expect(dropZone).toContainText(/release|drop now/i);
|
|||
|
|
|
|||
|
|
// Simulate dragleave to reset
|
|||
|
|
await dropZone.dispatchEvent('dragleave');
|
|||
|
|
|
|||
|
|
await expect(dropZone).not.toHaveClass(/active|highlight|drag-over/);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('handles multiple files dropped at once', async ({ page }) => {
|
|||
|
|
await page.goto('/documents');
|
|||
|
|
|
|||
|
|
const fileInput = page.locator('input[type="file"]');
|
|||
|
|
|
|||
|
|
await fileInput.setInputFiles([
|
|||
|
|
{ name: 'image1.png', mimeType: 'image/png', buffer: Buffer.from('fake-png-1') },
|
|||
|
|
{ name: 'image2.png', mimeType: 'image/png', buffer: Buffer.from('fake-png-2') },
|
|||
|
|
{ name: 'image3.png', mimeType: 'image/png', buffer: Buffer.from('fake-png-3') },
|
|||
|
|
]);
|
|||
|
|
|
|||
|
|
await expect(page.getByText('image1.png')).toBeVisible();
|
|||
|
|
await expect(page.getByText('image2.png')).toBeVisible();
|
|||
|
|
await expect(page.getByText('image3.png')).toBeVisible();
|
|||
|
|
});
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**JavaScript**
|
|||
|
|
|
|||
|
|
```javascript
|
|||
|
|
const { test, expect } = require('@playwright/test');
|
|||
|
|
const path = require('path');
|
|||
|
|
|
|||
|
|
test('uploads a file via drag-and-drop zone', async ({ page }) => {
|
|||
|
|
await page.goto('/documents');
|
|||
|
|
|
|||
|
|
const dropZone = page.locator('[data-testid="drop-zone"]');
|
|||
|
|
const fileInput = page.locator('input[type="file"]');
|
|||
|
|
|
|||
|
|
await fileInput.setInputFiles(path.resolve(__dirname, '../fixtures/report.pdf'));
|
|||
|
|
|
|||
|
|
await expect(dropZone.getByText('report.pdf')).toBeVisible();
|
|||
|
|
|
|||
|
|
await page.getByRole('button', { name: 'Upload' }).click();
|
|||
|
|
await expect(page.getByRole('alert')).toContainText('uploaded successfully');
|
|||
|
|
});
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Recipe 4: Download and Verify File Content
|
|||
|
|
|
|||
|
|
### Complete Example
|
|||
|
|
|
|||
|
|
**TypeScript**
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
import { test, expect } from '@playwright/test';
|
|||
|
|
import fs from 'fs';
|
|||
|
|
import path from 'path';
|
|||
|
|
|
|||
|
|
test('downloads a file and verifies its content', async ({ page }) => {
|
|||
|
|
await page.goto('/documents');
|
|||
|
|
|
|||
|
|
// Start waiting for the download event BEFORE clicking
|
|||
|
|
const downloadPromise = page.waitForEvent('download');
|
|||
|
|
|
|||
|
|
await page.getByRole('link', { name: 'report.csv' }).click();
|
|||
|
|
|
|||
|
|
const download = await downloadPromise;
|
|||
|
|
|
|||
|
|
// Save to a temporary location
|
|||
|
|
const downloadPath = path.join(__dirname, '../downloads', download.suggestedFilename());
|
|||
|
|
await download.saveAs(downloadPath);
|
|||
|
|
|
|||
|
|
// Read and verify the content
|
|||
|
|
const content = fs.readFileSync(downloadPath, 'utf-8');
|
|||
|
|
|
|||
|
|
expect(content).toContain('name,email,role');
|
|||
|
|
expect(content).toContain('Jane,jane@example.com,admin');
|
|||
|
|
|
|||
|
|
// Verify line count
|
|||
|
|
const lines = content.trim().split('\n');
|
|||
|
|
expect(lines.length).toBeGreaterThan(1); // header + at least one data row
|
|||
|
|
|
|||
|
|
// Clean up
|
|||
|
|
fs.unlinkSync(downloadPath);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('downloads a JSON file and verifies structure', async ({ page }) => {
|
|||
|
|
await page.goto('/api-docs');
|
|||
|
|
|
|||
|
|
const downloadPromise = page.waitForEvent('download');
|
|||
|
|
await page.getByRole('button', { name: 'Export API spec' }).click();
|
|||
|
|
|
|||
|
|
const download = await downloadPromise;
|
|||
|
|
const downloadPath = path.join(__dirname, '../downloads', download.suggestedFilename());
|
|||
|
|
await download.saveAs(downloadPath);
|
|||
|
|
|
|||
|
|
const content = JSON.parse(fs.readFileSync(downloadPath, 'utf-8'));
|
|||
|
|
|
|||
|
|
expect(content).toHaveProperty('openapi');
|
|||
|
|
expect(content.paths).toBeDefined();
|
|||
|
|
expect(Object.keys(content.paths).length).toBeGreaterThan(0);
|
|||
|
|
|
|||
|
|
fs.unlinkSync(downloadPath);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('downloads a file via the stream (no disk save needed)', async ({ page }) => {
|
|||
|
|
await page.goto('/documents');
|
|||
|
|
|
|||
|
|
const downloadPromise = page.waitForEvent('download');
|
|||
|
|
await page.getByRole('link', { name: 'report.csv' }).click();
|
|||
|
|
|
|||
|
|
const download = await downloadPromise;
|
|||
|
|
|
|||
|
|
// Read directly from the download stream without saving to disk
|
|||
|
|
const readable = await download.createReadStream();
|
|||
|
|
const chunks: Buffer[] = [];
|
|||
|
|
|
|||
|
|
for await (const chunk of readable!) {
|
|||
|
|
chunks.push(Buffer.from(chunk));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const content = Buffer.concat(chunks).toString('utf-8');
|
|||
|
|
expect(content).toContain('name,email,role');
|
|||
|
|
});
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**JavaScript**
|
|||
|
|
|
|||
|
|
```javascript
|
|||
|
|
const { test, expect } = require('@playwright/test');
|
|||
|
|
const fs = require('fs');
|
|||
|
|
const path = require('path');
|
|||
|
|
|
|||
|
|
test('downloads a file and verifies its content', async ({ page }) => {
|
|||
|
|
await page.goto('/documents');
|
|||
|
|
|
|||
|
|
const downloadPromise = page.waitForEvent('download');
|
|||
|
|
await page.getByRole('link', { name: 'report.csv' }).click();
|
|||
|
|
|
|||
|
|
const download = await downloadPromise;
|
|||
|
|
|
|||
|
|
const downloadPath = path.join(__dirname, '../downloads', download.suggestedFilename());
|
|||
|
|
await download.saveAs(downloadPath);
|
|||
|
|
|
|||
|
|
const content = fs.readFileSync(downloadPath, 'utf-8');
|
|||
|
|
expect(content).toContain('name,email,role');
|
|||
|
|
expect(content).toContain('Jane,jane@example.com,admin');
|
|||
|
|
|
|||
|
|
fs.unlinkSync(downloadPath);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('downloads a file via the stream', async ({ page }) => {
|
|||
|
|
await page.goto('/documents');
|
|||
|
|
|
|||
|
|
const downloadPromise = page.waitForEvent('download');
|
|||
|
|
await page.getByRole('link', { name: 'report.csv' }).click();
|
|||
|
|
|
|||
|
|
const download = await downloadPromise;
|
|||
|
|
|
|||
|
|
const readable = await download.createReadStream();
|
|||
|
|
const chunks = [];
|
|||
|
|
|
|||
|
|
for await (const chunk of readable) {
|
|||
|
|
chunks.push(Buffer.from(chunk));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const content = Buffer.concat(chunks).toString('utf-8');
|
|||
|
|
expect(content).toContain('name,email,role');
|
|||
|
|
});
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Recipe 5: Download and Verify Filename
|
|||
|
|
|
|||
|
|
### Complete Example
|
|||
|
|
|
|||
|
|
**TypeScript**
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
import { test, expect } from '@playwright/test';
|
|||
|
|
|
|||
|
|
test('download has correct filename and extension', async ({ page }) => {
|
|||
|
|
await page.goto('/reports');
|
|||
|
|
|
|||
|
|
const downloadPromise = page.waitForEvent('download');
|
|||
|
|
await page.getByRole('button', { name: 'Export as PDF' }).click();
|
|||
|
|
|
|||
|
|
const download = await downloadPromise;
|
|||
|
|
|
|||
|
|
// Verify filename pattern
|
|||
|
|
expect(download.suggestedFilename()).toMatch(/^report-\d{4}-\d{2}-\d{2}\.pdf$/);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('download filename changes based on selected format', async ({ page }) => {
|
|||
|
|
await page.goto('/reports');
|
|||
|
|
|
|||
|
|
// Select CSV format
|
|||
|
|
await page.getByLabel('Export format').selectOption('csv');
|
|||
|
|
|
|||
|
|
const csvDownload = page.waitForEvent('download');
|
|||
|
|
await page.getByRole('button', { name: 'Download' }).click();
|
|||
|
|
const csv = await csvDownload;
|
|||
|
|
expect(csv.suggestedFilename()).toMatch(/\.csv$/);
|
|||
|
|
|
|||
|
|
// Select Excel format
|
|||
|
|
await page.getByLabel('Export format').selectOption('xlsx');
|
|||
|
|
|
|||
|
|
const xlsxDownload = page.waitForEvent('download');
|
|||
|
|
await page.getByRole('button', { name: 'Download' }).click();
|
|||
|
|
const xlsx = await xlsxDownload;
|
|||
|
|
expect(xlsx.suggestedFilename()).toMatch(/\.xlsx$/);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('download has correct MIME type via response headers', async ({ page }) => {
|
|||
|
|
await page.goto('/reports');
|
|||
|
|
|
|||
|
|
// Intercept the download request to check response headers
|
|||
|
|
const responsePromise = page.waitForResponse('**/api/reports/export**');
|
|||
|
|
const downloadPromise = page.waitForEvent('download');
|
|||
|
|
|
|||
|
|
await page.getByRole('button', { name: 'Export as PDF' }).click();
|
|||
|
|
|
|||
|
|
const response = await responsePromise;
|
|||
|
|
expect(response.headers()['content-type']).toContain('application/pdf');
|
|||
|
|
expect(response.headers()['content-disposition']).toContain('attachment');
|
|||
|
|
|
|||
|
|
await downloadPromise; // Consume the download event
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('download failure shows error to user', async ({ page }) => {
|
|||
|
|
// Mock the download endpoint to fail
|
|||
|
|
await page.route('**/api/reports/export**', async (route) => {
|
|||
|
|
await route.fulfill({
|
|||
|
|
status: 500,
|
|||
|
|
contentType: 'application/json',
|
|||
|
|
body: JSON.stringify({ error: 'Report generation failed' }),
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
await page.goto('/reports');
|
|||
|
|
await page.getByRole('button', { name: 'Export as PDF' }).click();
|
|||
|
|
|
|||
|
|
await expect(page.getByRole('alert')).toContainText(/failed|error/i);
|
|||
|
|
});
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**JavaScript**
|
|||
|
|
|
|||
|
|
```javascript
|
|||
|
|
const { test, expect } = require('@playwright/test');
|
|||
|
|
|
|||
|
|
test('download has correct filename and extension', async ({ page }) => {
|
|||
|
|
await page.goto('/reports');
|
|||
|
|
|
|||
|
|
const downloadPromise = page.waitForEvent('download');
|
|||
|
|
await page.getByRole('button', { name: 'Export as PDF' }).click();
|
|||
|
|
|
|||
|
|
const download = await downloadPromise;
|
|||
|
|
expect(download.suggestedFilename()).toMatch(/^report-\d{4}-\d{2}-\d{2}\.pdf$/);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('download filename changes based on selected format', async ({ page }) => {
|
|||
|
|
await page.goto('/reports');
|
|||
|
|
|
|||
|
|
await page.getByLabel('Export format').selectOption('csv');
|
|||
|
|
|
|||
|
|
const csvDownload = page.waitForEvent('download');
|
|||
|
|
await page.getByRole('button', { name: 'Download' }).click();
|
|||
|
|
const csv = await csvDownload;
|
|||
|
|
expect(csv.suggestedFilename()).toMatch(/\.csv$/);
|
|||
|
|
});
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Recipe 6: Large File Upload with Progress
|
|||
|
|
|
|||
|
|
### Complete Example
|
|||
|
|
|
|||
|
|
**TypeScript**
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
import { test, expect } from '@playwright/test';
|
|||
|
|
|
|||
|
|
test('shows upload progress for a large file', async ({ page }) => {
|
|||
|
|
await page.goto('/documents');
|
|||
|
|
|
|||
|
|
const fileInput = page.locator('input[type="file"]');
|
|||
|
|
|
|||
|
|
// Create a large in-memory file (5 MB)
|
|||
|
|
const largeBuffer = Buffer.alloc(5 * 1024 * 1024, 'x');
|
|||
|
|
|
|||
|
|
await fileInput.setInputFiles({
|
|||
|
|
name: 'large-dataset.bin',
|
|||
|
|
mimeType: 'application/octet-stream',
|
|||
|
|
buffer: largeBuffer,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
await page.getByRole('button', { name: 'Upload' }).click();
|
|||
|
|
|
|||
|
|
// Verify progress bar appears
|
|||
|
|
const progressBar = page.getByRole('progressbar');
|
|||
|
|
await expect(progressBar).toBeVisible();
|
|||
|
|
|
|||
|
|
// Verify progress percentage updates
|
|||
|
|
// Use polling to check that progress increases
|
|||
|
|
await expect(async () => {
|
|||
|
|
const value = await progressBar.getAttribute('aria-valuenow');
|
|||
|
|
expect(Number(value)).toBeGreaterThan(0);
|
|||
|
|
}).toPass({ timeout: 10000 });
|
|||
|
|
|
|||
|
|
// Wait for upload to complete
|
|||
|
|
await expect(progressBar).not.toBeVisible({ timeout: 60000 });
|
|||
|
|
await expect(page.getByRole('alert')).toContainText('uploaded successfully');
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('cancels an in-progress upload', async ({ page }) => {
|
|||
|
|
// Slow down the upload API to simulate a long upload
|
|||
|
|
await page.route('**/api/documents/upload', async (route) => {
|
|||
|
|
// Delay for 10 seconds to give us time to cancel
|
|||
|
|
await new Promise((resolve) => setTimeout(resolve, 10000));
|
|||
|
|
await route.continue();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
await page.goto('/documents');
|
|||
|
|
|
|||
|
|
const fileInput = page.locator('input[type="file"]');
|
|||
|
|
const largeBuffer = Buffer.alloc(5 * 1024 * 1024, 'x');
|
|||
|
|
|
|||
|
|
await fileInput.setInputFiles({
|
|||
|
|
name: 'large-file.bin',
|
|||
|
|
mimeType: 'application/octet-stream',
|
|||
|
|
buffer: largeBuffer,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
await page.getByRole('button', { name: 'Upload' }).click();
|
|||
|
|
|
|||
|
|
// Wait for upload to start
|
|||
|
|
await expect(page.getByRole('progressbar')).toBeVisible();
|
|||
|
|
|
|||
|
|
// Cancel the upload
|
|||
|
|
await page.getByRole('button', { name: 'Cancel upload' }).click();
|
|||
|
|
|
|||
|
|
// Verify upload was cancelled
|
|||
|
|
await expect(page.getByRole('progressbar')).not.toBeVisible();
|
|||
|
|
await expect(page.getByText(/cancelled|aborted/i)).toBeVisible();
|
|||
|
|
|
|||
|
|
// Verify the file did not appear in the documents list
|
|||
|
|
await expect(page.getByRole('link', { name: 'large-file.bin' })).not.toBeVisible();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('retries a failed upload', async ({ page }) => {
|
|||
|
|
let attempt = 0;
|
|||
|
|
|
|||
|
|
await page.route('**/api/documents/upload', async (route) => {
|
|||
|
|
attempt++;
|
|||
|
|
if (attempt === 1) {
|
|||
|
|
// First attempt fails
|
|||
|
|
await route.fulfill({
|
|||
|
|
status: 500,
|
|||
|
|
contentType: 'application/json',
|
|||
|
|
body: JSON.stringify({ error: 'Server error' }),
|
|||
|
|
});
|
|||
|
|
} else {
|
|||
|
|
// Second attempt succeeds
|
|||
|
|
await route.fulfill({
|
|||
|
|
status: 200,
|
|||
|
|
contentType: 'application/json',
|
|||
|
|
body: JSON.stringify({ id: '123', name: 'data.csv' }),
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
await page.goto('/documents');
|
|||
|
|
|
|||
|
|
const fileInput = page.locator('input[type="file"]');
|
|||
|
|
await fileInput.setInputFiles({
|
|||
|
|
name: 'data.csv',
|
|||
|
|
mimeType: 'text/csv',
|
|||
|
|
buffer: Buffer.from('col1,col2\nval1,val2'),
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
await page.getByRole('button', { name: 'Upload' }).click();
|
|||
|
|
|
|||
|
|
// First attempt fails
|
|||
|
|
await expect(page.getByText(/upload failed|error/i)).toBeVisible();
|
|||
|
|
|
|||
|
|
// Retry
|
|||
|
|
await page.getByRole('button', { name: /retry/i }).click();
|
|||
|
|
|
|||
|
|
// Second attempt succeeds
|
|||
|
|
await expect(page.getByRole('alert')).toContainText('uploaded successfully');
|
|||
|
|
expect(attempt).toBe(2);
|
|||
|
|
});
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**JavaScript**
|
|||
|
|
|
|||
|
|
```javascript
|
|||
|
|
const { test, expect } = require('@playwright/test');
|
|||
|
|
|
|||
|
|
test('shows upload progress for a large file', async ({ page }) => {
|
|||
|
|
await page.goto('/documents');
|
|||
|
|
|
|||
|
|
const fileInput = page.locator('input[type="file"]');
|
|||
|
|
|
|||
|
|
const largeBuffer = Buffer.alloc(5 * 1024 * 1024, 'x');
|
|||
|
|
|
|||
|
|
await fileInput.setInputFiles({
|
|||
|
|
name: 'large-dataset.bin',
|
|||
|
|
mimeType: 'application/octet-stream',
|
|||
|
|
buffer: largeBuffer,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
await page.getByRole('button', { name: 'Upload' }).click();
|
|||
|
|
|
|||
|
|
const progressBar = page.getByRole('progressbar');
|
|||
|
|
await expect(progressBar).toBeVisible();
|
|||
|
|
|
|||
|
|
await expect(progressBar).not.toBeVisible({ timeout: 60000 });
|
|||
|
|
await expect(page.getByRole('alert')).toContainText('uploaded successfully');
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('cancels an in-progress upload', async ({ page }) => {
|
|||
|
|
await page.route('**/api/documents/upload', async (route) => {
|
|||
|
|
await new Promise((resolve) => setTimeout(resolve, 10000));
|
|||
|
|
await route.continue();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
await page.goto('/documents');
|
|||
|
|
|
|||
|
|
const fileInput = page.locator('input[type="file"]');
|
|||
|
|
await fileInput.setInputFiles({
|
|||
|
|
name: 'large-file.bin',
|
|||
|
|
mimeType: 'application/octet-stream',
|
|||
|
|
buffer: Buffer.alloc(5 * 1024 * 1024, 'x'),
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
await page.getByRole('button', { name: 'Upload' }).click();
|
|||
|
|
await expect(page.getByRole('progressbar')).toBeVisible();
|
|||
|
|
|
|||
|
|
await page.getByRole('button', { name: 'Cancel upload' }).click();
|
|||
|
|
|
|||
|
|
await expect(page.getByRole('progressbar')).not.toBeVisible();
|
|||
|
|
await expect(page.getByText(/cancelled|aborted/i)).toBeVisible();
|
|||
|
|
});
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Recipe 7: Testing File Type Restrictions
|
|||
|
|
|
|||
|
|
### Complete Example
|
|||
|
|
|
|||
|
|
**TypeScript**
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
import { test, expect } from '@playwright/test';
|
|||
|
|
|
|||
|
|
test('accepts allowed file types', async ({ page }) => {
|
|||
|
|
await page.goto('/documents');
|
|||
|
|
|
|||
|
|
const fileInput = page.locator('input[type="file"]');
|
|||
|
|
|
|||
|
|
// Verify the accept attribute on the file input
|
|||
|
|
await expect(fileInput).toHaveAttribute('accept', /\.pdf|\.doc|\.docx|\.txt/);
|
|||
|
|
|
|||
|
|
// Upload an allowed file type
|
|||
|
|
await fileInput.setInputFiles({
|
|||
|
|
name: 'valid-document.pdf',
|
|||
|
|
mimeType: 'application/pdf',
|
|||
|
|
buffer: Buffer.from('fake-pdf-content'),
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// Should be accepted without error
|
|||
|
|
await expect(page.getByText('valid-document.pdf')).toBeVisible();
|
|||
|
|
await expect(page.getByText(/not allowed|invalid/i)).not.toBeVisible();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('rejects disallowed file types with clear error message', async ({ page }) => {
|
|||
|
|
await page.goto('/documents');
|
|||
|
|
|
|||
|
|
const fileInput = page.locator('input[type="file"]');
|
|||
|
|
|
|||
|
|
// Upload a disallowed file type
|
|||
|
|
// Note: setInputFiles bypasses the browser's accept attribute filter,
|
|||
|
|
// so the app's JavaScript validation is what we are testing here
|
|||
|
|
await fileInput.setInputFiles({
|
|||
|
|
name: 'script.exe',
|
|||
|
|
mimeType: 'application/x-msdownload',
|
|||
|
|
buffer: Buffer.from('fake-exe-content'),
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// App should show a rejection message
|
|||
|
|
await expect(page.getByRole('alert')).toContainText(
|
|||
|
|
/not allowed|unsupported file type|only .pdf, .doc/i
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
// File should not appear in the upload queue
|
|||
|
|
await expect(page.getByText('script.exe')).not.toBeVisible();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('enforces maximum file size', async ({ page }) => {
|
|||
|
|
await page.goto('/documents');
|
|||
|
|
|
|||
|
|
const fileInput = page.locator('input[type="file"]');
|
|||
|
|
|
|||
|
|
// Create a file that exceeds the max size (e.g., 11 MB when limit is 10 MB)
|
|||
|
|
const oversizedBuffer = Buffer.alloc(11 * 1024 * 1024, 'x');
|
|||
|
|
|
|||
|
|
await fileInput.setInputFiles({
|
|||
|
|
name: 'huge-file.pdf',
|
|||
|
|
mimeType: 'application/pdf',
|
|||
|
|
buffer: oversizedBuffer,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
await expect(page.getByRole('alert')).toContainText(/file.*too large|exceeds.*10 ?MB/i);
|
|||
|
|
await expect(page.getByText('huge-file.pdf')).not.toBeVisible();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('enforces maximum number of files', async ({ page }) => {
|
|||
|
|
await page.goto('/documents');
|
|||
|
|
|
|||
|
|
const fileInput = page.locator('input[type="file"]');
|
|||
|
|
|
|||
|
|
// Try to upload more files than the limit (e.g., limit is 5)
|
|||
|
|
const files = Array.from({ length: 6 }, (_, i) => ({
|
|||
|
|
name: `file-${i + 1}.txt`,
|
|||
|
|
mimeType: 'text/plain' as const,
|
|||
|
|
buffer: Buffer.from(`content ${i + 1}`),
|
|||
|
|
}));
|
|||
|
|
|
|||
|
|
await fileInput.setInputFiles(files);
|
|||
|
|
|
|||
|
|
await expect(page.getByRole('alert')).toContainText(/maximum.*5 files|too many files/i);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('validates image dimensions for avatar upload', async ({ page }) => {
|
|||
|
|
await page.goto('/settings/profile');
|
|||
|
|
|
|||
|
|
const fileInput = page.locator('input[type="file"]');
|
|||
|
|
|
|||
|
|
// Create a tiny 1x1 PNG (valid format but too small)
|
|||
|
|
// Minimal PNG buffer
|
|||
|
|
const tinyPng = Buffer.from(
|
|||
|
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
|
|||
|
|
'base64'
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
await fileInput.setInputFiles({
|
|||
|
|
name: 'tiny-avatar.png',
|
|||
|
|
mimeType: 'image/png',
|
|||
|
|
buffer: tinyPng,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
await expect(page.getByRole('alert')).toContainText(/minimum.*dimensions|too small/i);
|
|||
|
|
});
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**JavaScript**
|
|||
|
|
|
|||
|
|
```javascript
|
|||
|
|
const { test, expect } = require('@playwright/test');
|
|||
|
|
|
|||
|
|
test('rejects disallowed file types with clear error message', async ({ page }) => {
|
|||
|
|
await page.goto('/documents');
|
|||
|
|
|
|||
|
|
const fileInput = page.locator('input[type="file"]');
|
|||
|
|
|
|||
|
|
await fileInput.setInputFiles({
|
|||
|
|
name: 'script.exe',
|
|||
|
|
mimeType: 'application/x-msdownload',
|
|||
|
|
buffer: Buffer.from('fake-exe-content'),
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
await expect(page.getByRole('alert')).toContainText(
|
|||
|
|
/not allowed|unsupported file type|only .pdf, .doc/i
|
|||
|
|
);
|
|||
|
|
await expect(page.getByText('script.exe')).not.toBeVisible();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('enforces maximum file size', async ({ page }) => {
|
|||
|
|
await page.goto('/documents');
|
|||
|
|
|
|||
|
|
const fileInput = page.locator('input[type="file"]');
|
|||
|
|
|
|||
|
|
const oversizedBuffer = Buffer.alloc(11 * 1024 * 1024, 'x');
|
|||
|
|
|
|||
|
|
await fileInput.setInputFiles({
|
|||
|
|
name: 'huge-file.pdf',
|
|||
|
|
mimeType: 'application/pdf',
|
|||
|
|
buffer: oversizedBuffer,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
await expect(page.getByRole('alert')).toContainText(/file.*too large|exceeds.*10 ?MB/i);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('enforces maximum number of files', async ({ page }) => {
|
|||
|
|
await page.goto('/documents');
|
|||
|
|
|
|||
|
|
const fileInput = page.locator('input[type="file"]');
|
|||
|
|
|
|||
|
|
const files = Array.from({ length: 6 }, (_, i) => ({
|
|||
|
|
name: `file-${i + 1}.txt`,
|
|||
|
|
mimeType: 'text/plain',
|
|||
|
|
buffer: Buffer.from(`content ${i + 1}`),
|
|||
|
|
}));
|
|||
|
|
|
|||
|
|
await fileInput.setInputFiles(files);
|
|||
|
|
|
|||
|
|
await expect(page.getByRole('alert')).toContainText(/maximum.*5 files|too many files/i);
|
|||
|
|
});
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Variations
|
|||
|
|
|
|||
|
|
### Upload via File Chooser Dialog
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
test('uploads via the native file chooser dialog', async ({ page }) => {
|
|||
|
|
await page.goto('/documents');
|
|||
|
|
|
|||
|
|
// Listen for the file chooser event before clicking the trigger
|
|||
|
|
const fileChooserPromise = page.waitForEvent('filechooser');
|
|||
|
|
await page.getByRole('button', { name: 'Choose file' }).click();
|
|||
|
|
|
|||
|
|
const fileChooser = await fileChooserPromise;
|
|||
|
|
|
|||
|
|
// Verify it accepts only certain types
|
|||
|
|
expect(fileChooser.isMultiple()).toBe(false);
|
|||
|
|
|
|||
|
|
await fileChooser.setFiles({
|
|||
|
|
name: 'chosen-file.pdf',
|
|||
|
|
mimeType: 'application/pdf',
|
|||
|
|
buffer: Buffer.from('pdf-content'),
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
await expect(page.getByText('chosen-file.pdf')).toBeVisible();
|
|||
|
|
});
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### Upload with Image Preview
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
test('shows image preview after selecting a file', async ({ page }) => {
|
|||
|
|
await page.goto('/settings/profile');
|
|||
|
|
|
|||
|
|
const fileInput = page.locator('input[type="file"]');
|
|||
|
|
|
|||
|
|
await fileInput.setInputFiles(path.resolve(__dirname, '../fixtures/avatar.jpg'));
|
|||
|
|
|
|||
|
|
// Verify the image preview is displayed
|
|||
|
|
const preview = page.getByRole('img', { name: /preview|avatar/i });
|
|||
|
|
await expect(preview).toBeVisible();
|
|||
|
|
|
|||
|
|
// Verify the preview src is a blob or data URL
|
|||
|
|
const src = await preview.getAttribute('src');
|
|||
|
|
expect(src).toMatch(/^(blob:|data:image)/);
|
|||
|
|
});
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### Download with Authentication
|
|||
|
|
|
|||
|
|
```typescript
|
|||
|
|
test('downloads a file that requires authentication', async ({ page, request }) => {
|
|||
|
|
// Some downloads go through an API that needs auth cookies
|
|||
|
|
await page.goto('/documents');
|
|||
|
|
|
|||
|
|
// The UI download works because the browser sends cookies
|
|||
|
|
const downloadPromise = page.waitForEvent('download');
|
|||
|
|
await page.getByRole('link', { name: 'confidential-report.pdf' }).click();
|
|||
|
|
|
|||
|
|
const download = await downloadPromise;
|
|||
|
|
expect(download.suggestedFilename()).toBe('confidential-report.pdf');
|
|||
|
|
|
|||
|
|
// Alternatively, verify via API request (which carries the auth context)
|
|||
|
|
const response = await request.get('/api/documents/123/download');
|
|||
|
|
expect(response.ok()).toBeTruthy();
|
|||
|
|
expect(response.headers()['content-type']).toContain('application/pdf');
|
|||
|
|
});
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Tips
|
|||
|
|
|
|||
|
|
1. **Use `setInputFiles` for upload testing**. Even if the UI uses a drag-and-drop zone, there is always an underlying `input[type="file"]` element. Target it directly with `setInputFiles()` instead of trying to simulate OS-level drag events.
|
|||
|
|
|
|||
|
|
2. **Prefer in-memory buffers over fixture files**. Creating files with `Buffer.from()` keeps tests self-contained and eliminates dependencies on external fixture files. Use fixture files only when you need real file content (e.g., a valid PDF that your app parses).
|
|||
|
|
|
|||
|
|
3. **Always set up the download listener before clicking**. Call `page.waitForEvent('download')` before the click that triggers the download. If you click first, you may miss the event.
|
|||
|
|
|
|||
|
|
4. **Use `createReadStream()` to verify content without disk I/O**. The `download.createReadStream()` method lets you read file content directly in memory, which is faster and avoids cleanup of temporary files.
|
|||
|
|
|
|||
|
|
5. **Test the accept attribute AND the JavaScript validation separately**. The HTML `accept` attribute only filters the OS file dialog -- it does not prevent uploads via other means. Your app's JavaScript validation is the real gatekeeper, and `setInputFiles()` bypasses the `accept` filter, which is exactly what you want to test.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Related
|
|||
|
|
|
|||
|
|
- [Playwright Upload Docs](https://playwright.dev/docs/input#upload-files)
|
|||
|
|
- [Playwright Download Docs](https://playwright.dev/docs/downloads)
|
|||
|
|
- `recipes/drag-and-drop.md` -- General drag and drop patterns
|
|||
|
|
- `recipes/crud-testing.md` -- File uploads as part of resource creation
|
|||
|
|
- `foundations/actions.md` -- Input interaction fundamentals
|