Playwright architecture, selector reliability, and advanced interaction patterns.

Handling Multiple File Uploads in Playwright

A file input that accepts several files at once is one of the easiest controls to automate badly. The native OS picker cannot be driven by a headless browser, relative paths resolve against whatever directory the runner happens to start in, and modern frameworks frequently re-render the <input type="file"> the instant a selection lands. Playwright's setInputFiles() sidesteps the OS dialog completely by writing the chosen files straight into the input element, but the call still fails when the input is detached, hidden behind a custom button, or addressed with a path that exists on your laptop and nowhere else. This guide walks through the root cause of those failures, a minimal reproducible test, a numbered fix you can apply line by line, and the verification steps that prove the upload actually reached the server. It builds directly on File Uploads & Downloads and the broader patterns in Advanced Interactions & Test Assertions.

Multiple file upload flow with setInputFiles Absolute file paths are resolved, the file input is waited for, setInputFiles assigns the array, and a response waiter confirms the server accepted every file. Resolve paths path.resolve() Wait for input waitFor() Assign array setInputFiles() Verify upload waitForResponse() Deterministic multi-file upload pipeline Each stage awaits the previous one so CI never races the DOM.
The four stages of a stable multi-file upload: resolve absolute paths, wait for the input, assign the whole array in one call, then confirm the server accepted every file.

Root cause: detached inputs, non-portable paths, and repeated calls

A multi-file upload test fails for three structurally different reasons, and conflating them leads to fixes that only mask the symptom.

The first is path resolution. setInputFiles() reads files from disk relative to the process working directory unless you hand it an absolute path. A spec that passes 'fixtures/doc1.pdf' works when the runner starts in the spec's folder and throws ENOENT when CI starts it from the repository root. Resolving every path with path.resolve(__dirname, ...) pins the lookup to the file that owns the fixture regardless of where the run begins.

The second is element lifecycle. Many applications hide the real <input type="file"> and surface a styled button, or they swap the input for a new node the moment a file is chosen so they can show a preview. If your locator resolves to a node that the framework has already removed from the DOM, Playwright raises an "element is not attached" error. The defense is to address a stable input and wait for it to reach the attached state before assigning files, rather than assuming the node you found a moment ago still exists.

The third is the call pattern itself. setInputFiles() models a single user interaction with the picker, so it replaces the input's files collection rather than appending to it. A loop that calls it once per fixture therefore leaves exactly one file selected — the last one — and the assertion that follows fails with a count of 1 where you expected 4. All three failure modes are timing- or environment-sensitive, which is why they read as flakiness even though every one of them is deterministic once you know which axis is wrong.

Fragile versus stable multi-file upload choices A four-row matrix comparing the fragile and stable option for fixture paths, input reference, assignment calls, and the success signal. Four decisions that decide whether the upload is stable Decision Fragile choice Stable choice Fixture paths 'fixtures/doc1.pdf' path.resolve(__dirname) Input reference Click the styled button Real input + waitFor Assignment calls One call per file One array, one call Success signal UI text only Parsed response body Every row on the left survives a laptop run and dies on a CI runner.
Each row is an independent decision; a suite only becomes reliable when all four sit in the right-hand column.

Minimal reproducible example

The test below uploads two fixtures into a single input and asserts the UI confirms the count. Every path is absolute, and the input is waited for before assignment so a re-render cannot detach it mid-call.

import { test, expect } from '@playwright/test';
import path from 'node:path';

test('uploads multiple files into one input', async ({ page }) => {
  await page.goto('/upload-form');

  // Address the real <input type="file">, not the styled button in front of it.
  const fileInput = page.locator('input[type="file"]');

  // Wait for the node to be present in the DOM before assigning files,
  // so a framework re-render cannot detach it during setInputFiles().
  await fileInput.waitFor({ state: 'attached' });

  // Resolve each fixture against this spec's directory so the path is
  // identical on a laptop and on a CI runner that starts elsewhere.
  const filePaths = [
    path.resolve(__dirname, 'fixtures', 'doc1.pdf'),
    path.resolve(__dirname, 'fixtures', 'img2.png'),
  ];

  // One call assigns the whole array; the OS file dialog is never opened.
  await fileInput.setInputFiles(filePaths);

  // Assert on the rendered result the user would see, not on internal state.
  await expect(page.getByText('2 files ready')).toBeVisible();
});

The assertion deliberately reads rendered text rather than a DOM property. That keeps the test honest about what the user experiences and lines up with the retrying matchers described in Web-First Assertions, which poll until the component finishes rendering instead of sampling once.

