diff --git a/cli/types/index.d.ts b/cli/types/index.d.ts index 97dfec2831..1bccee51ab 100644 --- a/cli/types/index.d.ts +++ b/cli/types/index.d.ts @@ -1783,6 +1783,15 @@ declare namespace Cypress { * @see https://on.cypress.io/writefile */ writeFile(filePath: string, contents: C, encoding: Encodings, options?: Partial): Chainable + + /** + * jQuery library bound to the AUT + * + * @see https://on.cypress.io/$ + * @example + * cy.$$('p') + */ + $$: JQueryStatic } interface SinonSpyAgent { diff --git a/packages/driver/package.json b/packages/driver/package.json index 70be256302..07e0c7e209 100644 --- a/packages/driver/package.json +++ b/packages/driver/package.json @@ -31,6 +31,7 @@ "bytes": "3.1.0", "chai": "3.5.0", "chai-as-promised": "6.0.0", + "chai-subset": "1.6.0", "chokidar-cli": "1.2.2", "clone": "2.1.2", "compression": "1.7.4", diff --git a/packages/driver/src/cy/commands/actions/click.coffee b/packages/driver/src/cy/commands/actions/click.coffee index 79d2b385fc..f44dc16f33 100644 --- a/packages/driver/src/cy/commands/actions/click.coffee +++ b/packages/driver/src/cy/commands/actions/click.coffee @@ -44,7 +44,6 @@ module.exports = (Commands, Cypress, cy, state, config) -> $el = $dom.wrap(el) domEvents = {} - $previouslyFocusedEl = null if options.log ## figure out the options which actually change the behavior of clicks @@ -149,9 +148,6 @@ module.exports = (Commands, Cypress, cy, state, config) -> ## without firing the focus event $previouslyFocused = cy.getFocused() - if el = cy.needsForceFocus() - cy.fireFocus(el) - el = $elToClick.get(0) domEvents.mouseDown = $Mouse.mouseDown($elToClick, coords.fromViewport) @@ -169,21 +165,17 @@ module.exports = (Commands, Cypress, cy, state, config) -> ## retrieve the first focusable $el in our parent chain $elToFocus = $elements.getFirstFocusableEl($elToClick) - if cy.needsFocus($elToFocus, $previouslyFocused) - cy.fireFocus($elToFocus.get(0)) - - ## if we are currently trying to focus - ## the body then calling body.focus() - ## is a noop, and it will not blur the - ## current element, which is all so wrong - if $elToFocus.is("body") + if $dom.isWindow($elToFocus) + # if the first focusable element from the click + # is the window, then we can skip the focus event + # since the user has clicked a non-focusable element $focused = cy.getFocused() - - ## if the current focused element hasn't changed - ## then blur manually - if $elements.isSame($focused, $previouslyFocused) - cy.fireBlur($focused.get(0)) + if $focused + cy.fireBlur $focused.get(0) + else + # the user clicked inside a focusable element + cy.fireFocus $elToFocus.get(0) afterMouseDown($elToClick, coords) }) diff --git a/packages/driver/src/cy/commands/actions/focus.coffee b/packages/driver/src/cy/commands/actions/focus.coffee index 1c79bc6f5e..db9affc7bb 100644 --- a/packages/driver/src/cy/commands/actions/focus.coffee +++ b/packages/driver/src/cy/commands/actions/focus.coffee @@ -28,10 +28,20 @@ module.exports = (Commands, Cypress, cy, state, config) -> consoleProps: -> "Applied To": $dom.getElements(options.$el) - ## http://www.w3.org/TR/html5/editing.html#specially-focusable + el = options.$el.get(0) + + ## the body is not really focusable, but it + ## can have focus on initial page load. + ## this is instead a noop. + ## TODO: throw on body instead (breaking change) + isBody = $dom.isJquery(options.$el) && + $elements.isElement(options.$el.get(0)) && + $elements.isBody(options.$el.get(0)) + + ## http://www.w3.org/$R/html5/editing.html#specially-focusable ## ensure there is only 1 dom element in the subject ## make sure its allowed to be focusable - if not (isWin or $dom.isFocusable(options.$el)) + if not (isWin or isBody or $dom.isFocusable(options.$el)) return if options.error is false node = $dom.stringify(options.$el) @@ -48,7 +58,6 @@ module.exports = (Commands, Cypress, cy, state, config) -> args: { num } }) - el = options.$el.get(0) cy.fireFocus(el) diff --git a/packages/driver/src/cy/commands/actions/type.coffee b/packages/driver/src/cy/commands/actions/type.coffee index 05807ec2bb..ca047ab991 100644 --- a/packages/driver/src/cy/commands/actions/type.coffee +++ b/packages/driver/src/cy/commands/actions/type.coffee @@ -328,6 +328,23 @@ module.exports = (Commands, Cypress, cy, state, config) -> ## if it's the body, don't need to worry about focus return type() if isBody + ## if the subject is already the focused element, start typing + ## we handle contenteditable children by getting the host contenteditable, + ## and seeing if that is focused + ## Checking first if element is focusable accounts for focusable els inside + ## of contenteditables + $focused = cy.getFocused() + $focused = $focused && $focused[0] + + if $elements.isFocusable(options.$el) + elToCheckCurrentlyFocused = options.$el[0] + else if $elements.isContentEditable(options.$el[0]) + elToCheckCurrentlyFocused = $selection.getHostContenteditable(options.$el[0]) + + if elToCheckCurrentlyFocused && elToCheckCurrentlyFocused is $focused + ## TODO: not scrolling here, but revisit when scroll algorithm changes + return type() + $actionability.verify(cy, options.$el, options, { onScroll: ($el, type) -> Cypress.action("cy:scrolled", $el, type) @@ -335,9 +352,6 @@ module.exports = (Commands, Cypress, cy, state, config) -> onReady: ($elToClick) -> $focused = cy.getFocused() - if el = cy.needsForceFocus() - cy.fireFocus(el) - ## if we dont have a focused element ## or if we do and its not ourselves ## then issue the click diff --git a/packages/driver/src/cy/focused.coffee b/packages/driver/src/cy/focused.coffee index d79eeb4dec..e09d1b1e39 100644 --- a/packages/driver/src/cy/focused.coffee +++ b/packages/driver/src/cy/focused.coffee @@ -4,16 +4,18 @@ $elements = require("../dom/elements") $actionability = require("./actionability") create = (state) -> + + documentHasFocus = () -> + ## hardcode document has focus as true + ## since the test should assume the window + ## is in focus the entire time + return true + fireBlur = (el) -> win = $window.getWindowByElement(el) hasBlurred = false - hasFocus = top.document.hasFocus() - - if not hasFocus - win.focus() - ## we need to bind to the blur event here ## because some browsers will not ever fire ## the blur event if the window itself is not @@ -42,25 +44,33 @@ create = (state) -> ## fallback if our focus event never fires ## to simulate the focus + focusin if not hasBlurred - ## todo handle relatedTarget's per the spec - focusoutEvt = new FocusEvent "focusout", { - bubbles: true - cancelable: false - view: win - relatedTarget: null - } + simulateBlurEvent(el, win) - blurEvt = new FocusEvent "blur", { - bubble: false - cancelable: false - view: win - relatedTarget: null - } + simulateBlurEvent = (el, win) -> + ## todo handle relatedTarget's per the spec + focusoutEvt = new FocusEvent "focusout", { + bubbles: true + cancelable: false + view: win + relatedTarget: null + } - el.dispatchEvent(blurEvt) - el.dispatchEvent(focusoutEvt) + blurEvt = new FocusEvent "blur", { + bubble: false + cancelable: false + view: win + relatedTarget: null + } + + el.dispatchEvent(blurEvt) + el.dispatchEvent(focusoutEvt) fireFocus = (el) -> + ## body will never emit focus events + ## so we avoid simulating this + if $elements.isBody(el) + return + ## if we are focusing a different element ## dispatch any primed change events ## we have to do this because our blur @@ -77,11 +87,6 @@ create = (state) -> hasFocused = false - hasFocus = top.document.hasFocus() - - if not hasFocus - win.focus() - ## we need to bind to the focus event here ## because some browsers will not ever fire ## the focus event if the window itself is not @@ -98,34 +103,9 @@ create = (state) -> cleanup() - ## body will never emit focus events - ## so we avoid simulating this - if $elements.isBody(el) - return - ## fallback if our focus event never fires ## to simulate the focus + focusin if not hasFocused - simulate = -> - ## todo handle relatedTarget's per the spec - focusinEvt = new FocusEvent "focusin", { - bubbles: true - view: win - relatedTarget: null - } - - focusEvt = new FocusEvent "focus", { - view: win - relatedTarget: null - } - - ## not fired in the correct order per w3c spec - ## because chrome chooses to fire focus before focusin - ## and since we have a simulation fallback we end up - ## doing it how chrome does it - ## http://www.w3.org/TR/DOM-Level-3-Events/#h-events-focusevent-event-order - el.dispatchEvent(focusEvt) - el.dispatchEvent(focusinEvt) ## only blur if we have a focused element AND its not ## currently ourselves! @@ -136,50 +116,56 @@ create = (state) -> if not $window.isWindow(el) fireBlur($focused.get(0)) - simulate() + simulateFocusEvent(el, win) + + simulateFocusEvent = (el, win) -> + ## todo handle relatedTarget's per the spec + focusinEvt = new FocusEvent "focusin", { + bubbles: true + view: win + relatedTarget: null + } + + focusEvt = new FocusEvent "focus", { + view: win + relatedTarget: null + } + + ## not fired in the correct order per w3c spec + ## because chrome chooses to fire focus before focusin + ## and since we have a simulation fallback we end up + ## doing it how chrome does it + ## http://www.w3.org/TR/DOM-Level-3-Events/#h-events-focusevent-event-order + el.dispatchEvent(focusEvt) + el.dispatchEvent(focusinEvt) interceptFocus = (el, contentWindow, focusOption) -> - ## if our document does not have focus - ## then that means that we need to attempt to - ## bring our window into focus, and then figure - ## out if the browser fires the native focus - ## event - and if it doesn't, to flag this - ## element as needing focus on the next action - ## command - hasFocus = top.document.hasFocus() + ## normally programmatic focus calls cause "primed" focus/blur + ## events if the window is not in focus + ## so we fire fake events to act as if the window + ## is always in focus + $focused = getFocused() - if not hasFocus - contentWindow.focus() + if $elements.isFocusable($dom.wrap(el)) && (!$focused || $focused[0] isnt el) + fireFocus(el) + return - didReceiveFocus = false + $elements.callNativeMethod(el, 'focus') + return - onFocus = -> - didReceiveFocus = true + interceptBlur = (el) -> + ## normally programmatic blur calls cause "primed" focus/blur + ## events if the window is not in focus + ## so we fire fake events to act as if the window + ## is always in focus. + $focused = getFocused() - $elements.callNativeMethod(el, "addEventListener", "focus", onFocus) + if $focused && $focused[0] is el + fireBlur(el) + return - evt = $elements.callNativeMethod(el, "focus", focusOption) - - ## always unbind if added listener - if onFocus - $elements.callNativeMethod(el, "removeEventListener", "focus", onFocus) - - ## if we didn't receive focus - if not didReceiveFocus - ## then store this element as needing - ## force'd focus later on - state("needsForceFocus", el) - - return evt - - needsForceFocus = -> - ## if we have a primed focus event then - if needsForceFocus = state("needsForceFocus") - ## always reset it - state("needsForceFocus", null) - - ## and return whatever needs force focus - return needsForceFocus + $elements.callNativeMethod(el, 'blur') + return needsFocus = ($elToFocus, $previouslyFocusedEl) -> $focused = getFocused() @@ -225,7 +211,9 @@ create = (state) -> interceptFocus - needsForceFocus + interceptBlur, + + documentHasFocus, } module.exports = { diff --git a/packages/driver/src/cypress/cy.coffee b/packages/driver/src/cypress/cy.coffee index 8a80eda2ab..130aa6c6e2 100644 --- a/packages/driver/src/cypress/cy.coffee +++ b/packages/driver/src/cypress/cy.coffee @@ -154,11 +154,20 @@ create = (specWindow, Cypress, Cookies, state, config, log) -> contentWindow.HTMLElement.prototype.focus = (focusOption) -> focused.interceptFocus(this, contentWindow, focusOption) + contentWindow.HTMLElement.prototype.blur = -> + focused.interceptBlur(this) + + contentWindow.SVGElement.prototype.focus = (focusOption) -> + focused.interceptFocus(this, contentWindow, focusOption) + + contentWindow.SVGElement.prototype.blur = -> + focused.interceptBlur(this) + contentWindow.HTMLInputElement.prototype.select = -> $selection.interceptSelect.call(this) contentWindow.document.hasFocus = -> - top.document.hasFocus() + focused.documentHasFocus.call(@) enqueue = (obj) -> ## if we have a nestedIndex it means we're processing @@ -625,7 +634,6 @@ create = (specWindow, Cypress, Cookies, state, config, log) -> ## focused sync methods getFocused: focused.getFocused - needsForceFocus: focused.needsForceFocus needsFocus: focused.needsFocus fireFocus: focused.fireFocus fireBlur: focused.fireBlur diff --git a/packages/driver/src/dom/elements.js b/packages/driver/src/dom/elements.js index da4e6cd916..b57a55c3d7 100644 --- a/packages/driver/src/dom/elements.js +++ b/packages/driver/src/dom/elements.js @@ -25,8 +25,17 @@ const $utils = require('../cypress/utils') const fixedOrStickyRe = /(fixed|sticky)/ -const focusable = 'body,a[href],link[href],button,select,[tabindex],input,textarea,[contenteditable]' - +const focusable = [ + 'a[href]', + 'area[href]', + 'input:not([disabled])', + 'select:not([disabled])', + 'textarea:not([disabled])', + 'button:not([disabled])', + 'iframe', + '[tabindex]', + '[contentEditable]', +] const inputTypeNeedSingleValueChangeRe = /^(date|time|month|week)$/ const canSetSelectionRangeElementRe = /^(text|search|URL|tel|password)$/ @@ -197,6 +206,7 @@ const nativeMethods = { setSelectionRange: _nativeSetSelectionRange, modify: window.Selection.prototype.modify, focus: _nativeFocus, + hasFocus: window.document.hasFocus, blur: _nativeBlur, select: _nativeSelect, } @@ -354,7 +364,9 @@ const isElement = function (obj) { } const isFocusable = ($el) => { - return $el.is(focusable) + return _.some(focusable, (sel) => { + return $el.is(sel) + }) } const isType = function ($el, type) { diff --git a/packages/driver/src/dom/selection.js b/packages/driver/src/dom/selection.js index c0edef14fb..6e11045fb3 100644 --- a/packages/driver/src/dom/selection.js +++ b/packages/driver/src/dom/selection.js @@ -54,7 +54,7 @@ const _getSelectionBoundsFromContentEditable = function (el) { const range = sel.getRangeAt(0) //# if div[contenteditable] > text - const hostContenteditable = _getHostContenteditable(range.commonAncestorContainer) + const hostContenteditable = getHostContenteditable(range.commonAncestorContainer) if (hostContenteditable === el) { return { @@ -123,10 +123,16 @@ const _insertSubstring = (curText, newText, [start, end]) => { return curText.substring(0, start) + newText + curText.substring(end) } -const _getHostContenteditable = function (el) { +const _hasContenteditableAttr = (el) => { + const attr = $elements.tryCallNativeMethod(el, 'getAttribute', 'contenteditable') + + return attr !== undefined && attr !== null && attr !== 'false' +} + +const getHostContenteditable = function (el) { let curEl = el - while (curEl.parentElement && !$elements.tryCallNativeMethod(curEl, 'getAttribute', 'contenteditable')) { + while (curEl.parentElement && !_hasContenteditableAttr(curEl)) { curEl = curEl.parentElement } @@ -134,7 +140,7 @@ const _getHostContenteditable = function (el) { //# so act as if the original element is the host contenteditable //# TODO: remove this when we no longer click before type and move //# cursor to the end - if (!$elements.callNativeMethod(curEl, 'getAttribute', 'contenteditable')) { + if (!_hasContenteditableAttr(curEl)) { return el } @@ -451,7 +457,7 @@ const moveSelectionToEnd = function (el) { //# to selectAll and then collapse so we use the Selection API const doc = $document.getDocumentFromElement(el) const range = $elements.callNativeMethod(doc, 'createRange') - const hostContenteditable = _getHostContenteditable(el) + const hostContenteditable = getHostContenteditable(el) let lastTextNode = _getInnerLastChild(hostContenteditable) if (lastTextNode.tagName === 'BR') { @@ -593,6 +599,7 @@ module.exports = { deleteSelectionContents, moveSelectionToEnd, getCaretPosition, + getHostContenteditable, moveCursorLeft, moveCursorRight, moveCursorUp, diff --git a/packages/driver/test/cypress/fixtures/active-elements.html b/packages/driver/test/cypress/fixtures/active-elements.html index 2ae1177a4d..c05fc16c79 100644 --- a/packages/driver/test/cypress/fixtures/active-elements.html +++ b/packages/driver/test/cypress/fixtures/active-elements.html @@ -6,6 +6,10 @@ body { height: 300px; } + + *:focus { + outline: 4px solid blue + } diff --git a/packages/driver/test/cypress/integration/commands/actions/type_spec.coffee b/packages/driver/test/cypress/integration/commands/actions/type_spec.coffee index cdea1be3b8..6c3fe35813 100644 --- a/packages/driver/test/cypress/integration/commands/actions/type_spec.coffee +++ b/packages/driver/test/cypress/integration/commands/actions/type_spec.coffee @@ -2348,6 +2348,87 @@ describe "src/cy/commands/actions/type", -> it "respects being formatted by input event handlers" + it "accurately returns host contenteditable attr", -> + hostEl = cy.$$('
foo
').appendTo(cy.$$('body')) + cy.get('#ce-inner1').then ($el) -> + expect($selection.getHostContenteditable($el[0])).to.eq(hostEl[0]) + + it "accurately returns host contenteditable=true attr", -> + hostEl = cy.$$('
foo
').appendTo(cy.$$('body')) + cy.get('#ce-inner1').then ($el) -> + expect($selection.getHostContenteditable($el[0])).to.eq(hostEl[0]) + + it "accurately returns host contenteditable=\"\" attr", -> + hostEl = cy.$$('
foo
').appendTo(cy.$$('body')) + cy.get('#ce-inner1').then ($el) -> + expect($selection.getHostContenteditable($el[0])).to.eq(hostEl[0]) + + it "accurately returns host contenteditable=\"foo\" attr", -> + hostEl = cy.$$('
foo
').appendTo(cy.$$('body')) + cy.get('#ce-inner1').then ($el) -> + expect($selection.getHostContenteditable($el[0])).to.eq(hostEl[0]) + + it "accurately returns same el with no falsey contenteditable=\"false\" attr", -> + hostEl = cy.$$('
foo
').appendTo(cy.$$('body')) + cy.get('#ce-inner1').then ($el) -> + expect($selection.getHostContenteditable($el[0])).to.eq($el[0]) + + + ## https://github.com/cypress-io/cypress/issues/3001 + describe('skip actionability if already focused', () => + it('inside input', () => + cy.$$('body').append(Cypress.$(' +
+ + ')) + + cy.$$('#foo').focus() + + + cy.focused().type('new text').should('have.prop', 'value', 'new text') + ) + + it('inside textarea', () => + + cy.$$('body').append(Cypress.$(' +
\ + + ')) + + cy.$$('#foo').focus() + + + cy.focused().type('new text').should('have.prop', 'value', 'new text') + ) + + it('inside contenteditable', () => + + cy.$$('body').append(Cypress.$(' +
+
+
foo
bar
baz
+
+ ')) + win = cy.state('window') + doc = window.document + + cy.$$('#foo').focus() + inner = cy.$$('div:contains(bar):last') + + range = doc.createRange() + + range.selectNodeContents(inner[0]) + sel = win.getSelection() + + sel.removeAllRanges() + + sel.addRange(range) + + + cy.get('div:contains(bar):last').type('new text').should('have.prop', 'innerText', 'new text') + ) + ) + it "can arrow from maxlength", -> cy.get('input:first').invoke('attr', 'maxlength', "5").type('foobar{leftarrow}') cy.window().then (win) -> diff --git a/packages/driver/test/cypress/integration/e2e/focus_blur_spec.coffee b/packages/driver/test/cypress/integration/e2e/focus_blur_spec.coffee deleted file mode 100644 index 95fdbce29e..0000000000 --- a/packages/driver/test/cypress/integration/e2e/focus_blur_spec.coffee +++ /dev/null @@ -1,275 +0,0 @@ -## in all browsers... -## -## activeElement is always programmatically respected and behaves identical whether window is in or out of focus -## -## browser: chrome... -## -## scenario 1: given '#one' is activeElement call programmatic el.focus() on '#two' -## - if window is in focus -## - blur will fire on '#one' -## - focus will fire on '#two' -## - if window is out of focus (the event wil be primed until the window receives focus again) -## - by clicking anywhere on the (not on the element)... -## - focus on '#two' will fire first -## - blur on '#two' will fire second -## - activeElement will now be -## - by clicking on another element that is focusable -## - focus on '#two' is first sent -## - blur on '#two' is then sent -## - focus is finally sent on the new focusable element we clicked -## - if instead on clicking we programmatically call .focus() back to '#one' -## - focus is fired on '#one' -## - if we were to instead click directly on '#one' then no focus or blur events are fired -## - if when clicking directly back to '#one' we prevent the 'mousedown' event -## - the focus event will fire AND the element will still be activeElement -## - had we not programmatically call .focus() ahead of time, then the focus event would -## have been not fired, and our activeElement would not have changed -## -## scenario 2 : given '#one' is activeElement call programmatic el.blur() on '#one' -## - if window is in focus -## - blur will fire on '#one' -## - if window is out of focus -## - no events will ever fire even when regaining focus -## -## browser: firefox... -## - no focus events are queued when programmatically calling element.focus() AND the window is out of focus. the events evaporate into the ether. -## - however, if calling window.focus() programmatically prior to programmatic element.focus() calls will fire all events as if the window is natively in focus - -{ _ } = Cypress - -it "sends delayed focus when programmatically invoked during action commands", -> - cy - .visit("http://localhost:3500/fixtures/active-elements.html") - .then -> - events = [] - - expect(cy.getFocused()).to.be.null - - hasFocus = top.document.hasFocus() - - ## programmatically focus the first input element - $input = cy.$$("input:first") - - $input.on "focus", (e) -> - events.push(e.originalEvent) - - ## when we mousedown on the input - ## prevent default so that we can test - ## that the force focus event is set ahead - ## of time - $input.on "mousedown", (e) -> - e.preventDefault() - - $input.get(0).focus() - - cy - .log('top.document.hasFocus()', hasFocus) - .then -> - if hasFocus - ## if we currently have focus it means - ## that the browser should fire the - ## native event immediately - expect(cy.state("needsForceFocus")).to.be.undefined - expect(events).to.have.length(1) - expect(events[0].isTrusted).to.be.true - else - expect(cy.state("needsForceFocus")).to.eq($input.get(0)) - expect(events).to.be.empty - - expect(cy.getFocused().get(0)).to.eq($input.get(0)) - - cy - .get("input:first").click() - .then -> - expect(cy.getFocused().get(0)).to.eq($input.get(0)) - - cy - .log('top.document.hasFocus()', hasFocus) - .then -> - if hasFocus - ## if we had focus then no additional - ## focus event is necessary - expect(events).to.have.length(1) - expect(events[0].isTrusted).to.be.true - else - expect(cy.state("needsForceFocus")).to.be.null - - ## we polyfill the focus event manually - expect(events).to.have.length(1) - expect(events[0].isTrusted).to.be.false - -it 'sends programmatic blur when delayed due to window being out of focus', -> - cy - .visit("http://localhost:3500/fixtures/active-elements.html") - .then -> - events = [] - - expect(cy.getFocused()).to.be.null - - hasFocus = top.document.hasFocus() - - ## programmatically focus the first input element - $one = cy.$$("#one") - $two = cy.$$("#two") - - ["focus", "blur"].forEach (evt) -> - $one.on evt, (e) -> - events.push(e.originalEvent) - - $two.on evt, (e) -> - events.push(e.originalEvent) - - ## when we mousedown on the input - ## prevent default so that we can test - ## that the force focus event is set ahead - ## of time - $two.on "mousedown", (e) -> - e.preventDefault() - - $one.get(0).focus() - - cy - .log('top.document.hasFocus()', hasFocus) - .then -> - if hasFocus - ## if we currently have focus it means - ## that the browser should fire the - ## native event immediately - expect(cy.state("needsForceFocus")).to.be.undefined - expect(events).to.have.length(1) - expect(events[0].isTrusted).to.be.true - else - expect(cy.state("needsForceFocus")).to.eq($one.get(0)) - expect(events).to.be.empty - - expect(cy.getFocused().get(0)).to.eq($one.get(0)) - - cy - .get("#one").click() - .then -> - expect(cy.getFocused().get(0)).to.eq($one.get(0)) - - cy - .log('top.document.hasFocus()', hasFocus) - .then -> - if hasFocus - ## if we had focus then no additional - ## focus event is necessary - expect(events).to.have.length(1) - expect(events[0].isTrusted).to.be.true - else - expect(cy.state("needsForceFocus")).to.be.null - - ## we polyfill the focus event manually - expect(events).to.have.length(1) - expect(events[0].isTrusted).to.be.false - -it "blur the activeElement when clicking the body", -> - cy - .visit("http://localhost:3500/fixtures/active-elements.html") - .then -> - events = [] - - expect(cy.getFocused()).to.be.null - - doc = cy.state("document") - - hasFocus = top.document.hasFocus() - - ## programmatically focus the first, then second input element - $body = cy.$$("body") - $one = cy.$$("#one") - $two = cy.$$("#two") - - ["focus", "blur"].forEach (evt) -> - $one.on evt, (e) -> - events.push(e.originalEvent) - - $two.on evt, (e) -> - events.push(e.originalEvent) - - $one.get(0).focus() - $two.get(0).focus() - - cy - .log('top.document.hasFocus()', hasFocus) - .then -> - if hasFocus - ## if we currently have focus it means - ## that the browser should fire the - ## native event immediately - expect(cy.state("needsForceFocus")).to.be.undefined - expect(events).to.have.length(3) - - expect(_.toPlainObject(events[0])).to.include({ - type: "focus" - isTrusted: true - target: $one.get(0) - }) - - expect(_.toPlainObject(events[1])).to.include({ - type: "blur" - isTrusted: true - target: $one.get(0) - }) - - expect(_.toPlainObject(events[2])).to.include({ - type: "focus" - isTrusted: true - target: $two.get(0) - }) - else - expect(cy.state("needsForceFocus")).to.eq($two.get(0)) - expect(events).to.be.empty - - expect(cy.getFocused().get(0)).to.eq($two.get(0)) - - cy - .get("body").click() - .then -> - expect(doc.activeElement).to.eq($body.get(0)) - - cy - .log('top.document.hasFocus()', hasFocus) - .then -> - if hasFocus - ## if we had focus then no additional - ## focus event is necessary - expect(events).to.have.length(4) - - expect(_.toPlainObject(events[3])).to.include({ - type: "blur" - isTrusted: true - target: $two.get(0) - }) - else - expect(cy.state("needsForceFocus")).to.be.null - - ## we polyfill the focus event manually - expect(events).to.have.length(2) - - expect(_.toPlainObject(events[0])).to.include({ - type: "focus" - isTrusted: false - target: $two.get(0) - }) - expect(_.toPlainObject(events[1])).to.include({ - type: "blur" - isTrusted: false - target: $two.get(0) - }) - -it 'opens the dropdown by force firing focus events', -> - cy - .visit('http://jedwatson.github.io/react-select/') - .then -> - hasFocus = top.document.hasFocus() - cy - .log('top.document.hasFocus()', hasFocus) - - cy - .get('#state-select') - .get('div.Select-value:first') - .click() - .get('.Select-option:contains(Victoria)') - .click() diff --git a/packages/driver/test/cypress/integration/e2e/focus_blur_spec.js b/packages/driver/test/cypress/integration/e2e/focus_blur_spec.js new file mode 100644 index 0000000000..e865a68e5b --- /dev/null +++ b/packages/driver/test/cypress/integration/e2e/focus_blur_spec.js @@ -0,0 +1,673 @@ +/// +/* eslint arrow-body-style:'off' */ + +/** + * in all browsers... + * + * activeElement is always programmatically respected and behaves identical whether window is in or out of focus + * + * browser: chrome... + * + * scenario 1: given '#one' is activeElement call programmatic el.focus() on '#two' + * - if window is in focus + * - blur will fire on '#one' + * - focus will fire on '#two' + * - if window is out of focus (the event wil be primed until the window receives focus again) + * - by clicking anywhere on the (not on the element)... + * - focus on '#two' will fire first + * - blur on '#two' will fire second + * - activeElement will now be + * - by clicking on another element that is focusable + * - focus on '#two' is first sent + * - blur on '#two' is then sent + * - focus is finally sent on the new focusable element we clicked + * - if instead on clicking we programmatically call .focus() back to '#one' + * - focus is fired on '#one' + * - if we were to instead click directly on '#one' then no focus or blur events are fired + * - if when clicking directly back to '#one' we prevent the 'mousedown' event + * - the focus event will fire AND the element will still be activeElement + * - had we not programmatically call .focus() ahead of time, then the focus event would + * have been not fired, and our activeElement would not have changed + * + * scenario 2 : given '#one' is activeElement call programmatic el.blur() on '#one' + * - if window is in focus + * - blur will fire on '#one' + * - if window is out of focus + * - no events will ever fire even when regaining focus + + * browser: firefox... + * - no focus events are queued when programmatically calling element.focus() AND the window is out of focus. the events evaporate into the ether. + * - however, if calling window.focus() programmatically prior to programmatic element.focus() calls will fire all events as if the window is natively in focus +*/ +const { _ } = Cypress + +const chaiSubset = require('chai-subset') + +chai.use(chaiSubset) + +const windowHasFocus = function () { + if (document.hasFocus()) return true + + let hasFocus = false + + window.addEventListener('focus', function () { + hasFocus = true + }) + window.focus() + + return hasFocus +} + +const requireWindowInFocus = () => { + let hasFocus = windowHasFocus() + + if (!hasFocus) { + expect(hasFocus, 'this test requires the window to be in focus').ok + } + +} + +it('can intercept blur/focus events', () => { + // Browser must be in focus + + const focus = cy.spy(window.top.HTMLElement.prototype, 'focus') + const blur = cy.spy(window.top.HTMLElement.prototype, 'blur') + + const handleFocus = cy.stub().as('handleFocus') + const handleBlur = cy.stub().as('handleBlur') + + const resetStubs = () => { + focus.reset() + blur.reset() + handleFocus.reset() + handleBlur.reset() + } + + cy + .visit('http://localhost:3500/fixtures/active-elements.html') + .then(() => { + + requireWindowInFocus() + + expect(cy.getFocused()).to.be.null + + // programmatically focus the first, then second input element + + const one = cy.$$('#one')[0] + const two = cy.$$('#two')[0] + + one.addEventListener('focus', handleFocus) + two.addEventListener('focus', handleFocus) + one.addEventListener('blur', handleBlur) + two.addEventListener('blur', handleBlur) + + one.focus() + + expect(focus).to.calledOnce + expect(handleFocus).calledOnce + expect(blur).not.called + expect(handleBlur).not.called + + resetStubs() + + one.focus() + + expect(focus).to.calledOnce + expect(handleFocus).not.called + expect(blur).not.called + expect(handleBlur).not.called + + resetStubs() + + one.blur() + + expect(blur).calledOnce + expect(handleBlur).calledOnce + + resetStubs() + + one.blur() + + expect(blur).calledOnce + expect(handleBlur).not.called + + }) + +}) + +it('blur the activeElement when clicking the body', () => { + cy + .visit('http://localhost:3500/fixtures/active-elements.html') + .then(() => { + const events = [] + + expect(cy.getFocused()).to.be.null + + const doc = cy.state('document') + + // programmatically focus the first, then second input element + const $body = cy.$$('body') + const $one = cy.$$('#one') + const $two = cy.$$('#two'); + + ['focus', 'blur'].forEach((evt) => { + $one.on(evt, (e) => { + events.push(e.originalEvent) + }) + + $two.on(evt, (e) => { + events.push(e.originalEvent) + }) + }) + + $one.get(0).focus() + $two.get(0).focus() + + cy.then(() => { + // if we currently have focus it means + // that the browser should fire the + // native event immediately + expect(events).to.have.length(3) + + expect(_.toPlainObject(events[0])).to.include({ + type: 'focus', + isTrusted: true, + target: $one.get(0), + }) + + expect(_.toPlainObject(events[1])).to.include({ + type: 'blur', + isTrusted: true, + target: $one.get(0), + }) + + expect(_.toPlainObject(events[2])).to.include({ + type: 'focus', + isTrusted: true, + target: $two.get(0), + }) + }) + + cy + .get('body').click() + .then(() => { + expect(doc.activeElement).to.eq($body.get(0)) + }) + + cy.then(() => { + // if we had focus then no additional + // focus event is necessary + expect(events).to.have.length(4) + + expect(_.toPlainObject(events[3])).to.include({ + type: 'blur', + isTrusted: true, + target: $two.get(0), + }) + }) + }) +}) + +describe('polyfill programmatic blur events', () => { + // restore these props for the rest of the tests + let stubElementFocus + let stubElementBlur + let stubSVGFocus + let stubSVGBlur + let stubHasFocus + let oldActiveElement = null + + const setActiveElement = (el) => { + Object.defineProperty(cy.state('document'), 'activeElement', { + get () { + return el + }, + configurable: true, + }) + } + + beforeEach(() => { + oldActiveElement = Object.getOwnPropertyDescriptor(window.Document.prototype, 'activeElement') + + // simulate window being out of focus by overwriting + // the focus/blur methods on HTMLElement + stubHasFocus = cy.stub(window.top.document, 'hasFocus').returns(false) + + stubElementFocus = cy.stub(window.top.HTMLElement.prototype, 'focus') + stubElementBlur = cy.stub(window.top.HTMLElement.prototype, 'blur') + stubSVGFocus = cy.stub(window.top.SVGElement.prototype, 'focus') + stubSVGBlur = cy.stub(window.top.SVGElement.prototype, 'blur') + }) + + afterEach(() => { + Object.defineProperty(window.Document.prototype, 'activeElement', oldActiveElement) + stubHasFocus.restore() + stubElementFocus.restore() + stubElementBlur.restore() + stubSVGFocus.restore() + stubSVGBlur.restore() + }) + + // https://github.com/cypress-io/cypress/issues/1486 + it('simulated events when window is out of focus when .focus called', () => { + cy + .visit('http://localhost:3500/fixtures/active-elements.html') + .then(() => { + + // programmatically focus the first, then second input element + const $one = cy.$$('#one') + const $two = cy.$$('#two') + + const stub = cy.stub().as('focus/blur event').callsFake(() => { + Cypress.log({}) + }); + + ['focus', 'blur'].forEach((evt) => { + $one.on(evt, stub) + + return $two.on(evt, stub) + }) + + $one.get(0).focus() + // a hack here becuase we nuked the real .focus + setActiveElement($one.get(0)) + + $two.get(0).focus() + // cy.get('#two').click() + + const getEvent = (n) => { + return stub.getCall(n).args[0].originalEvent + } + + cy.wrap(null).then(() => { + expect(stub).to.be.calledThrice + + expect(getEvent(0)).to.containSubset({ + type: 'focus', + target: $one.get(0), + isTrusted: false, + }) + + expect(getEvent(1)).to.containSubset({ + type: 'blur', + target: $one.get(0), + isTrusted: false, + }) + + expect(getEvent(2)).to.containSubset({ + type: 'focus', + target: $two.get(0), + isTrusted: false, + }) + }) + .then(() => { + + stub.reset() + + setActiveElement($two.get(0)) + + $two.get(0).focus() + + expect(stub, 'should not send focus if already focused el').not.called + }) + }) + } + ) + + // https://github.com/cypress-io/cypress/issues/1176 + it('simulated events when window is out of focus when .blur called', () => { + cy + .visit('http://localhost:3500/fixtures/active-elements.html') + .then(() => { + // programmatically focus the first, then second input element + const $one = cy.$$('#one') + const $two = cy.$$('#two') + + const stub = cy.stub().as('focus/blur event'); + + ['focus', 'blur'].forEach((evt) => { + $one.on(evt, stub) + + $two.on(evt, stub) + }) + + $one.get(0).focus() + + // a hack here becuase we nuked the real .focus + setActiveElement($one.get(0)) + + $one.get(0).blur() + + cy.then(() => { + + expect(stub).calledTwice + + expect(_.toPlainObject(stub.getCall(0).args[0].originalEvent)).to.containSubset({ + type: 'focus', + target: $one.get(0), + isTrusted: false, + }) + + expect(_.toPlainObject(stub.getCall(1).args[0].originalEvent)).to.containSubset({ + type: 'blur', + target: $one.get(0), + isTrusted: false, + }) + }) + + .then(() => { + + stub.reset() + + setActiveElement(cy.$$('body').get(0)) + + $one.get(0).blur() + + expect(stub, 'should not send blur if not focused el').not.called + }) + + }) + }) + + // https://github.com/cypress-io/cypress/issues/1486 + it('SVGElement simulated events when window is out of focus when .focus called', () => { + cy + .visit('http://localhost:3500/fixtures/active-elements.html') + .then(() => { + + // programmatically focus the first, then second input element + + const $one = cy.$$(` + + `).appendTo(cy.$$('body')) + const $two = cy.$$(` + + `).appendTo(cy.$$('body')) + + const stub = cy.stub().as('focus/blur event').callsFake(() => { + Cypress.log({}) + }); + + ['focus', 'blur'].forEach((evt) => { + $one.on(evt, stub) + + return $two.on(evt, stub) + }) + + $one.get(0).focus() + // a hack here becuase we nuked the real .focus + setActiveElement($one.get(0)) + + $two.get(0).focus() + // cy.get('#two').click() + + const getEvent = (n) => { + return stub.getCall(n).args[0].originalEvent + } + + cy.wrap(null).then(() => { + expect(stub).to.be.calledThrice + + expect(getEvent(0)).to.containSubset({ + type: 'focus', + target: $one.get(0), + isTrusted: false, + }) + + expect(getEvent(1)).to.containSubset({ + type: 'blur', + target: $one.get(0), + isTrusted: false, + }) + + expect(getEvent(2)).to.containSubset({ + type: 'focus', + target: $two.get(0), + isTrusted: false, + }) + }) + .then(() => { + + stub.reset() + + setActiveElement($two.get(0)) + + $two.get(0).focus() + + expect(stub, 'should not send focus if already focused el').not.called + }) + }) + } + ) + + // https://github.com/cypress-io/cypress/issues/1176 + it('SVGElement simulated events when window is out of focus when .blur called', () => { + cy + .visit('http://localhost:3500/fixtures/active-elements.html') + .then(() => { + // programmatically focus the first, then second input element + + const $one = cy.$$(` + + `).appendTo(cy.$$('body')) + const $two = cy.$$(` + + `).appendTo(cy.$$('body')) + const stub = cy.stub().as('focus/blur event'); + + ['focus', 'blur'].forEach((evt) => { + $one.on(evt, stub) + + $two.on(evt, stub) + }) + + $one.get(0).focus() + + // a hack here becuase we nuked the real .focus + setActiveElement($one.get(0)) + + $one.get(0).blur() + + cy.then(() => { + + expect(stub).calledTwice + + expect(_.toPlainObject(stub.getCall(0).args[0].originalEvent)).to.containSubset({ + type: 'focus', + target: $one.get(0), + isTrusted: false, + }) + + expect(_.toPlainObject(stub.getCall(1).args[0].originalEvent)).to.containSubset({ + type: 'blur', + target: $one.get(0), + isTrusted: false, + }) + }) + + .then(() => { + + stub.reset() + + setActiveElement(cy.$$('body').get(0)) + + $one.get(0).blur() + + expect(stub, 'should not send blur if not focused el').not.called + }) + + }) + }) + + it('document.hasFocus() always returns true', () => { + cy.visit('http://localhost:3500/fixtures/active-elements.html') + cy.document().then((doc) => { + expect(doc.hasFocus(), 'hasFocus returns true').eq(true) + }) + }) + + it('does not send focus events for non-focusable elements', () => { + cy.visit('http://localhost:3500/fixtures/active-elements.html') + .then(() => { + + cy.$$('
clearly not a focusable element
') + .appendTo(cy.$$('body')) + + const stub = cy.stub() + const el1 = cy.$$('#no-focus') + const win = cy.$$(cy.state('window')) + + win.on('focus', stub) + el1.on('focus', stub) + el1[0].focus() + + expect(stub).not.called + + }) + + }) +}) + +describe('intercept blur methods correctly', () => { + beforeEach(() => { + cy.visit('http://localhost:3500/fixtures/active-elements.html').then(() => { + // cy.$$('input:first').focus() + // cy.$$('body').focus() + cy.state('document').onselectionchange = cy.stub().as('selectionchange') + }) + }) + it('focus
', () => { + const $el = cy.$$('foo') + + // + $el.appendTo(cy.$$('body')) + // cy.$$('input').focus() + + // $el[0].focus() + cy.wrap($el[0]).focus() + .should('have.focus') + + cy.wait(0).get('@selectionchange').should('not.be.called') + + }) + it('focus ') + + $el.appendTo(cy.$$('body')) + $el[0].focus() + cy.wrap($el[0]).focus() + .should('have.focus') + + cy.wait(0).get('@selectionchange').should('not.be.called') + + }) + it('focus