    /*

    Siesta 5.3.0
    Copyright(c) 2009-2019 Bryntum AB
    https://bryntum.com/contact
    https://bryntum.com/products/siesta/license

    */
    Role('Siesta.Test.Simulate.Keyboard', {

        requires        : [
            '$',
            'simulateEvent', 'isEventPrevented'
            /*'getSimulateEventsWith', 'getElementAtCursor'*/
        ],

        does : [
            Siesta.Util.Role.CanFormatStrings,
            Siesta.Test.Browser.Role.CanWorkWithKeyboard
        ],

        has : {
                                     // For "KeyboardEvent" only Firefox set values for deprecated keyCode/charCode, Chrome just sets them to 0
            keyboardEventName       : ("KeyboardEvent" in window && bowser.gecko) ? "KeyboardEvent" : ("KeyEvent" in window ? "KeyEvents" : null)
        },

        methods: {

            // TODO switch fully to KeyboardEvent https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/KeyboardEvent
            // private
            createKeyboardEvent: function (type, options, el) {
                var event;
                var doc = el.ownerDocument,
                    global = this.global;

                options = $.extend({
                    bubbles    : true,
                    cancelable : true,
                    view       : this.global,
                    ctrlKey    : false,
                    altKey     : false,
                    shiftKey   : false,
                    metaKey    : false,
                    keyCode    : 0,
                    charCode   : 0,
                    key        : options.key || ''
                }, options);

                // use W3C standard when available and allowed by "simulateEventsWith" option
                if (doc.createEvent && this.getSimulateEventsWith() == 'dispatchEvent') {
                    try {
                        if (this.keyboardEventName === 'KeyboardEvent') {
                            event = new KeyboardEvent(type, options);
                        }
                    } catch (err) {
                        event = null;
                    }

                    if (!event) {
                        event = doc.createEvent("Events");
                        event.initEvent(type, options.bubbles, options.cancelable);

                        $.extend(event, {
                            view     : options.view,
                            ctrlKey  : options.ctrlKey,
                            altKey   : options.altKey,
                            shiftKey : options.shiftKey,
                            metaKey  : options.metaKey,
                            keyCode  : options.keyCode,
                            charCode : options.charCode,
                            key      : options.key || '',
                            code     : options.code
                        });
                    }
                } else if (doc.createEventObject) {
                    event = doc.createEventObject();
                    $.extend(event, options);
                }

                if (bowser.msie || bowser.opera) {
                    event.keyCode = (options.charCode > 0) ? options.charCode : options.keyCode;
                    event.charCode = undefined;
                }

                return event;
            },

            // private
            createTextEvent: function (type, options, el) {
                var doc         = el.ownerDocument;
                var event       = null;

                // only for Webkit / IE for now
                if (doc.createEvent) {
                    try {
                        event = doc.createEvent('TextEvent');

                        if (event && event.initTextEvent) {
                            event.initTextEvent(
                                type,
                                true,
                                true,
                                this.global,
                                options.text,
                                // IE ONLY below here
                                0,
                                window.navigator.userLanguage || window.navigator.language
                            );
                            return event;
                        }
                    }
                    catch(e) {}
                }

                return null;
            },

            /*!
             * Based on:
             *
             * @license EmulateTab
             * Copyright (c) 2011, 2012 The Swedish Post and Telecom Authority (PTS)
             * Developed for PTS by Joel Purra <http://joelpurra.se/>
             * Released under the BSD license.
             *
             * A jQuery plugin to emulate tabbing between elements on a page.
             */
            findNextFocusable : function (el, offset) {
                var $el         = this.$(el)

                var $focusable  = this.$(":focus, :input, a[href], [tabindex], body", el.ownerDocument)
                    .not(":disabled")
                    .not(":hidden")
                    .not("a[href]:empty")

                var escapeSelectorName  = function (str) {
                    // Based on http://api.jquery.com/category/selectors/
                    // Still untested
                    return str.replace(/(!"#$%&'\(\)\*\+,\.\/:;<=>\?@\[\]^`\{\|\}~)/g, "\\\\$1");
                }

                var isRadio     = false
                var selector

                if (el.tagName === "INPUT" && el.type === "radio" && el.name !== "" ) {
                    isRadio     = true
                    selector    = "input[type=radio][name=" + escapeSelectorName(el.name) + "]"
                }

                var processed       = []

                for (var i = 0; i < $focusable.length; i++) {
                    var currEl      = $focusable[ i ]

                    // always include current element
                    if (currEl != el && currEl.getAttribute('tabIndex') == -1 || isRadio && $(currEl).is(selector)) continue

                    processed.push(currEl)
                }

                var body                = el.ownerDocument.body
                var currentTabIndex     = el.getAttribute('tabIndex')

                var getTabIndex         = function (dom) {
                    if (dom == el && currentTabIndex == -1) return 0

                    if (dom == body) return 0

                    return dom.getAttribute('tabIndex') || 0
                }

                processed.sort(function (a, b) {
                    var aIndex      = getTabIndex(a)
                    var bIndex      = getTabIndex(b)

                    return aIndex < bIndex ? -1 : (aIndex > bIndex ? 1 : (a == body ? 1 : (b == body ? -1 : 0)))
                });

                var currentIndex    = $(processed).index($el);

                if (currentIndex == -1) return null

                return processed[ (currentIndex + offset) % processed.length ]
            },

            emulateTab : function (el, offset) {
                var next        = this.findNextFocusable(el, offset || 1)

                if (next)
                    this.test.focus(next)
                else
                    el.blur()

                return next
            },

            simulateType : function (text, options, params) {
                if (text == null) throw 'Must supply a string to type';

                var me          = this

                var el          = params.el

                if (el.disabled) {
                    return Promise.resolve()
                }

                // Store initial value of text fields, updated after ENTER key press in ´keyPress´ method
                if ('value' in el) {
                    el.setAttribute('__lastValue', el.value);
                }

                // Extract normal chars, or special keys in brackets such as [TAB], [RIGHT] or [ENTER]
                var keys        = this.extractKeysAndSpecialKeys(text + '');

                var queue       = new Siesta.Util.Queue({
                    deferer         : this.test.originalSetTimeout,
                    deferClearer    : this.test.originalClearTimeout,

                    interval        : this.actionDelay,
                    // this is 0, since user agent `type` method also contains queue with "callbackDelay"
                    // so we don't need to double that (which also breaks 624_rerun_hotkey)
                    callbackDelay   : 0,

                    observeTest     : this.test,

                    processor       : function (data, index) {
                        // 1. In IE10, it seems activeElement cannot be trusted as it sometimes returns an empty object with no properties.
                        // Try to detect this case and simply use the original el
                        // 2. If user clicks around in the project during ongoing test, the activeElement will be reset to BODY
                        // If this happens, reuse the original el and hope all is well
                        var focusedEl   = me.activeElement(true, el, el)

                        me.simulateKeyPress(focusedEl, data.key, options)
                    }
                })

                // the `el` should be already focused in the `type` method of the "user agent" code,
                // still allow to focus it, but using special "param.focus"
                if (params.focus) {
                    // Manually focus event to be typed into first
                    queue.addStep({
                        processor       : function () {
                            if (!me.nodeIsOrphan(el)) me.focus(el)
                        }
                    })

                    // focus the element one more time for IE - this seems to fix the weird sporadic failures in 042_keyevent_simulation3.t.js
                    // failures are caused by the field "blur" immediately after 1st focus
                    // no Ext "focus/blur" methods seems to be called, so it can be a browser behavior
                    bowser.msie && queue.addStep({
                        processor       : function () {
                            if (!me.nodeIsOrphan(el)) me.focus(el)
                        }
                    })
                }

                Joose.A.each(keys, function (key, index) {
                    key             = key.length == 1 ? key : key.substring(1, key.length - 1)

                    keys[ index ]   = key

                    queue.addStep({ key : key })
                });

                if (!el.readOnly && keys.length) {
                    var KeyCodes        = Siesta.Test.UserAgent.KeyCodes().keys;
                    var firstKeyCode    = KeyCodes[ keys[ 0 ].toUpperCase() ]

                    if (this.isReadableKey(firstKeyCode)) {
                        // Some browsers (IE/FF) do not overwrite selected text, do it manually
                        // but only if the key is readable (some letter etc)
                        // do not clear the selection in case of special symbol
                        var selText     = this.test.getSelectedText(el);

                        if (selText && 'value' in el && 'selectionStart' in el) {
                            var caretPos;

                            try {
                                caretPos = el.selectionStart;
                            } catch(e) {}

                            if (caretPos != null) {
                                // mimic replacing selected text
                                this.silentSetValue(el, el.value.substr(0, caretPos) + el.value.substr(caretPos + selText.length), 'value')

                                // Now set caret position to start of selection range
                                this.test.setCaretPosition(el, caretPos);
                            }
                        }
                    }
                }

                return new Promise(function (resolve, reject) {
                    queue.run(resolve)
                })
            },

            simulateKeyPress: function (el, key, options) {
                var isMac           = bowser.mac;

                var KeyCodes        = Siesta.Test.UserAgent.KeyCodes().keys
                var keyNameMap      = Siesta.Test.UserAgent.KeyCodes().keyNameMap;
                var keyCode         = KeyCodes[ key.toUpperCase() ] || 0;
                var keyDownEl       = el = this.test.normalizeElement(el);

                options             = options || {};

                options.readableKey = key;

                // keypress should not be fired on Mac when CMD is pressed
                // nor on Windows when CTRL is pressed
                var suppressKeyPress = (isMac && options.metaKey) || (!isMac && options.ctrlKey);

                // Should not actually type anything when CTRL / CMD are pressed
                var isReadableKey   = this.isReadableKey(keyCode);
                var charCode        = isReadableKey && !suppressKeyPress ? key.charCodeAt(0) : 0

                options.key         = isReadableKey ? key : (keyNameMap[ keyCode ] || '');
                options.code        = keyNameMap[ keyCode ];

                var me              = this,
                    isTextInput     = me.isTextInput(el),
                    isEditableNode  = me.isEditableNode(el),
                    acceptsTextInput = isTextInput || isEditableNode;

                var textValueProp       = 'value' in el ? 'value' : 'innerHTML';
                var originalLength      = el[ textValueProp ].length;

                var keyDownEvent        = me.simulateEvent(el, 'keydown', Joose.O.extend({ charCode : 0, keyCode : keyCode }, options));
                var keyDownPrevented    = this.isEventPrevented(keyDownEvent)

                var isSelection         = acceptsTextInput && this.mimicTextSelection(keyDownEvent, el);

                if (!isSelection) {
                    var keyPressPrevented = false;
                    var supports          = Siesta.Project.Browser.FeatureSupport().supports

                    // Need to reevaluate focused element here, it may have changed in a 'keydown' listener
                    el                  = me.activeElement(true, el, el);

                    // keypress should not be fired when CTRL or CMD are pressed
                    if (!suppressKeyPress && !keyDownPrevented) {
                        var event         = me.simulateEvent(el, 'keypress', Joose.O.extend({ charCode : charCode, keyCode : isReadableKey ? 0 : keyCode }, options));
                        keyPressPrevented = this.isEventPrevented(event)

                        if (!keyPressPrevented && keyCode === KeyCodes.TAB) {
                            el          = this.emulateTab(el, options.shiftKey ? -1 : 1) || el;
                        }
                    }

                    if (!keyDownPrevented && acceptsTextInput && keyCode != KeyCodes.TAB) {

                        if (isReadableKey && !suppressKeyPress && !keyPressPrevented) {
                            var innerHTML

                            // IE10 tries to be 'helpful' by inserting an empty space, clean it
                            // IE11 inserts <br> after call to the .focus() method of the element
                            if (isEditableNode && bowser.msie) {
                                innerHTML               = el.innerHTML

                                if (innerHTML.indexOf('&nbsp;') === 0) {
                                    el.innerHTML        = innerHTML.substring(6)
                                    originalLength      = el.innerHTML.length
                                } else
                                    if (innerHTML.indexOf('<br>') === 0) {
                                        el.innerHTML    = innerHTML.substring(4);
                                        originalLength  = el.innerHTML.length
                                    }
                            }

                            // IE won't do execCommand with insertText
                            if (isEditableNode && !bowser.msie) {
                                innerHTML           = el.innerHTML

                                if (innerHTML.charCodeAt(innerHTML.length - 1) === 8203) {
                                    el.innerHTML    = innerHTML.substring(0, innerHTML.length - 1);
                                }
                                el.ownerDocument.execCommand('insertText', false, options.readableKey);
                            } else {
                                 //TODO should check first if textInput event is supported
                                me.simulateEvent(el, bowser.msie ? 'textinput' : 'textInput', { text: options.readableKey });
                            }

                            // will fire 'input' event
                            me.mimicCharacterInsertion(el, key, options, originalLength);
                        } else {
                            me.mimicCaretMovement(el, keyCode);
                        }

                        // Manually delete one char off the end if backspace simulation is not supported by the browser
                        if (
                            (keyCode === KeyCodes.BACKSPACE || keyCode === KeyCodes.DELETE)
                            && !supports.canSimulateBackspace && el[ textValueProp ].length > 0
                        ) {
                            this.mimicCharacterDeletion(el, keyCode, options);
                        }

                        if (textValueProp === 'value' && keyCode === KeyCodes.ENTER && !keyPressPrevented) {
                            if (isTextInput) this.maybeMimicChangeEvent(keyDownEl);

                            if (!supports.enterSubmitsForm) {
                                this.mimicFormSubmit(el);
                            }
                        }
                    }
                }

                this.mimicClickOnEnter(el, keyCode);

                me.simulateEvent(el, 'keyup', $.extend({ charCode : 0, keyCode : keyCode }, options));

                return Promise.resolve()
            },

            mimicCharacterInsertion : function (el, readableKey, options, originalLength) {
                var textValueProp   = 'value' in el ? 'value' : 'innerHTML';

                var maxLength       = el.getAttribute('maxlength') || Infinity
                var isTextInput     = this.isTextInput(el);
                var supports        = Siesta.Project.Browser.FeatureSupport().supports;

                if (maxLength != null) maxLength    = Number(maxLength)

                // If the entered char had no impact on the textfield - manually put it there
                if (
                    !el.readOnly && (isTextInput || bowser.msie)
                    && !supports.canSimulateKeyCharacters
                    && originalLength === el[ textValueProp ].length && originalLength < maxLength
                ) {
                    var val         = el[ textValueProp ];
                    var caretPos    = this.test.getCaretPosition(el);

                    // Fallback to appending text to end of string if caret position cannot be determined
                    if (caretPos == undefined) {
                        caretPos    = val.length;
                    }

                    // Inject char at caret position
                    this.silentSetValue(
                        el,
                        val.substr(0, caretPos) + readableKey + val.substr(caretPos),
                        textValueProp
                    )

                    // Restore caret position
                    this.test.setCaretPosition(el, caretPos + 1);

                    this.simulateEvent(el, 'input', options);
                }
            },

            // this method will change the property `propertyName` of the `el` to a `newValue`
            // if `propertyName` will be "value" it will try to avoid "touching" the actual "value"
            // property, since that may trigger side effects
            // if user has defined own "value" property on the element (React did that, crazy)
            silentSetValue : function (el, newValue, propetyName) {
                var Object      = this.global.Object

                if (Object.getOwnPropertyDescriptor && propetyName == 'value') {
                    var desc    = Object.getOwnPropertyDescriptor(el.constructor.prototype, propetyName)

                    desc.set.call(el, newValue)
                } else
                    el[ propetyName ] = newValue;
            },

            mimicTextSelection : function(keyDownEvent, el) {
                var isMac       = bowser.mac;
                var KC          = Siesta.Test.UserAgent.KeyCodes().keys;

                var retVal      = false;

                // CTRL-A or CMD-A in text input should select all
                var ctrlKey     = (!isMac && keyDownEvent.ctrlKey) || (keyDownEvent.metaKey && isMac);

                switch (keyDownEvent.keyCode) {
                    // Select all
                    case KC["A"]:
                        if (ctrlKey) {
                            this.test.selectText(el);
                            retVal = true;
                        }
                        break;

                    case KC["LEFT"]:
                    case KC["HOME"]:
                        if (keyDownEvent.shiftKey) {
                            this.test.selectText(el, 0, this.test.getCaretPosition(el));
                            retVal = true;
                        }
                        break;

                    case KC["RIGHT"]:
                    case KC["END"]:
                        if (keyDownEvent.shiftKey) {
                            this.test.selectText(el, this.test.getCaretPosition(el));
                            retVal = true;
                        }
                        break;
                }

                return retVal;
            },

            mimicClickOnEnter : function (el, keyCode) {
                // somehow "node.nodeName" is empty sometimes in IE10
                var nodeName        = el.nodeName && el.nodeName.toLowerCase()
                var supports        = Siesta.Project.Browser.FeatureSupport().supports
                var KeyCodes        = Siesta.Test.UserAgent.KeyCodes().keys

                if ((nodeName == 'a' || nodeName == 'button') && keyCode === KeyCodes.ENTER && !supports.enterOnAnchorTriggersClick) {
                    // this "click" should not update the current cursor position its merely for activating "click" listeners
                    this.simulateEvent(el, 'click', { doNotUpdateCurrentPosition : true });
                }
            },

            mimicCaretMovement : function(el, keyCode) {
                // somehow "node.nodeName" is empty sometimes in IE10
                var nodeName        = el.nodeName && el.nodeName.toLowerCase()

                if ((nodeName == 'input' || nodeName == 'textarea')) {
                    var KeyCodes        = Siesta.Test.UserAgent.KeyCodes().keys;

                    switch (keyCode) {
                        case KeyCodes.HOME:
                            this.test.setCaretPosition(el, 0);
                            break;

                        case KeyCodes.LEFT:
                            var selText  = this.test.getSelectedText(el);

                            if (selText) {
                                var caretPos = this.test.getCaretPosition(el);

                                this.test.selectText(el, caretPos, caretPos);
                            } else {
                                this.test.moveCaretPosition(el, -1);
                            }
                            break;

                        case KeyCodes.RIGHT:
                            var selText  = this.test.getSelectedText(el);

                            if (selText) {
                                var caretPos = this.test.getCaretPosition(el);

                                this.test.selectText(el, caretPos + selText.length, caretPos + selText.length);
                            } else {
                                this.test.moveCaretPosition(el, 1);
                            }
                            break;

                        case KeyCodes.END:
                            this.test.setCaretPosition(el, el.value.length);
                            break;
                    }
                }
            },

            mimicFormSubmit : function (el) {
                var form        = this.$(el).closest('form');

                if (form.length) {
                    // Use jQuery's :submit instead of [type=submit] since <button>Foo</button> could have button.type=submit, but this is not queryable
                    var submitButton = form.find(':submit')[ 0 ];
                    var hasOneInput  = form.find('input').length === 1;

                    if (submitButton) {
                        submitButton.click();
                    }
                    else if (hasOneInput) {
                        var submitPrevented = this.isEventPrevented(this.simulateEvent(form[ 0 ], 'submit', {}));

                        if (!submitPrevented) form[ 0 ].submit();
                    }
                }
            },

            mimicCharacterDeletion : function (el, keyCode, options) {
                var isTextInput     = this.isTextInput(el);

                if (!el.readOnly) {
                    // IE won't do execCommand with insertText
                    if (isTextInput || bowser.msie) {
                        var textValueProp       = 'value' in el ? 'value' : 'innerHTML';
                        var text                = el[ textValueProp ];

                        var selText             = this.test.getSelectedText(el) || '';
                        var caretPosition       = this.test.getCaretPosition(el);

                        var inputChanged        = false

                        if (caretPosition != null && selText) {
                            this.silentSetValue(
                                el,
                                text.substring(0, caretPosition) + text.substring(caretPosition + selText.length),
                                textValueProp
                            )

                            inputChanged        = true
                        } else {
                            var KeyCodes        = Siesta.Test.UserAgent.KeyCodes().keys;

                            // fall back to last char index if caret position could not be determined
                            caretPosition       = caretPosition == null ? text.length : caretPosition;

                            if (keyCode === KeyCodes.BACKSPACE) {
                                if (caretPosition > 0) {
                                    inputChanged = true

                                    this.silentSetValue(
                                        el,
                                        text.substring(0, caretPosition - 1) + text.substring(caretPosition),
                                        textValueProp
                                    )

                                    caretPosition       = caretPosition - 1;
                                }
                            } else {
                                if (caretPosition < text.length) inputChanged = true

                                // DELETE key
                                this.silentSetValue(
                                    el,
                                    (caretPosition > 0 ? text.substring(0, caretPosition + 1) : '') + text.substring(caretPosition + 1),
                                    textValueProp
                                )
                            }
                        }

                        // Caret position is moved to end when setting text value, restore it manually
                        this.test.setCaretPosition(el, caretPosition);

                        inputChanged && this.simulateEvent(el, 'input', options);
                    } else {
                        el.ownerDocument.execCommand('delete');
                    }
                }
            },

            maybeMimicChangeEvent : function (el) {
                if (el.getAttribute('__lastValue') !== el.value) {
                    this.simulateEvent(el, 'change');

                    el.setAttribute('__lastValue', el.value);
                }
            }
        }
    });