Skip to content

Commit

Permalink
Prevented the opening of the inputs' native dialogs (close #1984) (#1987
Browse files Browse the repository at this point in the history
)

* initial

* fix review issue
  • Loading branch information
miherlosev committed Apr 11, 2019
1 parent f2ccece commit 8c832c7
Show file tree
Hide file tree
Showing 4 changed files with 110 additions and 50 deletions.
46 changes: 38 additions & 8 deletions src/client/sandbox/event/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;
Expand Down Expand Up @@ -116,15 +124,15 @@ 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);

if (!domUtils.isShadowUIElement(focusedEl) && !domUtils.isShadowUIElement(activeEl))
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
Expand All @@ -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);
Expand Down
35 changes: 19 additions & 16 deletions src/client/sandbox/upload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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);

Expand All @@ -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);
});
}
Expand Down
57 changes: 31 additions & 26 deletions src/client/utils/dom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -387,91 +388,95 @@ 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]';
}

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);
}
Expand Down Expand Up @@ -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];
}
Expand Down
22 changes: 22 additions & 0 deletions test/client/fixtures/utils/dom-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down

0 comments on commit 8c832c7

Please sign in to comment.