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.
Root cause: detached inputs and non-portable paths
A multi-file upload test fails for two 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. Both failure modes are timing- and environment-sensitive, which is why they read as flakiness even though the cause is deterministic.
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();
});
Step-by-step fix
- Target the underlying input, not the visible button. Use
page.locator('input[type="file"]')or adata-testidon the input element. Custom upload widgets render a styled trigger, butsetInputFiles()must operate on the real<input>. If the input isdisplay:none, that is fine — Playwright writes files directly and does not require visibility for this API. - 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. - 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." - 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. - 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. - 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.
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.
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.
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.
Verification
Confirm correctness on three 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. 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);
});
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. 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.