📖 The Story: The Upload Zone That Broke in WebKit

A QA engineer was testing a new document upload feature. The component listened for dragenter, dragover, and drop events on a #dropzone div. On Chrome, the test worked fine — the engineer had written 12 lines of JavaScript that created a DataTransfer object, added a File to it, and dispatched the three events manually. The test passed, the PR merged, and the feature shipped.

Three days later, a customer reported the upload zone was completely broken on Safari. The engineer ran the same test on WebKit and it failed silently — the drop event fired, but the files array was empty. After two hours of debugging, they discovered that WebKit’s synthetic DataTransfer behaved differently: the file added via items.add() was not visible in the drop handler’s event.dataTransfer.files collection unless the event was dispatched with a specific browser-internal flag.

The engineer wrote a browser-specific workaround with feature detection, doubling the test code to 24 lines. It worked, but every time the team touched the upload component, the test broke in a new and creative way. The test became the most flaky in the suite.

The real problem was not the test code. It was that Playwright had no first-class drop API, so every team reinvented the same brittle wheel.

🎯 Why It Matters

🎨

Cross-Browser

One API works identically on Chromium, Firefox, and WebKit. No more browser-specific DataTransfer workarounds.

♻️

Files & Data

Drop files (with mimeType and buffer), clipboard-like data (text/plain, text/uri-list), or both in a single call.

One Liner

Replaces 12+ lines of manual event synthesis with a single locator.drop() call. Less code, fewer bugs.

🧠

AI-Ready

Pairs naturally with MCP-driven agents that need to test upload zones without writing custom JS.

🌍 Real World Example

A SaaS company ships a new email attachment uploader. The QA engineer writes a test that drags a 2 MB PDF from the desktop onto the upload zone. Before 1.60, this test required a Node script that read the PDF into a Buffer, wrapped it in a File object, built a DataTransfer, and dispatched three events — and it still failed on Safari. With locator.drop(), the entire test becomes a single line: await page.locator('#dropzone').drop({ files: { name: 'invoice.pdf', mimeType: 'application/pdf', buffer: pdfBuffer } }). It passes on all three browsers on the first run.

🧒 Explain Like I'm 10

Imagine you have a toy mailbox with a slot on top. You want to test that when you drop a letter into the slot, the mailbox eats it and shows a thank-you light.

Before, you had to pretend to be the postman: you made a fake letter, you made a fake hand, you told the hand to wiggle over the slot, and you told the letter to fall in. Sometimes the mailbox didn’t notice the letter because your fake hand wiggled wrong.

Now, Playwright gives you a magic button that says “drop this letter right here.” You press the button, and the letter appears inside the mailbox every single time, no wiggling needed.

🎓 Professional Explanation

The HTML5 drag-and-drop API is notoriously inconsistent across browsers. The DataTransfer object that carries the dragged payload has different initialization rules on Chromium, Firefox, and WebKit — particularly around whether files added via items.add() appear in the files collection during the drop event. Playwright 1.60’s locator.drop() abstracts this away by dispatching dragenter, dragover, and drop with a synthetic DataTransfer constructed inside the browser process, ensuring the payload is visible to the page’s event handlers on every engine.

The API accepts two payload shapes: files (an object with name, mimeType, and buffer) for file-drop scenarios, and data (a record of MIME-type to string) for clipboard-like data such as text/plain or text/uri-list. You can pass both in the same call if your drop zone accepts mixed content.

📊 The Flow

How locator.drop() Works Internally

1. Test calls locator.drop({ files: {...} })
⬇️
2. Playwright builds a synthetic DataTransfer in the page context
⬇️
3. Dispatches dragenter → dragover → drop on the target element
⬇️
4. Page’s drop handler reads event.dataTransfer.files / .getData()
⬇️
5. Test asserts the upload UI reacted correctly

⚙️ The locator.drop() API

The API lives on the Locator class and accepts a single options object. You can drop files, clipboard-like data, or both. The synthetic DataTransfer is constructed inside the browser process, so the page’s drop handler sees the payload exactly as if a real user had dragged and dropped.

typescript
import { test } from class="tk-str">'@playwright/test';

test(class="tk-str">'upload a PDF via drag and drop', async ({ page }) => {
  await page.goto(class="tk-str">'https:class="tk-com">//app.example.com/upload');

  class=class="tk-str">"tk-com">// Drop a file onto the upload zone
  await page.locator(class="tk-str">'class="tk-com">#dropzone').drop({
    files: {
      name: class="tk-str">'invoice.pdf',
      mimeType: class="tk-str">'application/pdf',
      buffer: Buffer.from(class="tk-str">'mock pdf content'),
    },
  });

  await expect(page.getByText(class="tk-str">'invoice.pdf uploaded')).toBeVisible();
});

