SurfSense/.cursor/skills/playwright-testing/file-operations.md
2026-05-04 13:54:13 +05:30

25 KiB
Executable file

File Operations

When to use: Testing file uploads, downloads, drag-and-drop file interactions, file type validation, and download verification. Prerequisites: core/locators.md, core/assertions-and-waiting.md

Quick Reference

// 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 <input type="file"> element. Avoid when: The upload uses a drag-and-drop zone with no underlying file input.

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

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

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

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 <input type="file">. Common with drag-and-drop libraries and custom upload components. Avoid when: There is a visible <input type="file"> — use setInputFiles directly.

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

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 <input type="file"> fallback. Avoid when: A file input exists — even hidden ones work with setInputFiles.

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 <input type="file">
  // 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

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

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

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

// 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
  },
});
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

// playwright.config.js
const { defineConfig } = require('@playwright/test');

module.exports = defineConfig({
  use: {
    acceptDownloads: true,
  },
});
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

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

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

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

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 <input type="file"> 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 <input type="file"> 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.

// Debug: check if there is a hidden <input type="file"> 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.

// 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.

// 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.

// 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.