File upload testing: the whole surface, in four prompts
File upload is six features pretending to be one button: drag-drop, validation, progress, abort, and a server that can't trust anything the client sent. Here's how to test all of it in plain English, no scripts.
File upload testing: the whole surface, in four prompts
A file input looks like the least interesting element on the page — a button, a dialog, a spinner, done. It is, in practice, one of the most bug-dense widgets you ship, because the moment a user hands you a file you've taken on a small distributed system: a drag target with its own event model, client-side validation you can't trust, an upload that streams over a flaky network, a progress bar that has to track it, an abort path, and a server that has to re-validate everything the browser already "checked" because the browser's checks are a courtesy, not a guarantee.
Most teams test the happy path — pick a normal JPEG, watch it upload, see the thumbnail — and ship. The bugs live in the other ninety percent: the dragged folder, the file renamed invoice.pdf.exe, the upload cancelled at 80%, the 12 MB image that the client waved through and the server rejected with a raw 500. This is the playbook for covering the whole surface without writing a line of Playwright, in four prompts you can run on every deploy that touches the uploader.
What "testing file upload" actually has to cover
The button is the part that works. Here's the surface that doesn't, roughly in order of how often it breaks:
- Both input paths. The hidden
<input type="file">and the drag-and-drop target are two different code paths that are supposed to end in the same place. They rarely get equal testing. Drag-drop is where the fun edge cases live: dropping a folder, dropping multiple files when the UI expects one, dropping outside the target, dropping a file type the click-picker would have filtered out. - Validation the client does — and the server redoes. Size limits, type limits, filename rules. The rule that matters is whether the client and server agree, because a client that allows what the server rejects produces the worst failure mode in this whole list: a confident upload that 500s at the end.
- Type validation that isn't just the extension. OWASP's File Upload Cheat Sheet is blunt about this: the
Content-Typesent by the browser "is provided by the user, and as such cannot be trusted, as it is trivial to spoof." Real type validation checks the file's signature — its magic bytes — not the extension and not the header. The classic bypass is the double extension (shell.php.jpg) or the null-byte trick the cheat sheet calls out; a test pass that only renames the extension and calls it covered is testing nothing. - The progress bar's honesty. Does it track real bytes, or is it a CSS animation that hits 100% and then waits? On a slow connection the difference is the entire user experience.
- Abort and resume. Cancel mid-upload: does the request actually abort, or does the UI lie while the bytes keep flowing? Start again: clean, or duplicate?
- Post-upload state. The file appears in the list, the thumbnail renders, a reload still shows it, and a failed upload leaves no ghost row behind.
A scripted suite tends to cover items 1 and 6 with the click-picker only, mock the network, and never touch drag-drop or the size/type boundary at all — because driving a native file dialog and a real multi-megabyte upload from Playwright is genuinely annoying. The prompts below cover it the way a person would, against a real backend.
A note on test files before the prompts: you don't pre-stage fixtures. A browser agent can generate what it needs on the fly — a small valid image, a file just over the size limit, a non-image renamed to .png — so every prompt below just tells the agent what kind of file to create and upload.
Prompt 1: both input paths, including the dragged folder
Step 2 is the path-parity check most suites skip. Step 3 is the one that finds real bugs — a drop handler that calls readAsDataURL on a directory entry and throws an uncaught error nobody styled. The agent reads the rendered page, so "silently ignored" and "errored in a way the user can see" come back as distinguishable outcomes, not a single green tick.
Prompt 2: the size and type boundary, both sides of the wire
This is the prompt that earns its keep. The agent creates each test file itself — an oversized one, a wrong-type one, and a disguised one — so there's nothing to pre-stage.
Step 4 is the spoofed-type check straight out of OWASP's guidance — and the agent telling you where the rejection happened (step 5) is the part a mocked-network test can't, because it mocked away the exact boundary you care about. A client-only size check that lets a 50 MB file upload over a hotel Wi-Fi connection before failing is a real bug with a real support ticket attached, and it passes every test that stubs the request.
Prompt 3: progress, abort, and the network throttle
Step 2 separates a real abort from a cosmetic one — a UI that clears the progress bar while the XMLHttpRequest keeps streaming to the server is one of those bugs that's invisible until your storage bill or your moderation queue notices. The agent watching the network log catches the divergence that a screenshot never would. (Reading the network log even when a run passes is the habit that pays off across all of these flows.)
Prompt 4: the error messages, judged like a human
This is the prompt you cannot write as a script, because "could a confused human recover from this message" is a judgment, not an assertion. It's also where the highest-leverage bugs hide: not the crash, but the upload that quietly does nothing while the user waits, reloads, and gives up. This is the same reason an agent reading the rendered page finds bugs your scripts miss — it evaluates the screen the way the person filing your next support ticket will.
Reading the results
Each run comes back as a Monito Session, not a pass/fail bit: a screenshot timeline of every step, the network log with the actual multipart upload request and its real status, console output, and the agent's reasoning where it made a judgment call. For file upload specifically, the network log is the document to read — it's the only place the "client accepted, server rejected" mismatch is visible, and it's the difference between "the test passed" and "the test passed but the server quietly 500s on anything over 8 MB."
The prompts reference no selectors, no input IDs, no DOM paths — only what the uploader is for. Redesign the drop zone, swap the progress component, move the button: the intent didn't change, so the prompt didn't either. That's the whole point of describing intent instead of steps, and it's why this scales where scripted upload tests rot.
The one to run right now
If you run a single check today, run the spoofed-type-and-boundary prompt — it's the cheapest test with the nastiest failure mode, because a type check that trusts the extension is both a bug and a security hole:
Save it as a Test Scenario, pin it to your staging Project, and wire it into CI against every preview deploy that touches the uploader. A full run is typically 8–13 credits — roughly $0.08–$0.13 — and your first run is free. Point it at staging, paste the prompt, and read the network log. If the disguised .exe comes back rejected by file signature, you've earned the boring upload button you always wanted.