diff --git a/src/server/chromium/crDragDrop.ts b/src/server/chromium/crDragDrop.ts new file mode 100644 index 00000000000000..3e2a3403a2c87b --- /dev/null +++ b/src/server/chromium/crDragDrop.ts @@ -0,0 +1,301 @@ +/** + * 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 { CRPage } from './crPage'; +import * as types from '../types'; +import * as dragScriptSource from '../../generated/dragScriptSource'; +import { assert } from '../../utils/utils'; + +export class DragManager { + private _page: CRPage; + private _setup = false; + private _dragState: { + data: DataTransferJSON, + x: number, + y: number + } | null = null; + constructor(page: CRPage) { + this._page = page; + } + + async _setupIfNeeded() { + if (this._setup) + return false; + const page = this._page; + await page.exposeInternalBinding('dragStarted', (source, data: DataTransferJSON) => { + this._dragState = { + x: NaN, + y: NaN, + data + }; + }); + await page.evaluateOnNewDocument(dragScriptSource.source, 'utility'); + await Promise.all(page._page.frames().map(frame => frame._evaluateExpression(dragScriptSource.source, false, {}, 'utility').catch(e => {}))); + } + + async cancel() { + if (!this._dragState) + return false; + await this._dispatchDragEvent('dragend'); + this._dragState = null; + return true; + } + + async move(x: number, y: number, button: types.MouseButton | 'none', buttons: Set, modifiers: Set, moveCallback: () => Promise): Promise { + if (this._dragState) { + await this._moveDrag(x, y); + return; + } + if (button !== 'left') + return moveCallback(); + + await this._setupIfNeeded(); + await moveCallback(); + // thread the renderer to wait for the drag callback + await this._page._page.mainFrame()._evaluateExpression('', false, null); + if (this._dragState) { + this._dragState!.x = x; + this._dragState!.y = y; + } + } + async _moveDrag(x: number, y: number) { + assert(this._dragState, 'missing drag state'); + if (x === this._dragState.x && y === this._dragState.y) + return; + this._dragState.x = x; + this._dragState.y = y; + await this._dispatchDragEvent('dragover'); + } + + isDragging() { + return !!this._dragState; + } + + async down(x: number, y: number, button: types.MouseButton | 'none', buttons: Set, modifiers: Set): Promise { + return !!this._dragState; + } + + async up(x: number, y: number, button: types.MouseButton, buttons: Set, modifiers: Set) { + // await this._moveDrag(x, y); + assert(this._dragState, 'missing drag state'); + this._dragState.x = x; + this._dragState.y = y; + await this._dispatchDragEvent('drop'); + this._dragState = null; + } + + async _dispatchDragEvent(type: 'dragover'|'drop'|'dragend') { + assert(this._dragState, 'missing drag state'); + const {backendNodeId, frameId} = await this._page._mainFrameSession._client.send('DOM.getNodeForLocation', { + x: Math.round(this._dragState.x), + y: Math.round(this._dragState.y), + ignorePointerEventsNone: false, + includeUserAgentShadowDOM: false + }); + const frame = this._page._page.frames().find(x => x._id === frameId)!; + + const context = await frame._utilityContext(); + const elementHandle = await this._page._mainFrameSession._adoptBackendNodeId(backendNodeId, context); + // console.log(elementHandle); + return await elementHandle.evaluate(dispatchDragEvent, { + json: type === 'dragend' ? null : this._dragState.data, + type, + x: this._dragState.x, + y: this._dragState.y, + }); + } +} + + +async function dispatchDragEvent(element: Node, {type, x, y, json}: {type: 'dragover'|'drop'|'dragend', x: number, y: number, json: DataTransferJSON|null}) { + const node = element; // document.elementFromPoint(x, y); + if (!node) + throw new Error(`could not find node at (${x},${y})`); + const dataTransfer = jsonToDataTransfer(json); + const lastDragNode = (window as any).__lastDragNode as Node; + if (lastDragNode !== node) { + if (node) { + node.dispatchEvent(new DragEvent('dragenter', { + dataTransfer, + bubbles: true, + cancelable: false + })); + } + if (lastDragNode) { + lastDragNode.dispatchEvent(new DragEvent('dragleave', { + dataTransfer, + bubbles: true, + cancelable: false + })); + } + (window as any).__lastDragNode = node; + } + const dragOverEvent = new DragEvent('dragover', { + dataTransfer, + bubbles: true, + cancelable: true + }); + + node.dispatchEvent(dragOverEvent); + if (type === 'dragend') { + endDrag(); + return; + } + if (type === 'dragover') return; + + // TODO(einbinder) This should check if the effect is allowed + // by the DataTransfer, but currently the user can't set the effect. + if (dragOverEvent.defaultPrevented) { + const dropEvent = new DragEvent('drop', { + dataTransfer, + bubbles: true, + cancelable: true + }); + node.dispatchEvent(dropEvent); + if (dropEvent.defaultPrevented) { + endDrag(); + return; + } + } + doDefaultDrop(); + endDrag(); + + function endDrag() { + if ((window as any).__draggingElement) { + const draggingElement: Element = (window as any).__draggingElement; + delete (window as any).__draggingElement; + draggingElement.dispatchEvent(new DragEvent('dragend', { + dataTransfer: new DataTransfer(), + bubbles: true, + cancelable: false + })); + } + } + + function doDefaultDrop() { + const htmlItem = json!.items.find(x => x.type === 'text/html'); + const textItem = json!.items.find(x => x.type.startsWith('text/')); + if (!htmlItem && !textItem) + return; + // const html = document.createElement('') + const editableTarget = editableAncestor(node); + if (!editableTarget) + return; + editableTarget.focus(); + if (htmlItem) + document.execCommand('insertHTML', false, itemToString(htmlItem)); + else if (textItem) + document.execCommand('insertText', false, itemToString(textItem)); + } + + function jsonToDataTransfer(json: DataTransferJSON|null): DataTransfer { + const transfer = new DataTransfer(); + // Chromium doesn't allow setting these properties on a DataTransfer that wasn't + // specifically created for drag and drop. Redefining them lets us set them, + // but its mostly futile because this doesn't effect the main execution context. + Object.defineProperty(transfer, 'effectAllowed', {value: transfer.effectAllowed, writable: true}); + Object.defineProperty(transfer, 'dropEffect', {value: transfer.dropEffect, writable: true}); + if (json) { + for (const {data, type} of json.items) { + if (typeof data === 'string') + transfer.items.add(data, type); + else + transfer.items.add(jsonToFile(data)); + } + transfer.effectAllowed = json.effectAllowed; + transfer.dropEffect = json.dropEffect; + } + return transfer; + } + + + function binaryDataURLToString(url: string) { + return atob(url.slice(url.indexOf(',') + 1)); + } + + function jsonToFile(json: FileJSON): File { + const data = atob(binaryDataURLToString(json.dataURL)); + const file = new File([data], json.name, { + lastModified: json.lastModified, + type: json.type, + }); + return file; + } + + function itemToString({data}: ItemJSON) { + if (typeof data === 'string') + return data; + return binaryDataURLToString(data.dataURL); + } + + function editableAncestor(node: Node|null): HTMLElement|null { + if (!node) + return null; + if (isEditable(node)) + return node as HTMLElement; + return editableAncestor(node.parentElement); + } + + function isEditable(node: Node) { + if (node.nodeType !== Node.ELEMENT_NODE) + return false; + const element = node as Element; + if (element.nodeName.toLowerCase() === 'input') { + const input = element as HTMLInputElement; + const type = (input.getAttribute('type') || '').toLowerCase(); + const kDateTypes = new Set(['date', 'time', 'datetime', 'datetime-local']); + const kTextInputTypes = new Set(['', 'email', 'number', 'password', 'search', 'tel', 'text', 'url']); + if (!kTextInputTypes.has(type) && !kDateTypes.has(type)) + return false; + if (input.disabled) + return false; + if (input.readOnly) + return false; + return true; + } else if (element.nodeName.toLowerCase() === 'textarea') { + const textarea = element as HTMLTextAreaElement; + if (textarea.disabled) + return false; + if (textarea.readOnly) + return false; + } else if (!(element as HTMLElement).isContentEditable) { + return false; + } + return true; + } + +} + +type DataTransferJSON = { + dropEffect: string; + effectAllowed: string; + items: ItemJSON[]; +}; + +type FileJSON = { + lastModified: number; + name: string; + dataURL: string; + type: string; + // only exists for electron + path?: string; +}; + +type ItemJSON = { + data: string|FileJSON; + kind: string; + type: string; +}; + diff --git a/src/server/chromium/crInput.ts b/src/server/chromium/crInput.ts index 44d39bb8e65234..1fa2e5c2d6c1d1 100644 --- a/src/server/chromium/crInput.ts +++ b/src/server/chromium/crInput.ts @@ -20,6 +20,8 @@ import * as types from '../types'; import { CRSession } from './crConnection'; import { macEditingCommands } from '../macEditingCommands'; import { isString } from '../../utils/utils'; +import { DragManager } from './crDragDrop'; +import { CRPage } from './crPage'; function toModifiersMask(modifiers: Set): number { let mask = 0; @@ -38,6 +40,7 @@ export class RawKeyboardImpl implements input.RawKeyboard { constructor( private _client: CRSession, private _isMac: boolean, + private _dragManger: DragManager, ) { } _commandsForCode(code: string, modifiers: Set) { @@ -60,6 +63,8 @@ export class RawKeyboardImpl implements input.RawKeyboard { } async keydown(modifiers: Set, code: string, keyCode: number, keyCodeWithoutLocation: number, key: string, location: number, autoRepeat: boolean, text: string | undefined): Promise { + if (code === 'Escape' && await this._dragManger.cancel()) + return; const commands = this._commandsForCode(code, modifiers); await this._client.send('Input.dispatchKeyEvent', { type: text ? 'keyDown' : 'rawKeyDown', @@ -94,22 +99,30 @@ export class RawKeyboardImpl implements input.RawKeyboard { export class RawMouseImpl implements input.RawMouse { private _client: CRSession; + private _page: CRPage; + private _dragManager: DragManager; - constructor(client: CRSession) { + constructor(page: CRPage, client: CRSession, dragManager: DragManager) { + this._page = page; this._client = client; + this._dragManager = dragManager; } async move(x: number, y: number, button: types.MouseButton | 'none', buttons: Set, modifiers: Set): Promise { - await this._client.send('Input.dispatchMouseEvent', { - type: 'mouseMoved', - button, - x, - y, - modifiers: toModifiersMask(modifiers) + await this._dragManager.move(x, y, button, buttons, modifiers, async () => { + await this._client.send('Input.dispatchMouseEvent', { + type: 'mouseMoved', + button, + x, + y, + modifiers: toModifiersMask(modifiers) + }); }); } async down(x: number, y: number, button: types.MouseButton, buttons: Set, modifiers: Set, clickCount: number): Promise { + if (this._dragManager.isDragging()) + return; await this._client.send('Input.dispatchMouseEvent', { type: 'mousePressed', button, @@ -121,6 +134,10 @@ export class RawMouseImpl implements input.RawMouse { } async up(x: number, y: number, button: types.MouseButton, buttons: Set, modifiers: Set, clickCount: number): Promise { + if (this._dragManager.isDragging()) { + await this._dragManager.up(x, y, button, buttons, modifiers); + return; + } await this._client.send('Input.dispatchMouseEvent', { type: 'mouseReleased', button, diff --git a/src/server/chromium/crPage.ts b/src/server/chromium/crPage.ts index d5fb77ae40f2df..797f016ea78df9 100644 --- a/src/server/chromium/crPage.ts +++ b/src/server/chromium/crPage.ts @@ -39,6 +39,7 @@ import * as sourceMap from '../../utils/sourceMap'; import { rewriteErrorMessage } from '../../utils/stackTrace'; import { assert, headersArrayToObject, createGuid } from '../../utils/utils'; import { VideoRecorder } from './videoRecorder'; +import { DragManager } from './crDragDrop'; const UTILITY_WORLD_NAME = '__playwright_utility_world__'; @@ -56,6 +57,7 @@ export class CRPage implements PageDelegate { private readonly _coverage: CRCoverage; readonly _browserContext: CRBrowserContext; private readonly _pagePromise: Promise; + readonly _internalBindings = new Map(); _initializedPage: Page | null = null; // Holds window features for the next popup being opened via window.open, @@ -68,8 +70,9 @@ export class CRPage implements PageDelegate { constructor(client: CRSession, targetId: string, browserContext: CRBrowserContext, opener: CRPage | null, hasUIWindow: boolean) { this._targetId = targetId; this._opener = opener; - this.rawKeyboard = new RawKeyboardImpl(client, browserContext._browser._isMac); - this.rawMouse = new RawMouseImpl(client); + const dragManager = new DragManager(this); + this.rawKeyboard = new RawKeyboardImpl(client, browserContext._browser._isMac, dragManager); + this.rawMouse = new RawMouseImpl(this, client, dragManager); this.rawTouchscreen = new RawTouchscreenImpl(client); this._pdf = new CRPDF(client); this._coverage = new CRCoverage(client); @@ -127,10 +130,17 @@ export class CRPage implements PageDelegate { } async exposeBinding(binding: PageBinding) { - await this._forAllFrameSessions(frame => frame._initBinding(binding)); + await this._forAllFrameSessions(frame => frame._initBinding(binding, 'main')); await Promise.all(this._page.frames().map(frame => frame._evaluateExpression(binding.source, false, {}).catch(e => {}))); } + async exposeInternalBinding(name: string, callback: frames.FunctionWithSource) { + const binding = new PageBinding(name, callback, false); + this._internalBindings.set(name, binding); + await this._forAllFrameSessions(frame => frame._initBinding(binding, 'utility')); + await Promise.all(this._page.frames().map(frame => frame._evaluateExpression(binding.source, false, {}, 'utility').catch(e => {}))); + } + async updateExtraHTTPHeaders(): Promise { await this._forAllFrameSessions(frame => frame._updateExtraHTTPHeaders(false)); } @@ -198,8 +208,8 @@ export class CRPage implements PageDelegate { return this._go(+1); } - async evaluateOnNewDocument(source: string): Promise { - await this._forAllFrameSessions(frame => frame._evaluateOnNewDocument(source)); + async evaluateOnNewDocument(source: string, world: types.World = 'main'): Promise { + await this._forAllFrameSessions(frame => frame._evaluateOnNewDocument(source, world)); } async closePage(runBeforeUnload: boolean): Promise { @@ -405,6 +415,8 @@ class FrameSession { grantUniveralAccess: true, worldName: UTILITY_WORLD_NAME, }); + for (const binding of this._crPage._internalBindings.values()) + frame._evaluateExpression(binding.source, false, {}, 'utility').catch(e => {}); for (const binding of this._crPage._browserContext._pageBindings.values()) frame._evaluateExpression(binding.source, false, {}).catch(e => {}); } @@ -455,14 +467,16 @@ class FrameSession { promises.push(this._updateOffline(true)); promises.push(this._updateHttpCredentials(true)); promises.push(this._updateEmulateMedia(true)); + for (const binding of this._crPage._internalBindings.values()) + promises.push(this._initBinding(binding, 'utility')); for (const binding of this._crPage._browserContext._pageBindings.values()) - promises.push(this._initBinding(binding)); + promises.push(this._initBinding(binding, 'main')); for (const binding of this._crPage._page._pageBindings.values()) - promises.push(this._initBinding(binding)); + promises.push(this._initBinding(binding, 'main')); for (const source of this._crPage._browserContext._evaluateOnNewDocumentSources) - promises.push(this._evaluateOnNewDocument(source)); + promises.push(this._evaluateOnNewDocument(source, 'main')); for (const source of this._crPage._page._evaluateOnNewDocumentSources) - promises.push(this._evaluateOnNewDocument(source)); + promises.push(this._evaluateOnNewDocument(source, 'main')); if (this._isMainFrame() && this._crPage._browserContext._options.recordVideo) { const size = this._crPage._browserContext._options.recordVideo.size || this._crPage._browserContext._options.viewport || { width: 1280, height: 720 }; const screencastId = createGuid(); @@ -682,18 +696,24 @@ class FrameSession { this._page._addConsoleMessage(event.type, values, toConsoleMessageLocation(event.stackTrace)); } - async _initBinding(binding: PageBinding) { + async _initBinding(binding: PageBinding, world: types.World) { + const worldName = world === 'utility' ? UTILITY_WORLD_NAME : undefined; await Promise.all([ - this._client.send('Runtime.addBinding', { name: binding.name }), - this._client.send('Page.addScriptToEvaluateOnNewDocument', { source: binding.source }) + this._client.send('Runtime.addBinding', { name: binding.name, executionContextName: worldName }), + this._client.send('Page.addScriptToEvaluateOnNewDocument', { source: binding.source, worldName }) ]); } async _onBindingCalled(event: Protocol.Runtime.bindingCalledPayload) { const context = this._contextIdToContext.get(event.executionContextId)!; const pageOrError = await this._crPage.pageOrError(); - if (!(pageOrError instanceof Error)) - this._page._onBindingCalled(event.payload, context); + if (pageOrError instanceof Error) + return; + const internalBinding = this._crPage._internalBindings.get(event.name); + if (internalBinding && await context.frame._utilityContext() === context) + await internalBinding.dispatch(pageOrError, event.payload, context); + else + await this._page._onBindingCalled(event.payload, context); } _onDialog(event: Protocol.Page.javascriptDialogOpeningPayload) { @@ -877,8 +897,8 @@ class FrameSession { await this._client.send('Page.setInterceptFileChooserDialog', { enabled }).catch(e => {}); // target can be closed. } - async _evaluateOnNewDocument(source: string): Promise { - await this._client.send('Page.addScriptToEvaluateOnNewDocument', { source }); + async _evaluateOnNewDocument(source: string, world: types.World): Promise { + await this._client.send('Page.addScriptToEvaluateOnNewDocument', { source, worldName: world === 'utility' ? UTILITY_WORLD_NAME : undefined }); } async _getContentFrame(handle: dom.ElementHandle): Promise { diff --git a/src/server/injected/dragScript.ts b/src/server/injected/dragScript.ts new file mode 100644 index 00000000000000..3d063567ad0eb3 --- /dev/null +++ b/src/server/injected/dragScript.ts @@ -0,0 +1,115 @@ +/** + * 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. + */ + +type DataTransferJSON = { + dropEffect: string; + effectAllowed: string; + items: ItemJSON[]; +}; + +type FileJSON = { + lastModified: number; + name: string; + dataURL: string; + type: string; + // only exists for electron + path?: string; +}; + +type ItemJSON = { + data: string|FileJSON; + kind: string; + type: string; +}; + +// This is a binding added by Playwright. See crDragDrop.ts +declare function dragStarted(json: DataTransferJSON): Promise; + +window.addEventListener('dragstart', async originalDrag => { + if (originalDrag.defaultPrevented || !originalDrag.isTrusted) + return; + // cancel this drag, and create our own in order to track it + originalDrag.preventDefault(); + originalDrag.stopImmediatePropagation(); + const drag = new DragEvent(originalDrag.type, { + altKey: originalDrag.altKey, + bubbles: originalDrag.bubbles, + button: originalDrag.button, + buttons: originalDrag.buttons, + cancelable: originalDrag.cancelable, + clientX: originalDrag.clientX, + clientY: originalDrag.clientY, + composed: originalDrag.composed, + ctrlKey: originalDrag.ctrlKey, + dataTransfer: originalDrag.dataTransfer, + detail: originalDrag.detail, + metaKey: originalDrag.metaKey, + modifierAltGraph: originalDrag.getModifierState('AltGraph'), + modifierCapsLock: originalDrag.getModifierState('CapsLock'), + movementX: originalDrag.movementX, + movementY: originalDrag.movementY + }); + const path = originalDrag.composedPath(); + if (!path.length) + return; + path[0].dispatchEvent(drag); + if (!drag.dataTransfer || drag.defaultPrevented) + return; + const json = await dataTransferToJSON(drag.dataTransfer); + (window as any).__draggingElement = path[0]; + dragStarted(json); +}, true); + + +async function dataTransferToJSON(dataTransfer: DataTransfer): Promise { + // const files = await Promise.all([...dataTransfer.files].map(fileToJSON)); + const items = await Promise.all([...dataTransfer.items].map(async item => { + let data: string|FileJSON; + // store the item data before it disapears next tick + const {type, kind} = item; + if (kind === 'file') + data = await fileToJSON(item.getAsFile()!); + else + data = await new Promise(x => item.getAsString(x)); + return { + kind, + type, + data, + } as ItemJSON; + })); + return { + dropEffect: dataTransfer.dropEffect, + effectAllowed: dataTransfer.effectAllowed, + // files, + items, + // types: dataTransfer.types, + }; +} + +async function fileToJSON(file: File): Promise { + const buffer = await file.arrayBuffer(); + btoa(new Uint8Array(buffer).toString()); + const reader = new FileReader(); + const promise = new Promise(x => reader.onload = x); + reader.readAsDataURL(file); + await promise; + return { + lastModified: file.lastModified, + name: file.name, + type: file.type, + dataURL: reader.result as string + }; +} diff --git a/src/server/injected/dragScript.webpack.config.js b/src/server/injected/dragScript.webpack.config.js new file mode 100644 index 00000000000000..85a8fd5975385d --- /dev/null +++ b/src/server/injected/dragScript.webpack.config.js @@ -0,0 +1,46 @@ +/** + * 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. + */ + +const path = require('path'); +const InlineSource = require('./webpack-inline-source-plugin.js'); + +module.exports = { + entry: path.join(__dirname, 'dragScript.ts'), + devtool: 'source-map', + module: { + rules: [ + { + test: /\.tsx?$/, + loader: 'ts-loader', + options: { + transpileOnly: true + }, + exclude: /node_modules/ + } + ] + }, + resolve: { + extensions: [ '.tsx', '.ts', '.js' ] + }, + output: { + libraryTarget: 'var', + filename: 'dragScriptSource.js', + path: path.resolve(__dirname, '../../../lib/server/injected/packed') + }, + plugins: [ + new InlineSource(path.join(__dirname, '..', '..', 'generated', 'dragScriptSource.ts')), + ] +}; diff --git a/src/server/page.ts b/src/server/page.ts index 35cbad687aff38..a6445f427372dd 100644 --- a/src/server/page.ts +++ b/src/server/page.ts @@ -512,37 +512,41 @@ export class PageBinding { this.needsHandle = needsHandle; } - static async dispatch(page: Page, payload: string, context: dom.FrameExecutionContext) { + async dispatch(page: Page, payload: string, context: dom.FrameExecutionContext) { const {name, seq, args} = JSON.parse(payload); - try { - let binding = page._pageBindings.get(name); - if (!binding) - binding = page._browserContext._pageBindings.get(name); - let result: any; - if (binding!.needsHandle) { - const handle = await context.evaluateHandleInternal(takeHandle, { name, seq }).catch(e => null); - result = await binding!.playwrightFunction({ frame: context.frame, page, context: page._browserContext }, handle); - } else { - result = await binding!.playwrightFunction({ frame: context.frame, page, context: page._browserContext }, ...args); - } - context.evaluateInternal(deliverResult, { name, seq, result }).catch(e => debugLogger.log('error', e)); - } catch (error) { - if (isError(error)) - context.evaluateInternal(deliverError, { name, seq, message: error.message, stack: error.stack }).catch(e => debugLogger.log('error', e)); - else - context.evaluateInternal(deliverErrorValue, { name, seq, error }).catch(e => debugLogger.log('error', e)); + let result: any; + if (this.needsHandle) { + const handle = await context.evaluateHandleInternal(takeHandle, { name, seq }).catch(e => null); + result = await this.playwrightFunction({ frame: context.frame, page, context: page._browserContext }, handle); + } else { + result = await this.playwrightFunction({ frame: context.frame, page, context: page._browserContext }, ...args); } + context.evaluateInternal(deliverResult, { name, seq, result }).catch(e => debugLogger.log('error', e)); function takeHandle(arg: { name: string, seq: number }) { const handle = (window as any)[arg.name]['handles'].get(arg.seq); (window as any)[arg.name]['handles'].delete(arg.seq); return handle; } - function deliverResult(arg: { name: string, seq: number, result: any }) { (window as any)[arg.name]['callbacks'].get(arg.seq).resolve(arg.result); (window as any)[arg.name]['callbacks'].delete(arg.seq); } + } + + static async dispatch(page: Page, payload: string, context: dom.FrameExecutionContext) { + const {name, seq} = JSON.parse(payload); + try { + let binding = page._pageBindings.get(name); + if (!binding) + binding = page._browserContext._pageBindings.get(name); + await binding!.dispatch(page, payload, context); + } catch (error) { + if (isError(error)) + context.evaluateInternal(deliverError, { name, seq, message: error.message, stack: error.stack }).catch(e => debugLogger.log('error', e)); + else + context.evaluateInternal(deliverErrorValue, { name, seq, error }).catch(e => debugLogger.log('error', e)); + } function deliverError(arg: { name: string, seq: number, message: string, stack: string | undefined }) { const error = new Error(arg.message); diff --git a/test/assets/drag-n-drop.html b/test/assets/drag-n-drop.html index 8189c492577b3a..f43583e7c98d4e 100644 --- a/test/assets/drag-n-drop.html +++ b/test/assets/drag-n-drop.html @@ -28,7 +28,6 @@ ev.preventDefault(); var data = ev.dataTransfer.getData("text"); ev.target.appendChild(document.getElementById(data)); - ev.dataTransfer.clearData(); } diff --git a/test/drag.spec.ts b/test/drag.spec.ts new file mode 100644 index 00000000000000..be36ffbd5514cb --- /dev/null +++ b/test/drag.spec.ts @@ -0,0 +1,310 @@ +/** + * 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 { ElementHandle } from '..'; +import { it, expect, describe } from './fixtures'; +import { attachFrame } from './utils'; + +describe('Drag and drop', (test, {browserName}) => { + test.fixme(browserName !== 'chromium'); +}, () => { + it('should work', async ({server, page, context}) => { + await page.goto(server.PREFIX + '/drag-n-drop.html'); + await page.hover('#source'); + await page.mouse.down(); + await page.hover('#target'); + await page.mouse.up(); + expect(await page.$eval('#target', target => target.contains(document.querySelector('#source')))).toBe(true); // could not find source in target + }); + + it('should send the right events', async ({server, page, isFirefox}) => { + await page.goto(server.PREFIX + '/drag-n-drop.html'); + const events = await trackEvents(await page.$('body')); + await page.hover('#source'); + await page.mouse.down(); + await page.hover('#target'); + await page.mouse.up(); + expect(await events.jsonValue()).toEqual([ + 'mousemove', + 'mousedown', + isFirefox ? 'dragstart' : 'mousemove', + isFirefox ? 'mousemove' : 'dragstart', + 'dragenter', + 'dragover', + 'drop', + 'dragend', + ]); + }); + + it('should cancel on escape', async ({server, page, isFirefox}) => { + await page.goto(server.PREFIX + '/drag-n-drop.html'); + const events = await trackEvents(await page.$('body')); + await page.hover('#source'); + await page.mouse.down(); + await page.hover('#target'); + await page.keyboard.press('Escape'); + await page.mouse.up(); + expect(await page.$eval('#target', target => target.contains(document.querySelector('#source')))).toBe(false); // found source in target + expect(await events.jsonValue()).toEqual([ + 'mousemove', + 'mousedown', + isFirefox ? 'dragstart' : 'mousemove', + isFirefox ? 'mousemove' : 'dragstart', + 'dragenter', + 'dragover', + 'dragend', + 'mouseup', + ]); + }); + + it('should drag into an iframe', async ({server, page, isFirefox}) => { + await page.goto(server.PREFIX + '/drag-n-drop.html'); + const frame = await attachFrame(page, 'oopif',server.CROSS_PROCESS_PREFIX + '/drag-n-drop.html'); + const pageEvents = await trackEvents(await page.$('body')); + const frameEvents = await trackEvents(await frame.$('body')); + await page.hover('#source'); + await page.mouse.down(); + await frame.hover('#target'); + await page.mouse.up(); + expect(await frame.$eval('#target', target => target.contains(document.querySelector('#source')))).toBe(true); // could not find source in target + expect(await pageEvents.jsonValue()).toEqual([ + 'mousemove', + 'mousedown', + isFirefox ? 'dragstart' : 'mousemove', + isFirefox ? 'mousemove' : 'dragstart', + ]); + expect(await frameEvents.jsonValue()).toEqual([ + 'dragenter', + 'dragover', + 'drop', + ]); + }); + + it('should drag out of an iframe', async ({server, page}) => { + await page.goto(server.PREFIX + '/drag-n-drop.html'); + const frame = await attachFrame(page, 'oopif',server.CROSS_PROCESS_PREFIX + '/drag-n-drop.html'); + const pageEvents = await trackEvents(await page.$('body')); + const frameEvents = await trackEvents(await frame.$('body')); + await frame.hover('#source'); + await page.mouse.down(); + await page.hover('#target'); + await page.mouse.up(); + expect(await page.$eval('#target', target => target.contains(document.querySelector('#source')))).toBe(true); // could not find source in target + expect(await frameEvents.jsonValue()).toEqual([ + 'mousemove', + 'mousedown', + 'dragstart', + ]); + expect(await pageEvents.jsonValue()).toEqual([ + 'dragenter', + 'dragover', + 'drop', + ]); + }); + + it('should drag through a navigation', async ({server, page, isFirefox}) => { + await page.goto(server.PREFIX + '/drag-n-drop.html'); + const beforeNavigationEvents = await trackEvents(await page.$('body')); + await page.hover('#source'); + await page.mouse.down(); + // start the drag + await page.mouse.move(50, 50); + + expect(await beforeNavigationEvents.jsonValue()).toEqual([ + 'mousemove', + 'mousedown', + isFirefox ? 'dragstart' : 'mousemove', + isFirefox ? 'mousemove' : 'dragstart', + // 'dragenter', + // 'dragover', + ]); + + await page.reload(); + const afterNavigationEvents = await trackEvents(await page.$('body')); + + await page.hover('#target'); + await page.mouse.up(); + + expect(await page.$eval('#target', target => target.contains(document.querySelector('#source')))).toBe(true); // could not find source in target + expect(await afterNavigationEvents.jsonValue()).toEqual([ + 'dragenter', + 'dragover', + 'dragover', + 'drop', + ]); + }); + + it('should work even if the page tries to stop us', async ({page, server}) => { + await page.goto(server.PREFIX + '/drag-n-drop.html'); + await page.evaluate(() => { + window.DragEvent = null; + }); + await page.hover('#source'); + await page.mouse.down(); + await page.hover('#target'); + await page.mouse.up(); + expect(await page.$eval('#target', target => target.contains(document.querySelector('#source')))).toBe(true); // could not find source in target + }); + + it('should drag text into a textarea', async ({page}) => { + await page.setContent(` +
ThisIsTheText
+ + `); + await page.click('div', { + clickCount: 3 + }); + await page.mouse.down(); + await page.hover('textarea'); + await page.mouse.up(); + expect(await page.$eval('textarea', t => t.value.trim())).toBe('ThisIsTheText'); + }); + + it('should not drop when the dragover is ignored', async ({page}) => { + await page.setContent(` +
drag target
+ this is the drop target + `); + await page.evaluate(() => { + const events = window['events'] = []; + document.querySelector('div').addEventListener('dragstart', event => { + events.push('dragstart'); + event.dataTransfer.effectAllowed = 'copy'; + event.dataTransfer.setData('text/plain', 'drag data'); + }); + document.querySelector('drop-target').addEventListener('dragover', event => { + events.push('dragover'); + }); + document.querySelector('drop-target').addEventListener('drop', event => { + events.push('drop'); + }); + document.querySelector('div').addEventListener('dragend', event => { + events.push('dragend'); + }); + }); + await page.hover('div'); + await page.mouse.down(); + await page.hover('drop-target'); + await page.mouse.up(); + expect(await page.evaluate('events')).toEqual([ + 'dragstart', + 'dragover', + 'dragend' + ]); + }); + it('should respect the drop effect', (test, {browserName}) => { + test.fixme(browserName === 'chromium', 'Chromium doesn\'t let users set dropEffect on our fake data transfer'); + }, async ({page}) => { + page.on('console', console.log); + expect(await testIfDropped('copy', 'copy')).toBe(true); + expect(await testIfDropped('copy', 'move')).toBe(false); + expect(await testIfDropped('all', 'link')).toBe(true); + expect(await testIfDropped('all', 'none')).toBe(false); + + expect(await testIfDropped('copyMove', 'copy')).toBe(true); + expect(await testIfDropped('copyLink', 'copy')).toBe(true); + expect(await testIfDropped('linkMove', 'copy')).toBe(false); + + expect(await testIfDropped('copyMove', 'link')).toBe(false); + expect(await testIfDropped('copyLink', 'link')).toBe(true); + expect(await testIfDropped('linkMove', 'link')).toBe(true); + + expect(await testIfDropped('copyMove', 'move')).toBe(true); + expect(await testIfDropped('copyLink', 'move')).toBe(false); + expect(await testIfDropped('linkMove', 'move')).toBe(true); + + expect(await testIfDropped('uninitialized', 'copy')).toBe(true); + + async function testIfDropped(effectAllowed: string, dropEffect: string) { + await page.setContent(` +
drag target
+ this is the drop target + `); + await page.evaluate(({effectAllowed, dropEffect}) => { + window['dropped'] = false; + + document.querySelector('div').addEventListener('dragstart', event => { + event.dataTransfer.effectAllowed = effectAllowed; + event.dataTransfer.setData('text/plain', 'drag data'); + }); + + const dropTarget: HTMLElement = document.querySelector('drop-target'); + dropTarget.addEventListener('dragover', event => { + const before = event.dataTransfer.dropEffect + ':'; + event.dataTransfer.dropEffect = dropEffect; + console.log('set drop effect',before, dropEffect, event.dataTransfer.dropEffect); + event.preventDefault(); + }); + dropTarget.addEventListener('drop', event => { + window['dropped'] = true; + }); + }, {effectAllowed, dropEffect}); + await page.hover('div'); + await page.mouse.down(); + await page.hover('drop-target'); + await page.mouse.up(); + return await page.evaluate('dropped'); + } + }); + it('should drop when it has all drop effects', async ({page}) => { + await page.setContent(` +
drag target
+ this is the drop target + `); + await page.evaluate(() => { + const events = window['events'] = []; + document.querySelector('div').addEventListener('dragstart', event => { + events.push('dragstart'); + event.dataTransfer.effectAllowed = 'all'; + event.dataTransfer.setData('text/plain', 'drag data'); + }); + document.querySelector('drop-target').addEventListener('dragover', event => { + events.push('dragover'); + event.dataTransfer.dropEffect = 'copy'; + event.preventDefault(); + }); + document.querySelector('drop-target').addEventListener('drop', event => { + events.push('drop'); + }); + document.querySelector('div').addEventListener('dragend', event => { + events.push('dragend'); + }); + }); + await page.hover('div'); + await page.mouse.down(); + await page.hover('drop-target'); + await page.mouse.up(); + expect(await page.evaluate('events')).toEqual([ + 'dragstart', + 'dragover', + 'drop', + 'dragend' + ]); + }); + + async function trackEvents(target: ElementHandle) { + const eventsHandle = await target.evaluateHandle(target => { + const events: string[] = []; + for (const event of [ + 'mousedown', 'mousemove', 'mouseup', + 'dragstart', 'dragend', 'dragover', 'dragenter', 'dragleave', 'dragexit', + 'drop' + ]) + target.addEventListener(event, () => events.push(event), false); + return events; + }); + return eventsHandle; + } +}); diff --git a/utils/runWebpack.js b/utils/runWebpack.js index 01f6de5760c20b..1aa736be656292 100644 --- a/utils/runWebpack.js +++ b/utils/runWebpack.js @@ -20,6 +20,7 @@ const path = require('path'); const files = [ path.join('src', 'server', 'injected', 'injectedScript.webpack.config.js'), path.join('src', 'server', 'injected', 'utilityScript.webpack.config.js'), + path.join('src', 'server', 'injected', 'dragScript.webpack.config.js'), path.join('src', 'debug', 'injected', 'debugScript.webpack.config.js'), ];