UNPKG

siesta-lite

Version:

Stress-free JavaScript unit testing and functional testing tool, works in NodeJS and browsers

700 lines (537 loc) 28 kB
/* Siesta 5.6.1 Copyright(c) 2009-2022 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 : { keyboardEventName : ("KeyboardEvent" in window && !bowser.msedge) ? "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, // https://developer.mozilla.org/en-US/docs/Web/API/Event/composed // The read-only composed property of the Event interface returns a Boolean which indicates whether or not // the event will propagate across the shadow DOM boundary into the standard DOM. composed : true, 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 this.global.KeyboardEvent(type, options); } } catch (err) { event = null; } if (!event) { event = doc.createEvent("Events"); event.initEvent(type, options.bubbles, options.cancelable); $.extend(event, options); } } 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", this.getQueryableContainer(el)) .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 = this.getBodyElement(el) var currentTabIndex = Number(el.getAttribute('tabIndex') || 0) var getTabIndex = function (dom) { if (dom === el && currentTabIndex === -1) return 0 if (dom === body) return 0 return Number(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 }, makeSureBlurWorkaroundApplied : function (doc) { if (doc.__SIESTA_ONBLUR_WORKAROUND_APPLIED__) return doc.__SIESTA_ONBLUR_WORKAROUND_APPLIED__ = new Siesta.Test.SimulatorOnBlurWorkaround({ document : doc, simulator : this }) }, simulateType : function (text, options, params) { if (text == null) throw 'Must supply a string to type'; var me = this var el = params.el this.makeSureBlurWorkaroundApplied(el.ownerDocument) 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); } else if (el.isContentEditable) { // For contentEditable, walk up to find the root editable node var rootEditableEl = me.closest(el, '[contentEditable]'); if (rootEditableEl && rootEditableEl !== el) { var range = me.global.document.createRange(); var sel = me.global.getSelection(); try { range.setStart(el, 1); range.collapse(true); sel.removeAllRanges(); sel.addRange(range); } catch(e) { // Oh well... } } } // 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.ownerDocument) 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 originalValue = isTextInput && el[ textValueProp ]; 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.ownerDocument); // 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); } if (isTextInput && el[textValueProp] !== originalValue) { el.valueWasModifiedByBackspace = false; } // 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 (isTextInput && el[ textValueProp ] !== originalValue) { el.valueWasModifiedByBackspace = true; } } 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, propertyName) { var Object = this.global.Object if (Object.getOwnPropertyDescriptor && propertyName == 'value') { var desc = Object.getOwnPropertyDescriptor(el.constructor.prototype, propertyName) desc.set.call(el, newValue) } else el[ propertyName ] = 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); } } } });