The files option takes a single object (or array of objects) with name, mimeType, and buffer. The buffer is a Node.js Buffer — read it from disk with fs.readFileSync() or construct it inline for mocks.

typescript
class=class="tk-str">"tk-com">// Drop clipboard-like data instead of files
test(class="tk-str">'drop a URL onto the rich text editor', async ({ page }) => {
  await page.goto(class="tk-str">'https:class="tk-com">//app.example.com/editor');

  await page.locator(class="tk-str">'.editor-area').drop({
    data: {
      class="tk-str">'text/plain': class="tk-str">'Check this out',
      class="tk-str">'text/uri-list': class="tk-str">'https:class="tk-com">//example.com/article',
    },
  });

  await expect(page.locator(class="tk-str">'.editor-area a')).toHaveAttribute(
    class="tk-str">'href', class="tk-str">'https:class="tk-com">//example.com/article'
  );
});

The data option is a record of MIME-type to string. Common types: text/plain, text/html, text/uri-list. The page’s drop handler reads these via event.dataTransfer.getData('text/plain').

typescript
class=class="tk-str">"tk-com">// Drop multiple files at once
test(class="tk-str">'upload multiple files', async ({ page }) => {
  await page.goto(class="tk-str">'https:class="tk-com">//app.example.com/upload');

  await page.locator(class="tk-str">'class="tk-com">#dropzone').drop({
    files: [
      { name: class="tk-str">'a.pdf', mimeType: class="tk-str">'application/pdf', buffer: Buffer.from(class="tk-str">'a') },
      { name: class="tk-str">'b.png', mimeType: class="tk-str">'image/png', buffer: Buffer.from(class="tk-str">'b') },
      { name: class="tk-str">'c.txt', mimeType: class="tk-str">'text/plain', buffer: Buffer.from(class="tk-str">'c') },
    ],
  });

  await expect(page.getByText(class="tk-str">'3 files uploaded')).toBeVisible();
});

Pass an array of file objects to drop multiple files in a single action. The page’s drop handler receives all of them in event.dataTransfer.files.

🔄 Before vs After

Before: Manual event synthesis (1.59 and earlier)

typescript
class=class="tk-str">"tk-com">// ~12 lines, browser-specific, brittle
await page.locator(class="tk-str">'class="tk-com">#dropzone').evaluate(async (el, fileContent) => {
  const dt = new DataTransfer();
  const file = new File([fileContent], class="tk-str">'note.txt', { type: class="tk-str">'text/plain' });
  dt.items.add(file);
  el.dispatchEvent(new DragEvent(class="tk-str">'dragenter', { dataTransfer: dt, bubbles: true }));
  el.dispatchEvent(new DragEvent(class="tk-str">'dragover',  { dataTransfer: dt, bubbles: true }));
  el.dispatchEvent(new DragEvent(class="tk-str">'drop',      { dataTransfer: dt, bubbles: true }));
}, class="tk-str">'hello');

This approach worked on Chromium but silently failed on WebKit because the synthetic DataTransfer’s files collection was empty in the drop handler. Teams ended up with browser-specific branches that doubled the code.

After: locator.drop() (1.60+)

typescript
class=class="tk-str">"tk-com">// One line, cross-browser, no JS evaluation needed
await page.locator(class="tk-str">'class="tk-com">#dropzone').drop({
  files: {
    name: class="tk-str">'note.txt',
    mimeType: class="tk-str">'text/plain',
    buffer: Buffer.from(class="tk-str">'hello'),
  },
});

The same test now passes on Chromium, Firefox, and WebKit without any browser detection. The DataTransfer is constructed inside the browser process, so the payload is visible to the page’s event handlers on every engine.

⚠️ Gotcha

locator.drop() only fires dragenter, dragover, and drop. It does not simulate intermediate drag-move events. If your component relies on dragover coordinates for hover previews or sort indicators (like a kanban board reordering), locator.drop() will not trigger those. For full drag path simulation, you still need page.mouse.move() with explicit steps.

⚠️ Common Mistakes

Mistake 1: Forgetting to pass a Buffer, not a string

The buffer field expects a Node.js Buffer, not a plain string. If you pass buffer: 'hello', Playwright will silently send an empty file. Always wrap strings with Buffer.from('hello'), or read real files with fs.readFileSync('path/to/file').

typescript
class=class="tk-str">"tk-com">// WRONG
await page.locator(class="tk-str">'class="tk-com">#dropzone').drop({
  files: { name: class="tk-str">'note.txt', mimeType: class="tk-str">'text/plain', buffer: class="tk-str">'hello' }
});

