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.

playbookfile-uploadno-codeqa
monito

File upload testing: the whole surface, in four prompts

playbookfile-uploadno-codeqa
July 13, 2026

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:

  1. 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.
  2. 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.
  3. Type validation that isn't just the extension. OWASP's File Upload Cheat Sheet is blunt about this: the Content-Type sent 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.
  4. 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.
  5. 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?
  6. 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

Test the file uploader on https://staging.yourapp.com/upload.

1. Create a small valid image (PNG or JPEG) and upload it using the
   normal file picker. Confirm it uploads, a thumbnail/preview appears,
   and the file shows in the uploaded list.
2. Now drag-and-drop the same file onto the drop zone instead of using
   the picker. Confirm the result is identical to step 1.
3. Drag-and-drop a FOLDER (not a file) onto the drop zone. Report
   exactly what happens — is it rejected with a clear message, silently
   ignored, or does it error?
4. Drag-and-drop three files at once. If the UI is single-file, report
   whether it takes the first, rejects all, or breaks.

Throughout: report any console errors and any network request that
returns 4xx/5xx without a corresponding visible message in the UI.

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.

On https://staging.yourapp.com/upload, test validation boundaries.
Assume the stated limit is 5 MB and the allowed types are PNG/JPEG —
adjust to whatever the UI actually states.

1. Create and upload an image just UNDER the size limit. Expect success.
2. Create and upload a file just OVER the size limit. Expect a clear,
   specific error ("max 5 MB"), NOT a generic failure or a silent no-op.
3. Create and upload a .txt or .pdf when only images are allowed.
   Confirm it's rejected with a message a human can act on.
4. Take a plain .txt file, RENAME it to end in .png, and upload it.
   Report whether it's accepted. If accepted, this is a finding: type
   was validated by extension, not by file signature.
5. For every rejection above, watch the network tab: did the file get
   rejected in the browser, or did it upload fully and get rejected by
   the server? Report which, for each case.

Flag any case where the client accepts a file the server then rejects.

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

On https://staging.yourapp.com/upload, test the progress and abort
behavior. If you can throttle the network to a slow connection, do so
first so the upload takes several seconds.

1. Start uploading a large (but allowed) file. Watch the progress
   indicator: does it advance gradually in step with the upload, or
   does it jump to 100% and then hang while the request finishes?
2. Mid-upload (around 50%), click cancel/abort. Confirm: does the UI
   return to a clean ready state, AND does the network request actually
   get cancelled? Report whether the request kept running after the UI
   said it stopped.
3. After cancelling, immediately upload a different file. Confirm it
   succeeds cleanly and doesn't carry over state from the cancelled one
   (no duplicate, no stuck spinner, no leftover progress bar).
4. Start an upload and let it complete. Reload the page. Confirm the
   uploaded file is still listed (it persisted server-side, not just
   in component state).

Report all console errors, including ones that didn't visibly break
anything.

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

On https://staging.yourapp.com/upload, focus only on error quality.
For each failure case, capture the exact message shown and judge
whether a non-technical user could understand and recover from it.

1. Oversized file: is the message specific about the limit?
2. Wrong type: does it say which types ARE allowed?
3. Trigger a network failure mid-upload (or upload something the
   server will reject). Is the error recoverable — can the user retry
   without reloading — or is it a dead end?
4. Look for the silent failures: any case where the file simply
   doesn't appear and no message explains why. Those are the worst,
   because the user thinks it worked.

For each, rate the message as clear / confusing / missing, and quote it.

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:

On https://staging.yourapp.com/upload, create a plain .txt file, rename
it to end in .png, and upload it. Then create and upload a file just
over the stated size limit. For each: report whether it was accepted or
rejected, whether the rejection happened in the browser or at the
server (watch the network tab), and quote the exact error message the
user sees. Capture screenshots and any console errors.

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.