From 8c832c78e04ec1d69a9da5af629629819a60a224 Mon Sep 17 00:00:00 2001 From: Mikhail Losev Date: Thu, 11 Apr 2019 16:40:17 +0300 Subject: [PATCH] Prevented the opening of the inputs' native dialogs (close #1984) (#1987) * initial * fix review issue --- src/client/sandbox/event/index.ts | 46 +++++++++++++++++---- src/client/sandbox/upload/index.ts | 35 ++++++++-------- src/client/utils/dom.ts | 57 ++++++++++++++------------ test/client/fixtures/utils/dom-test.js | 22 ++++++++++ 4 files changed, 110 insertions(+), 50 deletions(-) diff --git a/src/client/sandbox/event/index.ts b/src/client/sandbox/event/index.ts index 32c3a8380..70697fd96 100644 --- a/src/client/sandbox/event/index.ts +++ b/src/client/sandbox/event/index.ts @@ -5,9 +5,17 @@ import Selection from './selection'; import SandboxBase from '../base'; import nativeMethods from '../native-methods'; import * as domUtils from '../../utils/dom'; -import { DOM_EVENTS } from '../../utils/event'; +import { DOM_EVENTS, preventDefault } from '../../utils/event'; import DataTransfer from './drag-and-drop/data-transfer'; import DragDataStore from './drag-and-drop/drag-data-store'; +/*eslint-disable no-unused-vars*/ +import EventSimulator from './simulator'; +import ElementEditingWatcher from './element-editing-watcher'; +import UnloadSandbox from './unload'; +import MessageSandbox from './message'; +import ShadowUI from '../shadow-ui'; +import TimersSandbox from '../timers'; +/*eslint-enable no-unused-vars*/ export default class EventSandbox extends SandboxBase { EVENT_PREVENTED_EVENT: string = 'hammerhead|event|event-prevented'; @@ -31,7 +39,7 @@ export default class EventSandbox extends SandboxBase { onFocus: any; cancelInternalEvents: any; - constructor (listeners, eventSimulator, elementEditingWatcher, unloadSandbox, messageSandbox, shadowUI, timerSandbox) { + constructor (listeners: Listeners, eventSimulator: EventSimulator, elementEditingWatcher: ElementEditingWatcher, unloadSandbox: UnloadSandbox, messageSandbox: MessageSandbox, shadowUI: ShadowUI, timerSandbox: TimersSandbox) { super(); this.listeners = listeners; @@ -116,7 +124,7 @@ export default class EventSandbox extends SandboxBase { const document = this.document; const eventSimulator = this.eventSimulator; - this.onFocus = function (e) { + this.onFocus = function (e: Event) { const focusedEl = e.target; const activeEl = domUtils.getActiveElement(document); @@ -124,7 +132,7 @@ export default class EventSandbox extends SandboxBase { shadowUI.setLastActiveElement(activeEl); }; - this.cancelInternalEvents = function (e, _dispatched, _preventEvent, _cancelHandlers, stopPropagation) { + this.cancelInternalEvents = function (e, _dispatched: boolean, _preventEvent: boolean, _cancelHandlers, stopPropagation: Function) { // NOTE: We should cancel events raised by calling the native function (focus, blur) only if the // element has a flag. If an event is dispatched, we shouldn't cancel it. // After calling a native function two events were raised @@ -139,32 +147,54 @@ export default class EventSandbox extends SandboxBase { }; } - attach (window) { + _preventInputNativeDialogs (window: Window): void { + this.listeners.addInternalEventListener(window, ['click'], (e: Event, dispatched: boolean) => { + if (dispatched && domUtils.isInputWithNativeDialog(e.target as HTMLInputElement)) + preventDefault(e, true); + }); + } + + attach (window: Window) { super.attach(window); + //@ts-ignore window.HTMLInputElement.prototype.setSelectionRange = this.overriddenMethods.setSelectionRange; + //@ts-ignore window.HTMLTextAreaElement.prototype.setSelectionRange = this.overriddenMethods.setSelectionRange; + //@ts-ignore window.Window.prototype.dispatchEvent = this.overriddenMethods.dispatchEvent; + //@ts-ignore window.Document.prototype.dispatchEvent = this.overriddenMethods.dispatchEvent; + //@ts-ignore window.HTMLElement.prototype.dispatchEvent = this.overriddenMethods.dispatchEvent; + //@ts-ignore window.SVGElement.prototype.dispatchEvent = this.overriddenMethods.dispatchEvent; + //@ts-ignore window.HTMLElement.prototype.focus = this.overriddenMethods.focus; + //@ts-ignore window.HTMLElement.prototype.blur = this.overriddenMethods.blur; + //@ts-ignore window.HTMLElement.prototype.click = this.overriddenMethods.click; + //@ts-ignore window.Window.focus = this.overriddenMethods.focus; + //@ts-ignore window.Window.blur = this.overriddenMethods.blur; + //@ts-ignore window.Event.prototype.preventDefault = this.overriddenMethods.preventDefault; - if (window.TextRange && window.TextRange.prototype.select) + //@ts-ignore + if (window.TextRange && window.TextRange.prototype.select) { + //@ts-ignore window.TextRange.prototype.select = this.overriddenMethods.select; + } this.initDocumentListening(window.document); - this.listeners.initElementListening(window, DOM_EVENTS.concat(['load', 'beforeunload', 'pagehide', 'unload', 'message'])); - this.listeners.addInternalEventListener(window, ['focus'], this.onFocus); this.listeners.addInternalEventListener(window, ['focus', 'blur', 'change', 'focusin', 'focusout'], this.cancelInternalEvents); + this._preventInputNativeDialogs(window); + this.unload.attach(window); this.message.attach(window); this.timers.attach(window); diff --git a/src/client/sandbox/upload/index.ts b/src/client/sandbox/upload/index.ts index c0c22c0e1..db604ec80 100644 --- a/src/client/sandbox/upload/index.ts +++ b/src/client/sandbox/upload/index.ts @@ -2,30 +2,32 @@ import INTERNAL_PROPS from '../../../processing/dom/internal-properties'; import SandboxBase from '../base'; import UploadInfoManager from './info-manager'; import { isFileInput } from '../../utils/dom'; -import { isIE, version as browserVersion } from '../../utils/browser'; +import { isIE, isFirefox, version as browserVersion } from '../../utils/browser'; import { stopPropagation, preventDefault } from '../../utils/event'; import { get as getSandboxBackup } from '../backup'; import nativeMethods from '../native-methods'; +/*eslint-disable no-unused-vars*/ +import Listeners from '../event/listeners'; +import EventSimulator from '../event/simulator'; +import ShadowUI from '../shadow-ui'; +/*eslint-enable no-unused-vars*/ export default class UploadSandbox extends SandboxBase { START_FILE_UPLOADING_EVENT: string = 'hammerhead|event|start-file-uploading'; END_FILE_UPLOADING_EVENT: string = 'hammerhead|event|end-file-uploading'; infoManager: UploadInfoManager; - listeners: any; - eventSimulator: any; - constructor (listeners, eventSimulator, shadowUI) { + constructor (private readonly _listeners: Listeners, //eslint-disable-line no-unused-vars + private readonly _eventSimulator: EventSimulator, //eslint-disable-line no-unused-vars + shadowUI: ShadowUI) { super(); this.infoManager = new UploadInfoManager(shadowUI); - - this.listeners = listeners; - this.eventSimulator = eventSimulator; } _riseChangeEvent (input: HTMLInputElement) { - this.eventSimulator.change(input); + this._eventSimulator.change(input); } static _getCurrentInfoManager (input: HTMLInputElement) { @@ -39,11 +41,11 @@ export default class UploadSandbox extends SandboxBase { attach (window: Window) { super.attach(window); - this.listeners.addInternalEventListener(window, ['change'], (e, dispatched) => { + this._listeners.addInternalEventListener(window, ['change'], (e, dispatched) => { const input = e.target; const currentInfoManager = UploadSandbox._getCurrentInfoManager(input); - if (isFileInput(input) && !dispatched) { + if (!dispatched && isFileInput(input)) { stopPropagation(e); preventDefault(e); @@ -69,12 +71,13 @@ export default class UploadSandbox extends SandboxBase { } }); - if (isIE) { - // NOTE: Prevent the browser's open file dialog. - this.listeners.addInternalEventListener(window, ['click'], (e, dispatched) => { - const input = e.target || e.srcElement; - - if (isFileInput(input) && dispatched) + if (isIE || isFirefox) { + // NOTE: Google Chrome does not open the native browser dialog when TestCafe clicks on the input. + // 'Click' is a complex emulated action that uses 'dispatchEvent' method internally. + // Another browsers open the native browser dialog in this case. + // This is why, we are forced to prevent the browser's open file dialog. + this._listeners.addInternalEventListener(window, ['click'], (e: Event, dispatched: boolean) => { + if (dispatched && isFileInput(e.target as HTMLInputElement)) preventDefault(e, true); }); } diff --git a/src/client/utils/dom.ts b/src/client/utils/dom.ts index 893fabe3b..0433165cb 100644 --- a/src/client/utils/dom.ts +++ b/src/client/utils/dom.ts @@ -31,11 +31,12 @@ const SCRIPT_OR_STYLE_RE = /^(script|style)$/i; const EDITABLE_INPUT_TYPES_RE = /^(email|number|password|search|tel|text|url)$/; const NUMBER_OR_EMAIL_INPUT_RE = /^(number|email)$/; -function getFocusableSelector () { - // NOTE: We don't take into account the case of embedded contentEditable elements, and we - // specify the contentEditable attribute for focusable elements. - return 'input, select, textarea, button, body, iframe, [contenteditable="true"], [contenteditable=""], [tabIndex]'; -} +// NOTE: input with 'file' type processed separately in 'UploadSandbox' +const INPUT_WITH_NATIVE_DIALOG = /^(color|date|datetime-local|month|week)$/; + +// NOTE: We don't take into account the case of embedded contentEditable elements, and we +// specify the contentEditable attribute for focusable elements. +const FOCUSABLE_SELECTOR = 'input, select, textarea, button, body, iframe, [contenteditable="true"], [contenteditable=""], [tabIndex]'; function isHidden (el: HTMLElement): boolean { return el.offsetWidth <= 0 && el.offsetHeight <= 0; @@ -387,84 +388,84 @@ export function isIframeWithoutSrc (iframe): boolean { return !iframeDocumentLocationHaveSupportedProtocol; } -export function isImgElement (el: HTMLElement): boolean { +export function isImgElement (el: any): boolean { return instanceToString(el) === '[object HTMLImageElement]'; } -export function isInputElement (el: HTMLElement): boolean { +export function isInputElement (el: any): boolean { return instanceToString(el) === '[object HTMLInputElement]'; } -export function isButtonElement (el: HTMLElement): boolean { +export function isButtonElement (el: any): boolean { return instanceToString(el) === '[object HTMLButtonElement]'; } -export function isHtmlElement (el): boolean { +export function isHtmlElement (el: any): boolean { return instanceToString(el) === '[object HTMLHtmlElement]'; } -export function isBodyElement (el): boolean { +export function isBodyElement (el: any): boolean { return instanceToString(el) === '[object HTMLBodyElement]'; } -export function isHeadElement (el: HTMLElement): boolean { +export function isHeadElement (el: any): boolean { return instanceToString(el) === '[object HTMLHeadElement]'; } -export function isHeadOrBodyElement (el: HTMLElement): boolean { +export function isHeadOrBodyElement (el: any): boolean { const elString = instanceToString(el); return elString === '[object HTMLHeadElement]' || elString === '[object HTMLBodyElement]'; } -export function isHeadOrBodyOrHtmlElement (el: HTMLElement): boolean { +export function isHeadOrBodyOrHtmlElement (el: any): boolean { const elString = instanceToString(el); return elString === '[object HTMLHeadElement]' || elString === '[object HTMLBodyElement]' || elString === '[object HTMLHtmlElement]'; } -export function isBaseElement (el: HTMLElement): boolean { +export function isBaseElement (el: any): boolean { return instanceToString(el) === '[object HTMLBaseElement]'; } -export function isScriptElement (el: HTMLElement): boolean { +export function isScriptElement (el: any): boolean { return instanceToString(el) === '[object HTMLScriptElement]'; } -export function isStyleElement (el: HTMLElement): boolean { +export function isStyleElement (el: any): boolean { return instanceToString(el) === '[object HTMLStyleElement]'; } -export function isLabelElement (el: HTMLElement): boolean { +export function isLabelElement (el: any): boolean { return instanceToString(el) === '[object HTMLLabelElement]'; } -export function isTextAreaElement (el: HTMLElement): boolean { +export function isTextAreaElement (el: any): boolean { return instanceToString(el) === '[object HTMLTextAreaElement]'; } -export function isOptionElement (el: HTMLElement): boolean { +export function isOptionElement (el: any): boolean { return instanceToString(el) === '[object HTMLOptionElement]'; } -export function isRadioButtonElement (el): boolean { +export function isRadioButtonElement (el: HTMLInputElement): boolean { return isInputElement(el) && el.type.toLowerCase() === 'radio'; } -export function isColorInputElement (el): boolean { +export function isColorInputElement (el: HTMLInputElement): boolean { return isInputElement(el) && el.type.toLowerCase() === 'color'; } -export function isCheckboxElement (el): boolean { +export function isCheckboxElement (el: HTMLInputElement): boolean { return isInputElement(el) && el.type.toLowerCase() === 'checkbox'; } -export function isSelectElement (el): boolean { +export function isSelectElement (el: any): boolean { return instanceToString(el) === '[object HTMLSelectElement]'; } -export function isFormElement (el: HTMLElement): boolean { +export function isFormElement (el: any): boolean { return instanceToString(el) === '[object HTMLFormElement]'; } @@ -472,6 +473,10 @@ export function isFileInput (el: HTMLInputElement): boolean { return isInputElement(el) && el.type.toLowerCase() === 'file'; } +export function isInputWithNativeDialog (el: HTMLInputElement): boolean { + return isInputElement(el) && INPUT_WITH_NATIVE_DIALOG.test(el.type.toLowerCase()); +} + export function isBodyElementWithChildren (el: HTMLElement): boolean { return isBodyElement(el) && nativeMethods.htmlCollectionLengthGetter.call(el.children); } @@ -520,10 +525,10 @@ export function isElementFocusable (el: HTMLElement): boolean { if (isTableDataCellElement(el) && isIE) return true; - return matches(el, getFocusableSelector()) || tabIndex !== null; + return matches(el, FOCUSABLE_SELECTOR) || tabIndex !== null; } -export function isShadowUIElement (element: HTMLElement): boolean { +export function isShadowUIElement (element: any): boolean { // @ts-ignore return !!element[INTERNAL_PROPS.shadowUIElement]; } diff --git a/test/client/fixtures/utils/dom-test.js b/test/client/fixtures/utils/dom-test.js index 94f41066d..3e48e41de 100644 --- a/test/client/fixtures/utils/dom-test.js +++ b/test/client/fixtures/utils/dom-test.js @@ -947,6 +947,28 @@ test('inspect html elements', function () { }); }); +test('isInputWithNativeDialog', function () { + var checkedInputTypes = ['color', 'date', 'datetime-local', 'month', 'week']; + var countCheckedTypes = 0; + + for (var i = 0; i < checkedInputTypes.length; i++) { + var checkedInputType = checkedInputTypes[i]; + var checkedInput = document.createElement('input'); + + checkedInput.type = checkedInputType; + + // NOTE: check the browser support for the specified input type + if (checkedInput.type !== checkedInputType) + continue; + + countCheckedTypes++; + ok(domUtils.isInputWithNativeDialog(checkedInput), checkedInputType); + } + + if (countCheckedTypes === 0) + expect(0); +}); + if (browserUtils.isChrome) { test('should return active element inside shadow DOM', function () { var host = document.createElement('div');