class=class="tk-str">"tk-com">// CORRECT
await page.locator(class="tk-str">'class="tk-com">#dropzone').drop({
  files: { name: class="tk-str">'note.txt', mimeType: class="tk-str">'text/plain', buffer: Buffer.from(class="tk-str">'hello') }
});

Mistake 2: Expecting drag-move events to fire

locator.drop() dispatches exactly three events: dragenter, dragover, drop. It does not fire dragstart, drag, or dragend. If your test asserts that a “drag in progress” overlay appeared, it will fail. Use page.mouse for full drag-path tests.

Mistake 3: Using the wrong MIME type

If you pass mimeType: 'application/pdf' but the buffer contains plain text, some upload zones validate the MIME against the file content and reject the drop. Match the MIME to the actual buffer content, or use 'application/octet-stream' for raw binary.

✅ Best Practices

  1. Always use Buffer.from() for inline file content — never pass raw strings to the buffer field.
  2. Match the mimeType to the actual content to avoid server-side validation failures in your test environment.
  3. Read real files with fs.readFileSync() when you need realistic file sizes for performance testing.
  4. Use the data option for clipboard scenarios (rich text editors, URL droppers) instead of faking file objects.
  5. Assert the upload UI reacted immediately after the drop — Playwright’s auto-waiting handles the async gap.

🏗️ Senior Engineer Deep Dive

Why the DataTransfer abstraction matters

The HTML5 drag-and-drop spec leaves DataTransfer initialization largely up to the browser. During a real user drag, the browser populates dataTransfer.files and dataTransfer.items internally. When you synthesize a DragEvent from JavaScript, you can only set dataTransfer via the constructor — and the items.add() method behaves differently across engines. WebKit, in particular, does not expose files added via items.add() in the files collection during the drop event. Playwright solves this by constructing the DataTransfer inside the browser process (via CDP for Chromium, marionette for Firefox, WIRelay for WebKit), bypassing the JavaScript sandbox entirely.

When to use drop() vs setInputFiles()

page.setInputFiles() is for traditional <input type="file"> elements. locator.drop() is for custom drop zones that listen for drag events. If your component has both — a hidden file input with a visible drop overlay — prefer setInputFiles() for reliability, and use drop() only when the component genuinely requires drag events.

💼 Interview Questions

Beginner
What does locator.drop() do in Playwright 1.60?
Show Answer
It simulates an external drag-and-drop onto an element by dispatching dragenter, dragover, and drop events with a synthetic DataTransfer. You can drop files (with name, mimeType, buffer) or clipboard-like data (text/plain, text/uri-list). It works cross-browser without any JavaScript evaluation.
Intermediate
How is locator.drop() different from page.setInputFiles()?
Show Answer
setInputFiles() targets a hidden <input type="file"> element directly. locator.drop() targets any element that listens for drag events (a custom drop zone). Use setInputFiles() when the component has a real file input; use drop() when the component only responds to dragenter/dragover/drop events.
Advanced
Why does locator.drop() work cross-browser when manual DataTransfer synthesis fails on WebKit?
Show Answer
When you create a DataTransfer in JavaScript and add files via items.add(), WebKit does not expose those files in the .files collection during the drop event. Playwright constructs the DataTransfer inside the browser process via CDP/Marionette/WIRelay, bypassing the JavaScript sandbox, so the files are visible to the page's event handlers on every engine.
Scenario
Your drop zone test passes on Chrome but fails on Safari. The drop event fires but event.dataTransfer.files is empty. How do you fix it?
Show Answer
This is the classic WebKit DataTransfer bug. If you're on Playwright 1.60+, switch from manual event synthesis to locator.drop({ files: {...} }) — it constructs the DataTransfer inside the browser process, so the files are visible on every engine. If you're stuck on 1.59 or earlier, you need a browser-specific workaround or an upgrade.

🏋️ Practical Exercise

🎯 Hands-On Practice medium

Your First Drop Test

  1. Create a new Playwright project: npm init playwright@latest
  2. Install 1.60+: npm install -D @playwright/test@1.62.1 && npx playwright install
  3. Write a test that navigates to https://ps.uci.edu/~frank/Src/doc/txt_dropping.html (a public drop test page)
  4. Use locator.drop({ data: { 'text/plain': 'Hello Playwright' } }) on the drop zone
  5. Assert the dropped text appears in the page’s output area
  6. Run the test on all three browsers: npx playwright test --project=chromium --project=firefox --project=webkit

🚀 Mini Project

🏗️ The Multi-File Upload Suite

  1. Build a test that drops 3 files simultaneously (a PDF, a PNG, and a TXT) onto a single drop zone
  2. Assert that all 3 file names appear in the upload list
  3. Add a second test that drops a URL (text/uri-list) onto a rich text editor and asserts the link is rendered
  4. Run both tests in parallel across 3 browsers (6 total runs) and verify they all pass
  5. Bonus: measure the test execution time and compare it to the old manual-synthesis approach