Step-by-step fix

  1. Target the underlying input, not the visible button. Use page.locator('input[type="file"]') or a data-testid on the input element. Custom upload widgets render a styled trigger, but setInputFiles() must operate on the real <input>. If the input is display:none, that is fine — Playwright writes files directly and does not require visibility for this API, a case explored further in Uploading Files to Hidden Input Elements.
  2. Wait for the input to attach. Call await fileInput.waitFor({ state: 'attached' }) before assigning files. This eliminates the "element is not attached to the DOM" error caused by frameworks that remount the input after the first selection.
  3. Resolve every path to an absolute path. Build paths with path.resolve(__dirname, 'fixtures', name). Relative strings resolve against the runner's working directory, which differs between local and CI, so they fail unpredictably. Absolute paths are the single most effective fix for "works on my machine."
  4. Assign the whole array in one call. Pass the array to setInputFiles([...]) rather than calling it once per file. Repeated calls replace the selection each time, so the input ends up holding only the last file. One call mirrors how a user multi-selects in the native picker.
  5. Verify the server accepted the upload. Register page.waitForResponse() before the action that triggers the request, then await it after, so the listener is in place when the response arrives. Assert on the parsed body or status to confirm processing rather than trusting UI text alone.
  6. Clear the selection when a step needs a fresh start. Call setInputFiles([]) to reset the input between sub-cases in the same test, which avoids stale files leaking into a later assertion.
  7. Give large batches their own timeout budget. A twenty-file batch of multi-megabyte fixtures can exceed the default action timeout while the browser is still streaming bytes. Pass setInputFiles(paths, { timeout: 30_000 }) for those specs instead of raising the global timeout, so a genuinely stuck upload elsewhere still fails fast — the same targeted approach recommended in Configuring Retries and Timeouts for Stable CI.
Ordering of a multi-file upload round trip A sequence diagram showing the test runner waiting for the input, registering a response waiter, assigning the array, and the server replying with an upload count. Who does what, and in which order Test runner Page File input Server waitFor state attached register waitForResponse setInputFiles([doc1, img2]) multipart POST 200 uploadedCount 2 The waiter is registered before the assignment, so the reply is never missed.
Registering the response waiter one step before the assignment is what removes the race between the upload request and the listener.

Assigning in-memory files and whole directories

Not every batch should live on disk. Committing twenty fixture files to version control to test a bulk importer is expensive to review and easy to let rot, and generated content — a CSV whose row count matters, an image with a specific byte size — is better produced in the test itself. setInputFiles() accepts an array of file payload objects in place of paths, each carrying a name, a MIME type, and a Buffer of raw bytes. The browser receives exactly the same multipart body it would have built from real files.

import { test, expect } from '@playwright/test';

test('uploads generated CSV files without touching disk', async ({ page }) => {
  await page.goto('/upload-form');
  const fileInput = page.locator('input[type="file"]');
  await fileInput.waitFor({ state: 'attached' });

  // Each entry is a file payload: display name, MIME type, and raw bytes.
  // Nothing is written to disk, so no fixture directory has to exist in CI.
  await fileInput.setInputFiles([
    { name: 'q1.csv', mimeType: 'text/csv', buffer: Buffer.from('id,total\n1,42\n') },
    { name: 'q2.csv', mimeType: 'text/csv', buffer: Buffer.from('id,total\n2,17\n') },
  ]);

  // The widget reads the names off the input, so both should render.
  await expect(page.getByText('q1.csv')).toBeVisible();
  await expect(page.getByText('q2.csv')).toBeVisible();
});

Mixing the two forms in one call is not supported — an array is either all paths or all payloads — so pick per test rather than per file. Payloads are the better default for generated data and for anything whose exact bytes the assertion depends on; real paths stay better for binary fixtures such as PDFs and images that you want a human to be able to open.

Directory uploads work differently again. An input carrying the webkitdirectory attribute expects a single folder, so you pass one absolute directory path rather than an array: await fileInput.setInputFiles(path.resolve(__dirname, 'fixtures', 'batch')). Playwright walks the tree and submits every file inside it with its relative path preserved, which is what the server needs to reconstruct the folder structure. Keep those fixture folders small; a directory with hundreds of entries makes the test slow for reasons that have nothing to do with the code under test.

Troubleshooting variants

setInputFiles throws "element is not attached to the DOM"

The framework replaced the input node between your locator resolving and the assignment running. Re-resolve the locator immediately before the call and wait for state: 'attached'. For inputs that are recreated on every render, wrap the assignment in expect(async () => { await fileInput.setInputFiles(paths); }).toPass() so Playwright re-resolves and retries until the node is stable. Anchor the locator to a stable parent container instead of a class that the framework regenerates.

Lifecycle states of a re-rendering file input A state machine in which the input moves from absent to attached to assigned, and a detachment error routes back through a re-resolve and retry state. How a remount moves the input between states waitFor() setInputFiles() Detached or absent Attached in DOM Files assigned Re-resolve + retry Server accepted 200 OK detachment error toPass() retry
A remount pushes the input back to the retry state rather than failing outright, which is why wrapping the assignment in toPass() clears the whole class of detachment errors.

Files upload locally but the test reports a missing file in CI

