Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions docs/src/api/class-locator.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

at the center is wrong when position is passed. I'd recommend inside the target element instead.

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.
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
entries. Works cross-browser by constructing the [DataTransfer] in the page context.
entries.


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.
Expand Down
12 changes: 12 additions & 0 deletions docs/src/api/params.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,18 @@ Defaults to `left`.
- `mimeType` <[string]> File type
- `buffer` <[Buffer]> File content

## drop-payload
- `payload` <[Object]>
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add a name alias

- `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]>

Expand Down
1 change: 1 addition & 0 deletions packages/isomorphic/protocolMetainfo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ export const methodMetainfo = new Map<string, MethodMetainfo>([
['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, }],
Expand Down
91 changes: 91 additions & 0 deletions packages/playwright-client/types/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13386,6 +13386,97 @@ export interface Locator {
trial?: boolean;
}): Promise<void>;

/**
* 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<string>|{
/**
* 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<void>;

/**
* **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.
Expand Down
20 changes: 19 additions & 1 deletion packages/playwright-core/src/client/frame.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -305,6 +305,24 @@ export class Frame extends ChannelOwner<channels.FrameChannel> implements api.Fr
return await this._channel.dragAndDrop({ source, target, ...options, timeout: this._timeout(options) });
}

async _drop(selector: string, payload: DropPayload, options: Omit<channels.FrameDropOptions, 'payloads' | 'localPaths' | 'streams' | 'data'> & 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) });
}
Expand Down
6 changes: 5 additions & 1 deletion packages/playwright-core/src/client/locator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -126,6 +126,10 @@ export class Locator implements api.Locator {
});
}

async drop(payload: DropPayload, options: Omit<channels.FrameDropOptions, 'payloads' | 'localPaths' | 'streams' | 'data' | 'force' | 'trial'> & TimeoutOptions = {}) {
await this._frame._drop(this._selector, payload, { strict: true, ...options });
}

async evaluate<R, Arg>(pageFunction: structs.PageFunctionOn<SVGElement | HTMLElement, Arg, R>, arg?: Arg, options?: TimeoutOptions): Promise<R> {
return await this._withElement(h => h.evaluate(pageFunction, arg), { title: 'Evaluate', timeout: options?.timeout });
}
Expand Down
4 changes: 4 additions & 0 deletions packages/playwright-core/src/client/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<channels.OriginStorage, 'indexedDB'>)[],
Expand Down
18 changes: 18 additions & 0 deletions packages/playwright-core/src/protocol/validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,10 @@ export class FrameDispatcher extends Dispatcher<Frame, channels.FrameChannel, Br
return await this._frame.dragAndDrop(progress, params.source, params.target, params);
}

async drop(params: channels.FrameDropParams, progress: Progress): Promise<void> {
return await this._frame.drop(progress, params.selector, params, params);
}

async tap(params: channels.FrameTapParams, progress: Progress): Promise<void> {
return await this._frame.tap(progress, params.selector, params);
}
Expand Down
72 changes: 71 additions & 1 deletion packages/playwright-core/src/server/dom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 {
Expand Down Expand Up @@ -657,6 +660,73 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
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;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why don't we reuse prepareFilesForUpload?

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,
}));
}
Comment on lines +666 to +682
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we instead force prepareFilesForUpload to read files into payloads right away?

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 */)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we please extract this large evaluated function into InjectedScript.drop()?

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;
Expand Down
10 changes: 10 additions & 0 deletions packages/playwright-core/src/server/frames.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1411,6 +1411,16 @@ export class Frame extends SdkObject<FrameEventMap> {
return dom.assertDone(await this._retryWithProgressIfNotConnected(progress, selector, params, (progress, handle) => handle._setInputFiles(progress, inputFileItems)));
}

async drop(progress: Progress, selector: string, params: Omit<channels.FrameDropParams, 'timeout' | 'selector'>, options: types.PointerActionWaitOptions): Promise<void> {
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)));
}
Expand Down
Loading
Loading