📝 Quiz

Test your understanding. Click an option to check your answer.

1. Which events does locator.drop() dispatch on the target element?
A) dragstart, drag, dragend
B) dragenter, dragover, drop
C) mousedown, mousemove, mouseup
D) click, change, input
Answer: B — locator.drop() dispatches dragenter, dragover, and drop — the three events a real drop triggers.
2. What type must the buffer field be in the files option?
A) A string
B) A Buffer
C) A number
D) An array of strings
Answer: B — The buffer field expects a Node.js Buffer. Use Buffer.from('text') for inline content or fs.readFileSync() for real files.
3. Which MIME type would you use to drop a URL onto a rich text editor?
A) text/html
B) application/json
C) text/uri-list
D) application/x-www-form-urlencoded
Answer: C — text/uri-list is the standard MIME type for URL data in drag-and-drop operations.
4. Why did manual DataTransfer synthesis fail on WebKit before 1.60?
A) WebKit does not support DataTransfer
B) Files added via items.add() were not visible in the .files collection during drop
C) WebKit requires a special user gesture flag
D) Playwright did not support WebKit at all
Answer: B — WebKit does not expose files added via items.add() in the .files collection during the drop event, causing silent failures.
5. Can locator.drop() simulate intermediate drag-move events for sort indicators?
A) Yes, it fires dragmove events
B) Yes, if you pass dragPath: true
C) No, it only fires dragenter/dragover/drop
D) Only on Chromium
Answer: C — locator.drop() fires exactly three events. For full drag-path simulation (sort indicators, hover previews), use page.mouse.move() with explicit steps.
6. How do you drop multiple files in a single call?
A) Pass files: { ... } with comma-separated names
B) Pass files: [ {...}, {...}, {...} ] as an array
C) Call drop() three times in a loop
D) It is not possible
Answer: B — Pass an array of file objects to the files option. All files appear in event.dataTransfer.files on the page.
7. What happens if you pass buffer: 'hello' instead of buffer: Buffer.from('hello')?
A) Playwright throws a TypeError
B) It works fine, strings are auto-converted
C) It silently sends an empty file
D) The test hangs indefinitely
Answer: C — Playwright does not throw, but the file content is empty because the string is not a valid Buffer. Always wrap strings with Buffer.from().
8. Which Playwright version introduced locator.drop()?
A) 1.58
B) 1.59
C) 1.60
D) 1.61
Answer: C — locator.drop() shipped in Playwright 1.60 (May 11, 2026) alongside HAR tracing and test.abort().
9. What is the recommended alternative for traditional elements?
A) locator.drop() always
B) page.setInputFiles()
C) page.evaluate(() => input.click())
D) There is no alternative
Answer: B — page.setInputFiles() targets file inputs directly and is more reliable for traditional upload forms. Use drop() only for custom drop zones.
10. Does locator.drop() work on all three Playwright browsers?
A) Only Chromium
B) Only Chromium and Firefox
C) Yes — Chromium, Firefox, and WebKit
D) Only when running headless
Answer: C — locator.drop() is cross-browser by design. The same test passes on Chromium, Firefox, and WebKit without any browser detection.

❓ FAQ

Q: Can I use locator.drop() to test drag-and-drop between two elements on the same page?

A: No. locator.drop() simulates an external drop (like dragging a file from the desktop). For internal element-to-element drag (like reordering list items), use the existing page.mouse API: await page.mouse.move(srcX, srcY); await page.mouse.down(); await page.mouse.move(dstX, dstY, { steps: 10 }); await page.mouse.up();.

Q: Does locator.drop() work with the old recordVideo option?

A: Yes. locator.drop() is independent of the video/screencast system. If you have video enabled, the drop will be visible in the recording. With the new 1.59+ screencast API, you can also add chapter markers around the drop for narration.

Q: Can I drop a file larger than available memory?

A: No. The buffer must fit in Node.js memory. For very large files, use a mock buffer of the correct size (Buffer.alloc(50 * 1024 * 1024) for a 50 MB placeholder) rather than reading the real file. The drop zone typically only checks the file name and size, not the actual content.

📦 Summary

🎯 Key Takeaways

  • locator.drop() replaces 12+ lines of brittle manual event synthesis with a single cross-browser call.
  • Use the files option for file drops (name, mimeType, buffer) and the data option for clipboard-like drops (text/plain, text/uri-list).
  • Always wrap strings with Buffer.from() — raw strings silently produce empty files.
  • It only fires dragenter/dragover/drop — use page.mouse for full drag-path tests with sort indicators.
  • Prefer setInputFiles() for traditional <input type="file"> elements; use drop() only for custom drop zones.