This is almost always a relative path. The CI runner started the process from the repository root, so 'fixtures/doc1.pdf' resolved to a directory that does not exist. Switch every path to path.resolve(__dirname, ...). Confirm the fixtures are committed and not excluded by .gitignore, and that the case of the filename matches exactly — CI on Linux is case-sensitive even when your local macOS or Windows machine is not. Container images add one more variant of the same trap: if the spec directory is mounted at a different prefix inside the container, an absolute path baked into a constant breaks while __dirname keeps working, which is why the Dockerizing Playwright for Headless CI setup matters here too.

The custom widget ignores the assigned files

Some components only read the input's files property in response to a real change event, and a few listen for input instead. setInputFiles() dispatches change automatically, so if the preview never updates, intercept the request to confirm the files were attached. If the widget intercepts clicks to open its own dialog, listen for the filechooser event with page.on('filechooser', chooser => chooser.setFiles(paths)) and trigger the widget's button instead. Pair this with Mocking API Responses with Playwright when you want to assert the widget's behavior without a live backend.

Only some of the files arrive and the count is off by one

Check for a client-side filter before you suspect Playwright. Widgets routinely drop entries that fail a size cap, a MIME allow-list derived from the input's accept attribute, or a maximum-count rule, and most of them do it silently. Assert on the rejection message as well as the accepted count so the test distinguishes "the upload dropped a file" from "the product correctly refused it". If the counts still disagree, log the input's own view of the selection with await fileInput.evaluate((el: HTMLInputElement) => el.files?.length) immediately after the call — a correct value there proves the assignment worked and moves the investigation to the widget or the server.

Verification

Confirm correctness on four axes. First, run the spec repeatedly with npx playwright test --repeat-each=10; absolute paths and an attach wait should produce ten clean passes with no path or detachment errors. If even one run in ten diverges, treat it as a real defect and work through Detecting and Fixing Flaky Playwright Tests rather than adding a retry. Second, assert on the server's view of the upload rather than the UI, using a response waiter so a slow render cannot give a false green:

import { test, expect } from '@playwright/test';
import path from 'node:path';

test('server receives every uploaded file', async ({ page }) => {
  await page.goto('/upload-form');
  const fileInput = page.locator('input[type="file"]');
  await fileInput.waitFor({ state: 'attached' });

  // Register the waiter BEFORE the upload so the response is never missed.
  const uploadResponse = page.waitForResponse(
    (resp) => resp.url().includes('/api/upload') && resp.status() === 200,
  );

  await fileInput.setInputFiles([
    path.resolve(__dirname, 'fixtures', 'file1.txt'),
    path.resolve(__dirname, 'fixtures', 'file2.txt'),
  ]);

  // Parse the server's confirmation to prove both files were processed.
  const body = await (await uploadResponse).json();
  expect(body.uploadedCount).toBe(2);
  // Names matter as much as the count: a truncated batch still returns 200.
  expect(body.fileNames).toEqual(['file1.txt', 'file2.txt']);
});

Third, open the run in the Playwright Trace Viewer with --trace on and inspect the multipart request in the Network tab — every file name and size should be present, which proves the assignment reached the wire and not just the DOM. Fourth, run the spec under full parallelism with several workers. Upload tests that share a fixture directory are safe, but ones that write to a common server-side upload folder are not, and the failure only appears when two workers land on the same endpoint at once; giving each worker its own account or upload namespace through a fixture, as covered in Playwright Config & Fixtures, removes the collision. To verify downloads in the same suite, see the companion guide on Automating File Downloads and Verifying Contents.

Frequently Asked Questions

Why does my multi-file upload only keep the last file?

You are calling setInputFiles() once per file, and each call replaces the previous selection. Pass all paths in a single array to one call, exactly as a native picker assigns several files at once, so the input holds the full set.

Do I need the file input to be visible before calling setInputFiles?

No. Unlike clicks and fills, setInputFiles() writes files directly into the element, so it works even when the input is display:none. You should still wait for state: 'attached' so a re-render cannot detach the node mid-call.

How do I make fixture paths work in CI?

Resolve each path with path.resolve(__dirname, ...) so it is absolute and independent of the runner's working directory. Relative strings resolve against wherever the process started, which differs between local and CI, and Linux CI is case-sensitive, so match the filename case exactly.

Can I upload files that do not exist on disk?

Yes. Pass an array of payload objects with a name, a mimeType, and a Buffer of bytes instead of an array of paths. The browser builds the identical multipart body, which suits generated CSV or JSON content whose exact contents the assertion depends on. One call must use either paths or payloads, never a mixture of both.

How do I upload an entire folder at once?

Give the input the webkitdirectory attribute and pass a single absolute directory path rather than an array. Playwright walks the tree and submits every file with its relative path preserved so the server can rebuild the structure. Keep the folder small, because each extra file adds real transfer time to the test.

Back to overview