From 55fcc8e8996052ee12f36b96ece808bf93f12eba Mon Sep 17 00:00:00 2001 From: Brian Mann Date: Sat, 16 Jun 2018 04:13:29 -0400 Subject: [PATCH] fixes #1909, do not error timing out when activeElements change out from under Cypress when window is out of focus - refactored getting the current focused element to be synchronous - remove the special handling of force focused or force blurred element state - just always use AUT document.activeElement - clean up tests --- .../src/cy/commands/actions/click.coffee | 74 +++--- .../src/cy/commands/actions/focus.coffee | 214 +++++++++--------- .../driver/src/cy/commands/querying.coffee | 36 +-- packages/driver/src/cy/focused.coffee | 29 +++ packages/driver/src/cypress/cy.coffee | 5 + .../driver/test/cypress/fixtures/mui.html | 25 ++ .../commands/actions/focus_spec.coffee | 63 +++--- .../integration/commands/querying_spec.coffee | 57 +---- .../cypress/integration/issues/1909_spec.js | 25 ++ 9 files changed, 272 insertions(+), 256 deletions(-) create mode 100644 packages/driver/src/cy/focused.coffee create mode 100644 packages/driver/test/cypress/fixtures/mui.html create mode 100644 packages/driver/test/cypress/integration/issues/1909_spec.js diff --git a/packages/driver/src/cy/commands/actions/click.coffee b/packages/driver/src/cy/commands/actions/click.coffee index 07d8f2e2591e..d61f888def78 100644 --- a/packages/driver/src/cy/commands/actions/click.coffee +++ b/packages/driver/src/cy/commands/actions/click.coffee @@ -182,44 +182,44 @@ module.exports = (Commands, Cypress, cy, state, config) -> onReady: ($elToClick, coords) -> ## TODO: get focused through a callback here - cy.now("focused", {log: false, verify: false}) - .then ($focused) -> - ## record the previously focused element before - ## issuing the mousedown because browsers may - ## automatically shift the focus to the element - ## without firing the focus event - $previouslyFocusedEl = $focused - - domEvents.mouseDown = $Mouse.mouseDown($elToClick, coords.fromViewport) - - ## if mousedown was cancelled then or caused - ## our element to be removed from the DOM - ## just resolve after mouse down and dont - ## send a focus event - if domEvents.mouseDown.preventedDefault or not $dom.isAttached($elToClick) - afterMouseDown($elToClick, coords) + $focused = cy.getFocused() + + ## record the previously focused element before + ## issuing the mousedown because browsers may + ## automatically shift the focus to the element + ## without firing the focus event + $previouslyFocusedEl = $focused + + domEvents.mouseDown = $Mouse.mouseDown($elToClick, coords.fromViewport) + + ## if mousedown was cancelled then or caused + ## our element to be removed from the DOM + ## just resolve after mouse down and dont + ## send a focus event + if domEvents.mouseDown.preventedDefault or not $dom.isAttached($elToClick) + afterMouseDown($elToClick, coords) + else + ## retrieve the first focusable $el in our parent chain + $elToFocus = getFirstFocusableEl($elToClick) + + $focused = cy.getFocused() + + if shouldFireFocusEvent($focused, $elToFocus) + ## if our mousedown went through and + ## we are focusing a different element + ## dispatch any primed change events + ## we have to do this because our blur + ## method might not get triggered if + ## our window is in focus since the + ## browser may fire blur events naturally + $actionability.dispatchPrimedChangeEvents(state) + + ## send in a focus event! + cy.now("focus", $elToFocus, {$el: $elToFocus, error: false, verify: false, log: false}) + .then -> + afterMouseDown($elToClick, coords) else - ## retrieve the first focusable $el in our parent chain - $elToFocus = getFirstFocusableEl($elToClick) - - cy.now("focused", {log: false, verify: false}) - .then ($focused) => - if shouldFireFocusEvent($focused, $elToFocus) - ## if our mousedown went through and - ## we are focusing a different element - ## dispatch any primed change events - ## we have to do this because our blur - ## method might not get triggered if - ## our window is in focus since the - ## browser may fire blur events naturally - $actionability.dispatchPrimedChangeEvents(state) - - ## send in a focus event! - cy.now("focus", $elToFocus, {$el: $elToFocus, error: false, verify: false, log: false}) - .then -> - afterMouseDown($elToClick, coords) - else - afterMouseDown($elToClick, coords) + afterMouseDown($elToClick, coords) }) .catch (err) -> ## snapshot only on click failure diff --git a/packages/driver/src/cy/commands/actions/focus.coffee b/packages/driver/src/cy/commands/actions/focus.coffee index 1d77751d67f8..12b4446578d6 100644 --- a/packages/driver/src/cy/commands/actions/focus.coffee +++ b/packages/driver/src/cy/commands/actions/focus.coffee @@ -63,13 +63,7 @@ module.exports = (Commands, Cypress, cy, state, config) -> focused = -> hasFocused = true - - ## set this back to null unless we are - ## force focused ourselves during this command - forceFocusedEl = cy.state("forceFocusedEl") - if forceFocusedEl isnt options.$el.get(0) - cy.state("forceFocusedEl", null) - + cleanup() cy.timeout($actionability.delay, true) @@ -83,6 +77,10 @@ module.exports = (Commands, Cypress, cy, state, config) -> options.$el.on("focus", focused) + ## store the currently focused item + ## for later use if necessary to simulate events + $focused = cy.getFocused() + ## does this synchronously fire? ## if it does we don't need this ## lower promise @@ -98,8 +96,6 @@ module.exports = (Commands, Cypress, cy, state, config) -> simulate = -> win = cy.state("window") - cy.state("forceFocusedEl", options.$el.get(0)) - ## todo handle relatedTarget's per the spec focusinEvt = new FocusEvent "focusin", { bubbles: true @@ -120,21 +116,25 @@ module.exports = (Commands, Cypress, cy, state, config) -> options.$el.get(0).dispatchEvent(focusEvt) options.$el.get(0).dispatchEvent(focusinEvt) - cy.now("focused", {log: false, verify: false}).then ($focused) -> - ## only blur if we have a focused element AND its not - ## currently ourselves! - if $focused and $focused.get(0) isnt options.$el.get(0) - - cy.now("blur", $focused, {$el: $focused, error: false, verify: false, log: false}) - .then -> - simulate() - else + ## only blur if we have a focused element AND its not + ## currently ourselves! + if $focused and $focused.get(0) isnt options.$el.get(0) + ## TODO: oh god... so bad, please fixme + cy.now("blur", $focused, { + $focused + $el: $focused, + error: false, + verify: false, + log: false + }) + .then -> simulate() + else + simulate() - ## need to catch potential errors from blur - ## here and reject the promise - .catch (err) -> - reject(err) + ## need to catch potential errors from blur + ## here and reject the promise + .catch(reject) promise .timeout(timeout) @@ -157,11 +157,14 @@ module.exports = (Commands, Cypress, cy, state, config) -> ## but allow them to be silenced _.defaults(options, { $el: subject + $focused: cy.getFocused() error: true log: true verify: true force: false }) + + { $el, $focused } = options if isWin = $dom.isWindow(options.$el) ## get this into a jquery object @@ -185,108 +188,105 @@ module.exports = (Commands, Cypress, cy, state, config) -> args: { num } }) - cy.now("focused", {log: false, verify: false}).then ($focused) -> - ## if we haven't forced the blur, and we don't currently - ## have a focused element OR we aren't the window then error - if (options.force isnt true) and (not $focused) and (not isWin) - return if options.error is false - - $utils.throwErrByPath("blur.no_focused_element", { onFail: options._log }) - - ## if we're currently window dont check for the wrong - ## focused element because window will not show up - ## as $focused - if (options.force isnt true) and (not isWin) and ( - options.$el.get(0) isnt $focused.get(0) - ) - return if options.error is false - - node = $dom.stringify($focused) - $utils.throwErrByPath("blur.wrong_focused_element", { - onFail: options._log - args: { node } - }) - - timeout = cy.timeout() * .90 - - cleanup = null - hasBlurred = false + ## if we haven't forced the blur, and we don't currently + ## have a focused element OR we aren't the window then error + if (options.force isnt true) and (not $focused) and (not isWin) + return if options.error is false - promise = new Promise (resolve) -> - ## 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 - ## currently focused. so we have to tell our users - ## to do just that! - cleanup = -> - options.$el.off("blur", blurred) + $utils.throwErrByPath("blur.no_focused_element", { onFail: options._log }) - blurred = -> - hasBlurred = true + ## if we're currently window dont check for the wrong + ## focused element because window will not show up + ## as $focused + if (options.force isnt true) and (not isWin) and ( + options.$el.get(0) isnt $focused.get(0) + ) + return if options.error is false - ## set this back to null unless we are - ## force blurring ourselves during this command - blacklistFocusedEl = cy.state("blacklistFocusedEl") - cy.state("blacklistFocusedEl", null) unless blacklistFocusedEl is options.$el.get(0) + node = $dom.stringify($focused) + $utils.throwErrByPath("blur.wrong_focused_element", { + onFail: options._log + args: { node } + }) - cleanup() + timeout = cy.timeout() * .90 - cy.timeout($actionability.delay, true) + cleanup = null + hasBlurred = false - Promise - .delay($actionability.delay) - .then(resolve) + promise = new Promise (resolve) -> + ## 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 + ## currently focused. so we have to tell our users + ## to do just that! + cleanup = -> + options.$el.off("blur", blurred) - options.$el.on("blur", blurred) + blurred = -> + hasBlurred = true - ## for simplicity we allow change events - ## to be triggered by a manual blur - $actionability.dispatchPrimedChangeEvents(state) + cleanup() - options.$el.get(0).blur() + cy.timeout($actionability.delay, true) Promise - .resolve(null) - .then -> - ## fallback if our blur event never fires - ## to simulate the blur + focusout - return if hasBlurred + .delay($actionability.delay) + .then(resolve) - win = cy.state("window") + options.$el.on("blur", blurred) - cy.state("blacklistFocusedEl", options.$el.get(0)) + ## for simplicity we allow change events + ## to be triggered by a manual blur + $actionability.dispatchPrimedChangeEvents(state) - ## todo handle relatedTarget's per the spec - focusoutEvt = new FocusEvent "focusout", { - bubbles: true - cancelable: false - view: win - relatedTarget: null - } + ## NOTE: we could likely check document.hasFocus() + ## here and expect that blur events are not fired + ## when the window is not in focus. that would prevent + ## us from making blur + focus async since we wait + ## immediately below + options.$el.get(0).blur() - blurEvt = new FocusEvent "blur", { - bubble: false - cancelable: false - view: win - relatedTarget: null - } - - options.$el.get(0).dispatchEvent(blurEvt) - options.$el.get(0).dispatchEvent(focusoutEvt) + Promise + .resolve(null) + .then -> + ## fallback if our blur event never fires + ## to simulate the blur + focusout + return if hasBlurred + + win = cy.state("window") + + ## todo handle relatedTarget's per the spec + focusoutEvt = new FocusEvent "focusout", { + bubbles: true + cancelable: false + view: win + relatedTarget: null + } + + blurEvt = new FocusEvent "blur", { + bubble: false + cancelable: false + view: win + relatedTarget: null + } + + options.$el.get(0).dispatchEvent(blurEvt) + options.$el.get(0).dispatchEvent(focusoutEvt) - promise - .timeout(timeout) - .catch Promise.TimeoutError, (err) -> - cleanup() + promise + .timeout(timeout) + .catch Promise.TimeoutError, (err) -> + cleanup() - return if options.error is false + return if options.error is false - $utils.throwErrByPath "blur.timed_out", { onFail: command } - .then -> - return options.$el if options.verify is false + $utils.throwErrByPath "blur.timed_out", { onFail: command } + .then -> + return options.$el if options.verify is false - do verifyAssertions = -> - cy.verifyUpcomingAssertions(options.$el, options, { - onRetry: verifyAssertions - }) + do verifyAssertions = -> + cy.verifyUpcomingAssertions(options.$el, options, { + onRetry: verifyAssertions + }) }) diff --git a/packages/driver/src/cy/commands/querying.coffee b/packages/driver/src/cy/commands/querying.coffee index 2ad138e789a8..05102051cb1d 100644 --- a/packages/driver/src/cy/commands/querying.coffee +++ b/packages/driver/src/cy/commands/querying.coffee @@ -45,38 +45,14 @@ module.exports = (Commands, Cypress, cy, state, config) -> }) getFocused = -> - try - forceFocusedEl = cy.state("forceFocusedEl") - if forceFocusedEl - if $dom.isAttached(forceFocusedEl) - el = forceFocusedEl - else - cy.state("forceFocusedEl", null) - else - el = cy.state("document").activeElement - - ## return null if we have an el but - ## the el is body or the el is currently the - ## blacklist focused el - if el and el isnt cy.state("blacklistFocusedEl") - el = $dom.wrap(el) - - if el.is("body") - log(null) - return null - - log(el) - return el - else - log(null) - return null - - catch - log(null) - return null + focused = cy.getFocused() + log(focused) + + return focused do resolveFocused = (failedByNonAssertion = false) -> - Promise.try(getFocused) + Promise + .try(getFocused) .then ($el) -> if options.verify is false return $el diff --git a/packages/driver/src/cy/focused.coffee b/packages/driver/src/cy/focused.coffee new file mode 100644 index 000000000000..33172463c11f --- /dev/null +++ b/packages/driver/src/cy/focused.coffee @@ -0,0 +1,29 @@ +$dom = require("../dom") + +create = (state) -> + return { + getFocused: -> + try + { activeElement, body } = state("document") + + ## active element is the default if its null + ## or its equal to document.body + activeElementIsDefault = -> + (not activeElement) or (activeElement is body) + + ## if activeElement is something other than the default + ## we bail early and reset the force focused el. + ## this can happen if the AUT steals focus + ## programmatically by calling + ## HTMLElement::focus directly + if activeElementIsDefault() + return null + + return $dom.wrap(activeElement) + catch + return null + } + +module.exports = { + create +} diff --git a/packages/driver/src/cypress/cy.coffee b/packages/driver/src/cypress/cy.coffee index 072843ff73c2..7c82218cecd8 100644 --- a/packages/driver/src/cypress/cy.coffee +++ b/packages/driver/src/cypress/cy.coffee @@ -11,6 +11,7 @@ $Aliases = require("../cy/aliases") $Events = require("./events") $Errors = require("../cy/errors") $Ensures = require("../cy/ensures") +$Focused = require("../cy/focused") $Location = require("../cy/location") $Assertions = require("../cy/assertions") $Listeners = require("../cy/listeners") @@ -82,6 +83,7 @@ create = (specWindow, Cypress, Cookies, state, config, log) -> jquery = $jQuery.create(state) location = $Location.create(state) + focused = $Focused.create(state) { expect } = $Chai.create(specWindow, assertions.assert) @@ -631,6 +633,9 @@ create = (specWindow, Cypress, Cookies, state, config, log) -> ## jquery sync methods getRemotejQueryInstance: jquery.getRemotejQueryInstance + + ## focused sync methods + getFocused: focused.getFocused ## snapshots sync methods createSnapshot: snapshots.createSnapshot diff --git a/packages/driver/test/cypress/fixtures/mui.html b/packages/driver/test/cypress/fixtures/mui.html new file mode 100644 index 000000000000..7924f0faf1d8 --- /dev/null +++ b/packages/driver/test/cypress/fixtures/mui.html @@ -0,0 +1,25 @@ + + + + + + + React App + + + +
+ + + diff --git a/packages/driver/test/cypress/integration/commands/actions/focus_spec.coffee b/packages/driver/test/cypress/integration/commands/actions/focus_spec.coffee index 98b528405f8e..2f56fb48f270 100644 --- a/packages/driver/test/cypress/integration/commands/actions/focus_spec.coffee +++ b/packages/driver/test/cypress/integration/commands/actions/focus_spec.coffee @@ -1,6 +1,9 @@ $ = Cypress.$.bind(Cypress) _ = Cypress._ +getActiveElement = -> + cy.state("document").activeElement + describe "src/cy/commands/actions/focus", -> before -> cy @@ -20,8 +23,9 @@ describe "src/cy/commands/actions/focus", -> cy.$$("#focus input").focus -> focus = true - cy.get("#focus input").focus().then -> + cy.get("#focus input").focus().then ($input) -> expect(focus).to.be.true + expect(getActiveElement()).to.eq($input.get(0)) it "bubbles focusin event", -> focusin = false @@ -44,25 +48,12 @@ describe "src/cy/commands/actions/focus", -> .then -> expect(blurred).to.be.true - it "sets forceFocusedEl", -> - input = cy.$$("#focus input") - - cy - .get("#focus input").focus() - .focused().then ($focused) -> - expect($focused.get(0)).to.eq(input.get(0)) - - ## make sure we have either set the property - ## or havent - if cy.state("document").hasFocus() - expect(cy.state("forceFocusedEl")).not.to.be.ok - else - expect(cy.state("forceFocusedEl")).to.eq(input.get(0)) - it "matches cy.focused()", -> button = cy.$$("#button") - cy.get("#button").focus().focused().then ($focused) -> + cy + .get("#button").focus().focused() + .then ($focused) -> expect($focused.get(0)).to.eq button.get(0) it "returns the original subject", -> @@ -320,25 +311,14 @@ describe "src/cy/commands/actions/focus", -> cy.get("#focus").within -> cy - .get("input").focus() - .get("button").focus() - .then -> - expect(blurred).to.be.true - - it "black lists the focused element", -> - input = cy.$$("#focus input") - - cy - .get("#focus input").focus().blur() - .focused().should("not.exist").then ($focused) -> - expect($focused).to.be.null - - ## make sure we have either set the property - ## or havent - if cy.state("document").hasFocus() - expect(cy.state("blacklistFocusedEl")).not.to.be.ok - else - expect(cy.state("blacklistFocusedEl")).to.eq(input.get(0)) + .get("input").focus() + .then ($input) -> + expect(getActiveElement()).to.eq($input.get(0)) + + .get("button").focus() + .then ($btn) -> + expect(blurred).to.be.true + expect(getActiveElement()).to.eq($btn.get(0)) it "sends a focusout event", -> focusout = false @@ -399,6 +379,17 @@ describe "src/cy/commands/actions/focus", -> cy.get("input:first").focus().blur().then -> expect(cy.timeout).to.be.calledWith(50, true) + it "can blur tabindex", -> + blurred = false + + cy + .$$("#comments").blur -> + blurred = true + .get(0).focus() + + cy.get("#comments").blur().then -> + expect(blurred).to.be.true + it "can force blurring on a non-focused element", -> blurred = false diff --git a/packages/driver/test/cypress/integration/commands/querying_spec.coffee b/packages/driver/test/cypress/integration/commands/querying_spec.coffee index 633464bdf00a..b9e56ef33a60 100644 --- a/packages/driver/test/cypress/integration/commands/querying_spec.coffee +++ b/packages/driver/test/cypress/integration/commands/querying_spec.coffee @@ -16,54 +16,22 @@ describe "src/cy/commands/querying", -> context "#focused", -> it "returns the activeElement", -> - button = cy.$$("#button") - button.get(0).focus() + $button = cy.$$("#button") + $button.get(0).focus() + + expect(cy.state("document").activeElement).to.eq($button.get(0)) cy.focused().then ($focused) -> - expect($focused.get(0)).to.eq(button.get(0)) + expect($focused.get(0)).to.eq($button.get(0)) it "returns null if no activeElement", -> - button = cy.$$("#button") - button.get(0).focus() - button.get(0).blur() + $button = cy.$$("#button") + $button.get(0).focus() + $button.get(0).blur() cy.focused().should('not.exist').then ($focused) -> expect($focused).to.be.null - it "uses forceFocusedEl if set", -> - input = cy.$$("input:first") - cy.state("forceFocusedEl", input.get(0)) - - cy.focused().then ($focused) -> - expect($focused.get(0)).to.eq input.get(0) - - it "does not use forceFocusedEl if that el is not in the document", -> - input = cy.$$("input:first") - - cy - .get("input:first").focus().focused().then -> - input.remove() - .focused().should("not.exist").then ($el) -> - expect($el).to.be.null - - it "nulls forceFocusedEl if that el is not in the document", -> - input = cy.$$("input:first") - - cy - .get("input:first").focus().focused().then -> - input.remove() - .focused().should("not.exist").then ($el) -> - expect(cy.state("forceFocusedEl")).to.be.null - - it "refuses to use blacklistFocusedEl", -> - input = cy.$$("input:first") - cy.state("blacklistFocusedEl", input.get(0)) - - cy - .get("input:first").focus() - .focused().should("not.exist").then ($focused) -> - expect($focused).to.be.null - describe "assertion verification", -> beforeEach -> cy.on "log:added", (attrs, log) => @@ -88,9 +56,8 @@ describe "src/cy/commands/querying", -> $input = cy.$$("input:first") cy.on "command:retry", _.after 2, -> - cy.state("forceFocusedEl", $input.get(0)) - $input.val("1234") + $input.get(0).focus() cy.focused().should("have.value", "1234").then -> lastLog = @lastLog @@ -183,8 +150,7 @@ describe "src/cy/commands/querying", -> cy.focused() it "fails waiting for the focused element not to exist", (done) -> - input = cy.$$("input:first") - cy.state("forceFocusedEl", input.get(0)) + cy.$$("input:first").focus() cy.on "fail", (err) => expect(err.message).to.include "Expected not to exist in the DOM, but it was continuously found." @@ -193,8 +159,7 @@ describe "src/cy/commands/querying", -> cy.focused().should("not.exist") it "eventually fails the assertion", (done) -> - input = cy.$$("input:first") - cy.state("forceFocusedEl", input.get(0)) + cy.$$("input:first").focus() cy.on "fail", (err) => lastLog = @lastLog diff --git a/packages/driver/test/cypress/integration/issues/1909_spec.js b/packages/driver/test/cypress/integration/issues/1909_spec.js new file mode 100644 index 000000000000..88fa54565261 --- /dev/null +++ b/packages/driver/test/cypress/integration/issues/1909_spec.js @@ -0,0 +1,25 @@ +it('should receive the blur event when the activeElement is programmatically changed', () => { + let blurred = false + + cy.visit('http://localhost:3500/fixtures/mui.html') + + cy + .get('[label="Age"] [role="button"]') + .click() + + cy + .get('.MuiMenuItem-selected-63') + .then(($el) => { + $el.on('blur', () => { + blurred = true + }) + }) + + cy + .get('ul[role="listbox"]') + .contains('Twenty') + .click() + .then(() => { + expect(blurred).to.be.true + }) +})