diff --git a/docs/src/api/class-locator.md b/docs/src/api/class-locator.md index 7b5916d418c0a..4057f6c224367 100644 --- a/docs/src/api/class-locator.md +++ b/docs/src/api/class-locator.md @@ -922,6 +922,54 @@ Locator of the element to drag to. ### option: Locator.dragTo.steps = %%-input-drag-steps-%% * since: v1.57 +## async method: Locator.drop +* since: v1.60 + +Simulate an external drag-and-drop of files or clipboard-like data onto this locator. + +**Details** + +Dispatches the native `dragenter`, `dragover`, and `drop` events at the center of the +target element with a synthetic [DataTransfer] carrying the provided files and/or data +entries. Works cross-browser by constructing the [DataTransfer] in the page context. + +If the target element's `dragover` listener does not call `preventDefault()`, the target +is considered to have rejected the drop: Playwright dispatches `dragleave` and this +method throws. + +**Usage** + +Drop a file buffer onto an upload area: + +```js +await page.locator('#dropzone').drop({ + files: { name: 'note.txt', mimeType: 'text/plain', buffer: Buffer.from('hello') }, +}); +``` + +Drop plain text and a URL together: + +```js +await page.locator('#dropzone').drop({ + data: { + 'text/plain': 'hello world', + 'text/uri-list': 'https://example.com', + }, +}); +``` + +### param: Locator.drop.payload = %%-drop-payload-%% +* since: v1.60 + +### option: Locator.drop.position = %%-input-position-%% +* since: v1.60 + +### option: Locator.drop.timeout = %%-input-timeout-%% +* since: v1.60 + +### option: Locator.drop.timeout = %%-input-timeout-js-%% +* since: v1.60 + ## async method: Locator.elementHandle * since: v1.14 * discouraged: Always prefer using [Locator]s and web assertions over [ElementHandle]s because latter are inherently racy. diff --git a/docs/src/api/params.md b/docs/src/api/params.md index faef52e2419ba..8a9d3d4322a93 100644 --- a/docs/src/api/params.md +++ b/docs/src/api/params.md @@ -134,6 +134,18 @@ Defaults to `left`. - `mimeType` <[string]> File type - `buffer` <[Buffer]> File content +## drop-payload +- `payload` <[Object]> + - `files` ?<[path]|[Array]<[path]>|[Object]|[Array]<[Object]>> + - `name` <[string]> File name + - `mimeType` <[string]> File type + - `buffer` <[Buffer]> File content + - `data` ?<[Object]<[string], [string]>> + +Data to drop onto the target. Provide `files` (file paths or in-memory buffers), `data` +(a mime-type → string map for clipboard-like content such as `text/plain`, `text/html`, +`text/uri-list`), or both. + ## input-down-up-delay - `delay` <[float]> diff --git a/packages/isomorphic/protocolMetainfo.ts b/packages/isomorphic/protocolMetainfo.ts index 13cf120061641..5c56bc4b521c7 100644 --- a/packages/isomorphic/protocolMetainfo.ts +++ b/packages/isomorphic/protocolMetainfo.ts @@ -167,6 +167,7 @@ export const methodMetainfo = new Map([ ['Frame.click', { title: 'Click', slowMo: true, snapshot: true, pause: true, input: true, isAutoWaiting: true, }], ['Frame.content', { title: 'Get content', snapshot: true, pause: true, }], ['Frame.dragAndDrop', { title: 'Drag and drop', slowMo: true, snapshot: true, pause: true, input: true, isAutoWaiting: true, }], + ['Frame.drop', { title: 'Drop files or data onto an element', slowMo: true, snapshot: true, pause: true, input: true, isAutoWaiting: true, }], ['Frame.dblclick', { title: 'Double click', slowMo: true, snapshot: true, pause: true, input: true, isAutoWaiting: true, }], ['Frame.dispatchEvent', { title: 'Dispatch "{type}"', slowMo: true, snapshot: true, pause: true, }], ['Frame.evaluateExpression', { title: 'Evaluate', snapshot: true, pause: true, }], diff --git a/packages/playwright-client/types/types.d.ts b/packages/playwright-client/types/types.d.ts index d4e0fa0d97da9..1ed380005167a 100644 --- a/packages/playwright-client/types/types.d.ts +++ b/packages/playwright-client/types/types.d.ts @@ -13386,6 +13386,97 @@ export interface Locator { trial?: boolean; }): Promise; + /** + * Simulate an external drag-and-drop of files or clipboard-like data onto this locator. + * + * **Details** + * + * Dispatches the native `dragenter`, `dragover`, and `drop` events at the center of the target element with a + * synthetic [DataTransfer] carrying the provided files and/or data entries. Works cross-browser by constructing the + * [DataTransfer] in the page context. + * + * If the target element's `dragover` listener does not call `preventDefault()`, the target is considered to have + * rejected the drop: Playwright dispatches `dragleave` and this method throws. + * + * **Usage** + * + * Drop a file buffer onto an upload area: + * + * ```js + * await page.locator('#dropzone').drop({ + * files: { name: 'note.txt', mimeType: 'text/plain', buffer: Buffer.from('hello') }, + * }); + * ``` + * + * Drop plain text and a URL together: + * + * ```js + * await page.locator('#dropzone').drop({ + * data: { + * 'text/plain': 'hello world', + * 'text/uri-list': 'https://example.com', + * }, + * }); + * ``` + * + * @param payload Data to drop onto the target. Provide `files` (file paths or in-memory buffers), `data` (a mime-type → string map + * for clipboard-like content such as `text/plain`, `text/html`, `text/uri-list`), or both. + * @param options + */ + drop(payload: { + files?: string|Array|{ + /** + * File name + */ + name: string; + + /** + * File type + */ + mimeType: string; + + /** + * File content + */ + buffer: Buffer; + }|Array<{ + /** + * File name + */ + name: string; + + /** + * File type + */ + mimeType: string; + + /** + * File content + */ + buffer: Buffer; + }>; + + data?: { [key: string]: string; }; + }, options?: { + /** + * A point to use relative to the top-left corner of element padding box. If not specified, uses some visible point of + * the element. + */ + position?: { + x: number; + + y: number; + }; + + /** + * Maximum time in milliseconds. Defaults to `0` - no timeout. The default value can be changed via `actionTimeout` + * option in the config, or by using the + * [browserContext.setDefaultTimeout(timeout)](https://playwright.dev/docs/api/class-browsercontext#browser-context-set-default-timeout) + * or [page.setDefaultTimeout(timeout)](https://playwright.dev/docs/api/class-page#page-set-default-timeout) methods. + */ + timeout?: number; + }): Promise; + /** * **NOTE** Always prefer using [Locator](https://playwright.dev/docs/api/class-locator)s and web assertions over * [ElementHandle](https://playwright.dev/docs/api/class-elementhandle)s because latter are inherently racy. diff --git a/packages/playwright-core/src/client/frame.ts b/packages/playwright-core/src/client/frame.ts index c3220dcaa0047..0a03ab019d163 100644 --- a/packages/playwright-core/src/client/frame.ts +++ b/packages/playwright-core/src/client/frame.ts @@ -32,7 +32,7 @@ import { TimeoutSettings } from './timeoutSettings'; import type { LocatorOptions } from './locator'; import type { Page } from './page'; -import type { FilePayload, LifecycleEvent, SelectOption, SelectOptionOptions, StrictOptions, TimeoutOptions, WaitForFunctionOptions } from './types'; +import type { DropPayload, FilePayload, LifecycleEvent, SelectOption, SelectOptionOptions, StrictOptions, TimeoutOptions, WaitForFunctionOptions } from './types'; import type * as structs from '../../types/structs'; import type * as api from '../../types/types'; import type { ByRoleOptions } from '@isomorphic/locatorUtils'; @@ -305,6 +305,24 @@ export class Frame extends ChannelOwner implements api.Fr return await this._channel.dragAndDrop({ source, target, ...options, timeout: this._timeout(options) }); } + async _drop(selector: string, payload: DropPayload, options: Omit & TimeoutOptions = {}) { + let fileParams: { payloads?: channels.FrameDropParams['payloads'], localPaths?: string[], streams?: channels.FrameDropParams['streams'] } = {}; + if (payload.files !== undefined) { + const converted = await convertInputFiles(this._platform, payload.files, this.page().context()); + if (converted.localDirectory || converted.directoryStream) + throw new Error('Dropping a directory is not supported — pass individual files.'); + fileParams = { payloads: converted.payloads, localPaths: converted.localPaths, streams: converted.streams }; + } + const dataArray = payload.data ? Object.entries(payload.data).map(([mimeType, value]) => ({ mimeType, value })) : undefined; + await this._channel.drop({ + selector, + ...fileParams, + data: dataArray, + ...options, + timeout: this._timeout(options), + }); + } + async tap(selector: string, options: channels.FrameTapOptions & TimeoutOptions = {}) { return await this._channel.tap({ selector, ...options, timeout: this._timeout(options) }); } diff --git a/packages/playwright-core/src/client/locator.ts b/packages/playwright-core/src/client/locator.ts index 08ba14ab73c46..9de0f2b29528e 100644 --- a/packages/playwright-core/src/client/locator.ts +++ b/packages/playwright-core/src/client/locator.ts @@ -23,7 +23,7 @@ import { ElementHandle } from './elementHandle'; import { DisposableStub } from './disposable'; import type { Frame } from './frame'; -import type { FilePayload, FrameExpectParams, Rect, SelectOption, SelectOptionOptions, TimeoutOptions } from './types'; +import type { DropPayload, FilePayload, FrameExpectParams, Rect, SelectOption, SelectOptionOptions, TimeoutOptions } from './types'; import type * as structs from '../../types/structs'; import type * as api from '../../types/types'; import type { ByRoleOptions } from '@isomorphic/locatorUtils'; @@ -126,6 +126,10 @@ export class Locator implements api.Locator { }); } + async drop(payload: DropPayload, options: Omit & TimeoutOptions = {}) { + await this._frame._drop(this._selector, payload, { strict: true, ...options }); + } + async evaluate(pageFunction: structs.PageFunctionOn, arg?: Arg, options?: TimeoutOptions): Promise { return await this._withElement(h => h.evaluate(pageFunction, arg), { title: 'Evaluate', timeout: options?.timeout }); } diff --git a/packages/playwright-core/src/client/types.ts b/packages/playwright-core/src/client/types.ts index 5977bb5da62d5..cb90ec932f987 100644 --- a/packages/playwright-core/src/client/types.ts +++ b/packages/playwright-core/src/client/types.ts @@ -35,6 +35,10 @@ export type WaitForFunctionOptions = TimeoutOptions & { polling?: 'raf' | number export type SelectOption = { value?: string, label?: string, index?: number, valueOrLabel?: string }; export type SelectOptionOptions = TimeoutOptions & { force?: boolean }; export type FilePayload = { name: string, mimeType: string, buffer: Buffer }; +export type DropPayload = { + files?: string | FilePayload | string[] | FilePayload[], + data?: { [mimeType: string]: string }, +}; export type StorageState = { cookies: channels.NetworkCookie[], origins: (Omit)[], diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index 366fe4411f092..90946df8e119d 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -1713,6 +1713,24 @@ scheme.FrameDragAndDropParams = tObject({ steps: tOptional(tInt), }); scheme.FrameDragAndDropResult = tOptional(tObject({})); +scheme.FrameDropParams = tObject({ + selector: tString, + strict: tOptional(tBoolean), + position: tOptional(tType('Point')), + payloads: tOptional(tArray(tObject({ + name: tString, + mimeType: tOptional(tString), + buffer: tBinary, + }))), + localPaths: tOptional(tArray(tString)), + streams: tOptional(tArray(tChannel(['WritableStream']))), + data: tOptional(tArray(tObject({ + mimeType: tString, + value: tString, + }))), + timeout: tFloat, +}); +scheme.FrameDropResult = tOptional(tObject({})); scheme.FrameDblclickParams = tObject({ selector: tString, strict: tOptional(tBoolean), diff --git a/packages/playwright-core/src/server/dispatchers/frameDispatcher.ts b/packages/playwright-core/src/server/dispatchers/frameDispatcher.ts index 0d476718559c4..b20a19587ad31 100644 --- a/packages/playwright-core/src/server/dispatchers/frameDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/frameDispatcher.ts @@ -156,6 +156,10 @@ export class FrameDispatcher extends Dispatcher { + return await this._frame.drop(progress, params.selector, params, params); + } + async tap(params: channels.FrameTapParams, progress: Progress): Promise { return await this._frame.tap(progress, params.selector, params); } diff --git a/packages/playwright-core/src/server/dom.ts b/packages/playwright-core/src/server/dom.ts index 347dc1bec977b..c0870c965ba88 100644 --- a/packages/playwright-core/src/server/dom.ts +++ b/packages/playwright-core/src/server/dom.ts @@ -15,6 +15,9 @@ */ import fs from 'fs'; +import path from 'path'; + +import mime from 'mime'; import { isUnderTest } from '@utils/debug'; import * as js from './javascript'; @@ -35,7 +38,7 @@ export type InputFilesItems = { localDirectory?: string }; -type ActionName = 'click' | 'hover' | 'dblclick' | 'tap' | 'move and up' | 'move and down'; +type ActionName = 'click' | 'hover' | 'dblclick' | 'tap' | 'move and up' | 'move and down' | 'drop'; type PerformActionResult = 'error:notvisible' | 'error:notconnected' | 'error:notinviewport' | 'error:optionsnotfound' | 'error:optionnotenabled' | { missingState: ElementState } | { hitTargetDescription: string } | 'done'; export class NonRecoverableDOMError extends Error { @@ -657,6 +660,73 @@ export class ElementHandle extends js.JSHandle { return assertDone(throwRetargetableDOMError(result)); } + async _drop(progress: Progress, inputFileItems: InputFilesItems, data: { mimeType: string, value: string }[], options: types.PointerActionWaitOptions): Promise<'error:notconnected' | 'done'> { + const { filePayloads, localPaths } = inputFileItems; + let payloads: { name: string, mimeType: string, buffer: string, lastModifiedMs?: number }[]; + if (localPaths && !filePayloads) { + // Co-located server/browser: read files into buffers so File objects can be + // constructed in page context. + payloads = await Promise.all(localPaths.map(async p => ({ + name: path.basename(p), + mimeType: mime.getType(p) || 'application/octet-stream', + buffer: (await fs.promises.readFile(p)).toString('base64'), + lastModifiedMs: (await fs.promises.stat(p)).mtimeMs, + }))); + } else { + payloads = (filePayloads ?? []).map(p => ({ + name: p.name, + mimeType: p.mimeType || 'application/octet-stream', + buffer: p.buffer, + lastModifiedMs: p.lastModifiedMs, + })); + } + return this._retryPointerAction(progress, 'drop', false /* waitForEnabled */, async (progress, point) => { + // Firefox strips files from DataTransfer objects that cross the isolated-world + // boundary into the page's main world. Adopt the element to main context and + // construct the DataTransfer + dispatch events there. + const mainContext = await progress.race(this._frame.mainContext()); + const handle = this._context === mainContext ? this : await progress.race(this._page.delegate.adoptElementHandle(this, mainContext)); + const disposeHandle = handle !== this; + try { + const result = await progress.race(handle.evaluate((node: Node, { payloads, data, point }) => { + if (!node.isConnected || node.nodeType !== 1 /* ELEMENT_NODE */) + return 'error:notconnected' as const; + const element = node as Element; + const dt = new DataTransfer(); + for (const p of payloads) { + const bytes = Uint8Array.from(atob(p.buffer), c => c.charCodeAt(0)); + const file = new File([bytes], p.name, { type: p.mimeType, lastModified: p.lastModifiedMs }); + dt.items.add(file); + } + for (const entry of data) + dt.setData(entry.mimeType, entry.value); + const makeEvent = (type: string) => new DragEvent(type, { + bubbles: true, + cancelable: true, + composed: true, + clientX: point.x, + clientY: point.y, + dataTransfer: dt, + }); + element.dispatchEvent(makeEvent('dragenter')); + const over = makeEvent('dragover'); + element.dispatchEvent(over); + if (!over.defaultPrevented) { + element.dispatchEvent(makeEvent('dragleave')); + return 'not-accepted' as const; + } + element.dispatchEvent(makeEvent('drop')); + return 'accepted' as const; + }, { payloads, data, point })); + if (result === 'not-accepted') + throw new NonRecoverableDOMError('Drop target did not accept the drop — its dragover handler did not call preventDefault()'); + } finally { + if (disposeHandle) + handle.dispose(); + } + }, { ...options, waitAfter: 'disabled' }); + } + async _setInputFiles(progress: Progress, items: InputFilesItems): Promise<'error:notconnected' | 'done'> { const { filePayloads, localPaths, localDirectory } = items; const multiple = filePayloads && filePayloads.length > 1 || localPaths && localPaths.length > 1; diff --git a/packages/playwright-core/src/server/frames.ts b/packages/playwright-core/src/server/frames.ts index aef7d3d1d2117..90fafe6282f98 100644 --- a/packages/playwright-core/src/server/frames.ts +++ b/packages/playwright-core/src/server/frames.ts @@ -1411,6 +1411,16 @@ export class Frame extends SdkObject { return dom.assertDone(await this._retryWithProgressIfNotConnected(progress, selector, params, (progress, handle) => handle._setInputFiles(progress, inputFileItems))); } + async drop(progress: Progress, selector: string, params: Omit, options: types.PointerActionWaitOptions): Promise { + const hasFiles = !!(params.payloads?.length || params.localPaths?.length || params.streams?.length); + const hasData = !!params.data?.length; + if (!hasFiles && !hasData) + throw new Error('At least one of "files" or "data" must be provided.'); + const inputFileItems = hasFiles ? await progress.race(prepareFilesForUpload(this, params)) : { filePayloads: undefined, localPaths: undefined }; + const data = params.data ?? []; + dom.assertDone(await this._retryWithProgressIfNotConnected(progress, selector, options, (progress, handle) => handle._drop(progress, inputFileItems, data, options))); + } + async type(progress: Progress, selector: string, text: string, options: { delay?: number, noAutoWaiting?: boolean } & types.StrictOptions) { return dom.assertDone(await this._retryWithProgressIfNotConnected(progress, selector, options, (progress, handle) => handle._type(progress, text, options))); } diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index d4e0fa0d97da9..1ed380005167a 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -13386,6 +13386,97 @@ export interface Locator { trial?: boolean; }): Promise; + /** + * Simulate an external drag-and-drop of files or clipboard-like data onto this locator. + * + * **Details** + * + * Dispatches the native `dragenter`, `dragover`, and `drop` events at the center of the target element with a + * synthetic [DataTransfer] carrying the provided files and/or data entries. Works cross-browser by constructing the + * [DataTransfer] in the page context. + * + * If the target element's `dragover` listener does not call `preventDefault()`, the target is considered to have + * rejected the drop: Playwright dispatches `dragleave` and this method throws. + * + * **Usage** + * + * Drop a file buffer onto an upload area: + * + * ```js + * await page.locator('#dropzone').drop({ + * files: { name: 'note.txt', mimeType: 'text/plain', buffer: Buffer.from('hello') }, + * }); + * ``` + * + * Drop plain text and a URL together: + * + * ```js + * await page.locator('#dropzone').drop({ + * data: { + * 'text/plain': 'hello world', + * 'text/uri-list': 'https://example.com', + * }, + * }); + * ``` + * + * @param payload Data to drop onto the target. Provide `files` (file paths or in-memory buffers), `data` (a mime-type → string map + * for clipboard-like content such as `text/plain`, `text/html`, `text/uri-list`), or both. + * @param options + */ + drop(payload: { + files?: string|Array|{ + /** + * File name + */ + name: string; + + /** + * File type + */ + mimeType: string; + + /** + * File content + */ + buffer: Buffer; + }|Array<{ + /** + * File name + */ + name: string; + + /** + * File type + */ + mimeType: string; + + /** + * File content + */ + buffer: Buffer; + }>; + + data?: { [key: string]: string; }; + }, options?: { + /** + * A point to use relative to the top-left corner of element padding box. If not specified, uses some visible point of + * the element. + */ + position?: { + x: number; + + y: number; + }; + + /** + * Maximum time in milliseconds. Defaults to `0` - no timeout. The default value can be changed via `actionTimeout` + * option in the config, or by using the + * [browserContext.setDefaultTimeout(timeout)](https://playwright.dev/docs/api/class-browsercontext#browser-context-set-default-timeout) + * or [page.setDefaultTimeout(timeout)](https://playwright.dev/docs/api/class-page#page-set-default-timeout) methods. + */ + timeout?: number; + }): Promise; + /** * **NOTE** Always prefer using [Locator](https://playwright.dev/docs/api/class-locator)s and web assertions over * [ElementHandle](https://playwright.dev/docs/api/class-elementhandle)s because latter are inherently racy. diff --git a/packages/protocol/src/channels.d.ts b/packages/protocol/src/channels.d.ts index 41566f45a2fbb..9f802a5191a61 100644 --- a/packages/protocol/src/channels.d.ts +++ b/packages/protocol/src/channels.d.ts @@ -2813,6 +2813,7 @@ export interface FrameChannel extends FrameEventTarget, Channel { click(params: FrameClickParams, progress?: Progress): Promise; content(params?: FrameContentParams, progress?: Progress): Promise; dragAndDrop(params: FrameDragAndDropParams, progress?: Progress): Promise; + drop(params: FrameDropParams, progress?: Progress): Promise; dblclick(params: FrameDblclickParams, progress?: Progress): Promise; dispatchEvent(params: FrameDispatchEventParams, progress?: Progress): Promise; evaluateExpression(params: FrameEvaluateExpressionParams, progress?: Progress): Promise; @@ -3016,6 +3017,39 @@ export type FrameDragAndDropOptions = { steps?: number, }; export type FrameDragAndDropResult = void; +export type FrameDropParams = { + selector: string, + strict?: boolean, + position?: Point, + payloads?: { + name: string, + mimeType?: string, + buffer: Binary, + }[], + localPaths?: string[], + streams?: WritableStreamChannel[], + data?: { + mimeType: string, + value: string, + }[], + timeout: number, +}; +export type FrameDropOptions = { + strict?: boolean, + position?: Point, + payloads?: { + name: string, + mimeType?: string, + buffer: Binary, + }[], + localPaths?: string[], + streams?: WritableStreamChannel[], + data?: { + mimeType: string, + value: string, + }[], +}; +export type FrameDropResult = void; export type FrameDblclickParams = { selector: string, strict?: boolean, diff --git a/packages/protocol/src/protocol.yml b/packages/protocol/src/protocol.yml index 949843f8f879e..fb5f4e6f485a7 100644 --- a/packages/protocol/src/protocol.yml +++ b/packages/protocol/src/protocol.yml @@ -2421,6 +2421,42 @@ Frame: input: true isAutoWaiting: true + drop: + title: Drop files or data onto an element + parameters: + selector: string + strict: boolean? + position: Point? + # Only one of payloads, localPaths and streams may be present. + payloads: + type: array? + items: + type: object + properties: + name: string + mimeType: string? + buffer: binary + localPaths: + type: array? + items: string + streams: + type: array? + items: WritableStream + data: + type: array? + items: + type: object + properties: + mimeType: string + value: string + timeout: float + flags: + slowMo: true + snapshot: true + pause: true + input: true + isAutoWaiting: true + dblclick: title: Double click parameters: diff --git a/tests/page/page-drop.spec.ts b/tests/page/page-drop.spec.ts new file mode 100644 index 0000000000000..32e549251266a --- /dev/null +++ b/tests/page/page-drop.spec.ts @@ -0,0 +1,120 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import fs from 'fs'; + +import { test as it, expect } from './pageTest'; + +it.skip(({ isAndroid }) => isAndroid, 'No drag&drop on Android.'); + +async function setupDropzone(page: import('playwright-core').Page) { + await page.setContent(` + +
+ + `); +} + +it('should drop a file payload', async ({ page }) => { + await setupDropzone(page); + await page.locator('#dropzone').drop({ + files: { name: 'note.txt', mimeType: 'text/plain', buffer: Buffer.from('hello') }, + }); + await expect.poll(() => page.evaluate(() => (window as any).__dropInfo)).toEqual({ + files: [{ name: 'note.txt', type: 'text/plain', size: 5, text: 'hello' }], + data: {}, + }); +}); + +it('should drop multiple file payloads', async ({ page }) => { + await setupDropzone(page); + await page.locator('#dropzone').drop({ + files: [ + { name: 'a.txt', mimeType: 'text/plain', buffer: Buffer.from('AAA') }, + { name: 'b.txt', mimeType: 'text/plain', buffer: Buffer.from('BB') }, + ], + }); + const info = await page.evaluate(() => (window as any).__dropInfo); + expect(info.files.map((f: any) => [f.name, f.text])).toEqual([['a.txt', 'AAA'], ['b.txt', 'BB']]); +}); + +it('should drop a file by local path', async ({ page }, testInfo) => { + await setupDropzone(page); + const filePath = testInfo.outputPath('hello.txt'); + await fs.promises.writeFile(filePath, 'path-content'); + await page.locator('#dropzone').drop({ files: filePath }); + const info = await page.evaluate(() => (window as any).__dropInfo); + expect(info.files).toHaveLength(1); + expect(info.files[0].name).toBe('hello.txt'); + expect(info.files[0].text).toBe('path-content'); +}); + +it('should drop clipboard-like data', async ({ page }) => { + await setupDropzone(page); + await page.locator('#dropzone').drop({ + data: { + 'text/plain': 'hello world', + 'text/uri-list': 'https://example.com', + }, + }); + const info = await page.evaluate(() => (window as any).__dropInfo); + expect(info.files).toEqual([]); + expect(info.data['text/plain']).toBe('hello world'); + expect(info.data['text/uri-list']).toBe('https://example.com'); +}); + +it('should drop files and data together', async ({ page }) => { + await setupDropzone(page); + await page.locator('#dropzone').drop({ + files: { name: 'mix.txt', mimeType: 'text/plain', buffer: Buffer.from('mix') }, + data: { 'text/plain': 'label' }, + }); + const info = await page.evaluate(() => (window as any).__dropInfo); + expect(info.files[0].text).toBe('mix'); + expect(info.data['text/plain']).toBe('label'); +}); + +it('should throw when target does not accept drop', async ({ page }) => { + // Dropzone without preventDefault on dragover. + await page.setContent(` +
+ `); + await expect(page.locator('#dropzone').drop({ + data: { 'text/plain': 'nope' }, + })).rejects.toThrow(/drop target did not accept the drop/i); +}); + +it('should throw when neither files nor data provided', async ({ page }) => { + await setupDropzone(page); + await expect(page.locator('#dropzone').drop({})).rejects.toThrow(/At least one of "files" or "data"/); +});