diff --git a/packages/driver/src/cy/actionability.coffee b/packages/driver/src/cy/actionability.coffee index 3ad78f7ebb10..09760af5ff39 100644 --- a/packages/driver/src/cy/actionability.coffee +++ b/packages/driver/src/cy/actionability.coffee @@ -209,6 +209,18 @@ ensureNotAnimating = (cy, $el, coordsHistory, animationDistanceThreshold) -> cy.ensureElementIsNotAnimating($el, coordsHistory, animationDistanceThreshold) verify = (cy, $el, options, callbacks) -> + + _.defaults(options, { + ensure: { + position: true, + visibility: true, + notDisabled: true, + notCovered: true, + notReadonly: false, + custom: false + } + }) + win = $dom.getWindowByElement($el.get(0)) { _log, force, position } = options @@ -220,7 +232,7 @@ verify = (cy, $el, options, callbacks) -> ## if we have a position we must validate ## this ahead of time else bail early - if position + if options.ensure.position and position try cy.ensureValidPosition(position, _log) catch err @@ -232,6 +244,9 @@ verify = (cy, $el, options, callbacks) -> runAllChecks = -> if force isnt true + ## ensure its 'receivable' + if (options.ensure.notDisabled) then cy.ensureNotDisabled($el, _log) + ## scroll the element into view $el.get(0).scrollIntoView() @@ -239,10 +254,13 @@ verify = (cy, $el, options, callbacks) -> onScroll($el, "element") ## ensure its visible - cy.ensureVisibility($el, _log) + if (options.ensure.visibility) then cy.ensureVisibility($el, _log) - ## ensure its 'receivable' (not disabled, readonly) - cy.ensureReceivability($el, _log) + if options.ensure.notReadonly + cy.ensureNotReadonly($el, _log) + + if _.isFunction(options.custom) + options.custom($el, _log) ## now go get all the coords for this element coords = getCoordinatesForEl(cy, $el, options) @@ -264,10 +282,13 @@ verify = (cy, $el, options, callbacks) -> ## to figure out if its being covered by another element. ## this calculation is relative from the viewport so we ## only care about fromViewport coords - $elAtCoords = ensureElIsNotCovered(cy, win, $el, coords.fromViewport, options, _log, onScroll) + $elAtCoords = options.ensure.notCovered && ensureElIsNotCovered(cy, win, $el, coords.fromViewport, options, _log, onScroll) ## pass our final object into onReady - return onReady($elAtCoords ? $el, coords) + finalEl = $elAtCoords ? $el + finalCoords = getCoordinatesForEl(cy, $el, options) + + return onReady(finalEl, finalCoords) ## we cannot enforce async promises here because if our ## element passes every single check, we MUST fire the event diff --git a/packages/driver/src/cy/commands/actions/type.js b/packages/driver/src/cy/commands/actions/type.js index 8475ef382cf1..7f553181fa5f 100644 --- a/packages/driver/src/cy/commands/actions/type.js +++ b/packages/driver/src/cy/commands/actions/type.js @@ -424,30 +424,27 @@ module.exports = function (Commands, Cypress, cy, state, config) { const handleFocused = function () { // if it's the body, don't need to worry about focus - let elToCheckCurrentlyFocused - if (isBody) { return type() } + options.ensure = { + position: true, + visibility: true, + notDisabled: true, + notCovered: true, + notReadonly: true, + } + // 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 - let $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 === $focused)) { - // TODO: not scrolling here, but revisit when scroll algorithm changes - return type() + if (($elements.isFocusedOrInFocused(options.$el.get(0)))) { + options.ensure = { + notReadonly: true, + } } return $actionability.verify(cy, options.$el, options, { @@ -456,12 +453,10 @@ module.exports = function (Commands, Cypress, cy, state, config) { }, onReady ($elToClick) { - $focused = cy.getFocused() - // if we dont have a focused element // or if we do and its not ourselves // then issue the click - if (!$focused || ($focused && ($focused.get(0) !== options.$el.get(0)))) { + if (!$elements.isFocusedOrInFocused($elToClick[0])) { // click the element first to simulate focus // and typical user behavior in case the window // is out of focus diff --git a/packages/driver/src/cy/ensures.coffee b/packages/driver/src/cy/ensures.coffee index 6c69c1491c80..92ee3ee7d6c5 100644 --- a/packages/driver/src/cy/ensures.coffee +++ b/packages/driver/src/cy/ensures.coffee @@ -101,7 +101,7 @@ create = (state, expect) -> args: { cmd, node } }) - ensureReceivability = (subject, onFail) -> + ensureNotDisabled = (subject, onFail) -> cmd = state("current").get("name") if subject.prop("disabled") @@ -112,6 +112,9 @@ create = (state, expect) -> args: { cmd, node } }) + ensureNotReadonly = (subject, onFail) -> + cmd = state("current").get("name") + # readonly can only be applied to input/textarea # not on checkboxes, radios, etc.. if $dom.isTextLike(subject) and subject.prop("readonly") @@ -127,7 +130,7 @@ create = (state, expect) -> # We overwrite the filter(":visible") in jquery # packages/driver/src/config/jquery.coffee#L51 - # So that this effectively calls our logic + # So that this effectively calls our logic # for $dom.isVisible aka !$dom.isHidden if not (subject.length is subject.filter(":visible").length) reason = $dom.getReasonIsHidden(subject) @@ -325,7 +328,7 @@ create = (state, expect) -> ensureElementIsNotAnimating - ensureReceivability + ensureNotDisabled ensureVisibility @@ -338,6 +341,8 @@ create = (state, expect) -> ensureValidPosition ensureScrollability + + ensureNotReadonly } module.exports = { diff --git a/packages/driver/src/cypress/cy.coffee b/packages/driver/src/cypress/cy.coffee index 98ce874a495e..fe5c18c88e37 100644 --- a/packages/driver/src/cypress/cy.coffee +++ b/packages/driver/src/cypress/cy.coffee @@ -680,7 +680,8 @@ create = (specWindow, Cypress, Cookies, state, config, log) -> ensureElDoesNotHaveCSS: ensures.ensureElDoesNotHaveCSS ensureVisibility: ensures.ensureVisibility ensureDescendents: ensures.ensureDescendents - ensureReceivability: ensures.ensureReceivability + ensureNotReadonly: ensures.ensureNotReadonly + ensureNotDisabled: ensures.ensureNotDisabled ensureValidPosition: ensures.ensureValidPosition ensureScrollability: ensures.ensureScrollability ensureElementIsNotAnimating: ensures.ensureElementIsNotAnimating diff --git a/packages/driver/src/cypress/error_messages.coffee b/packages/driver/src/cypress/error_messages.coffee index 54d6801baaba..b3c9ce68b5eb 100644 --- a/packages/driver/src/cypress/error_messages.coffee +++ b/packages/driver/src/cypress/error_messages.coffee @@ -209,6 +209,15 @@ module.exports = { https://on.cypress.io/element-cannot-be-interacted-with """ + readonly: """ + #{cmd('{{cmd}}')} failed because this element is readonly: + + {{node}} + + Fix this problem, or use {force: true} to disable error checking. + + https://on.cypress.io/element-cannot-be-interacted-with + """ invalid_position_argument: "Invalid position argument: '{{position}}'. Position may only be {{validPositions}}." not_scrollable: """ #{cmd('{{cmd}}')} failed because this element is not scrollable:\n diff --git a/packages/driver/src/dom/elements.js b/packages/driver/src/dom/elements.js index 943f2308528b..b28a2339b29c 100644 --- a/packages/driver/src/dom/elements.js +++ b/packages/driver/src/dom/elements.js @@ -4,6 +4,7 @@ const $jquery = require('./jquery') const $window = require('./window') const $document = require('./document') const $utils = require('../cypress/utils') +const $selection = require('./selection') const fixedOrStickyRe = /(fixed|sticky)/ @@ -344,6 +345,30 @@ const isFocused = (el) => { } } +const isFocusedOrInFocused = (el) => { + + const doc = $document.getDocumentFromElement(el) + + const { activeElement, body } = doc + + if (activeElementIsDefault(activeElement, body)) { + return false + } + + let elToCheckCurrentlyFocused + + if (isFocusable($(el))) { + elToCheckCurrentlyFocused = el + } else if (isContentEditable(el)) { + elToCheckCurrentlyFocused = $selection.getHostContenteditable(el) + } + + if (elToCheckCurrentlyFocused && elToCheckCurrentlyFocused === activeElement) { + return true + } + +} + const isElement = function (obj) { try { if ($jquery.isJquery(obj)) { @@ -813,7 +838,9 @@ const stringify = (el, form = 'long') => { }) } -module.exports = { +// We extend `module.exports` to allow circular dependencies using `require` +// Otherwise we would not be able to `require` this util from `./selection`, for example. +_.extend(module.exports, { isElement, isSelector, @@ -856,6 +883,8 @@ module.exports = { isFocused, + isFocusedOrInFocused, + isInputAllowingImplicitFormSubmission, isNeedSingleValueChangeInputElement, @@ -889,4 +918,4 @@ module.exports = { getFirstStickyPositionParent, getFirstScrollableParent, -} +}) diff --git a/packages/driver/test/cypress/fixtures/dom.html b/packages/driver/test/cypress/fixtures/dom.html index f1a56c0c3ee4..6532fd6337d6 100644 --- a/packages/driver/test/cypress/fixtures/dom.html +++ b/packages/driver/test/cypress/fixtures/dom.html @@ -289,7 +289,7 @@ -
+ @@ -297,6 +297,13 @@ + + + + +
@@ -356,7 +363,7 @@ 5 - diff --git a/packages/driver/test/cypress/integration/commands/actions/check_spec.coffee b/packages/driver/test/cypress/integration/commands/actions/check_spec.coffee index 4ef2f9e67da8..15bafe51ce51 100644 --- a/packages/driver/test/cypress/integration/commands/actions/check_spec.coffee +++ b/packages/driver/test/cypress/integration/commands/actions/check_spec.coffee @@ -75,6 +75,11 @@ describe "src/cy/commands/actions/check", -> done("should not fire change event") cy.get(checkbox).check() + + ## readonly should only be limited to inputs, not checkboxes + it "can check readonly checkboxes", -> + cy.get('#readonly-checkbox').check().then ($checkbox) -> + expect($checkbox).to.be.checked it "does not require visibility with force: true", -> checkbox = ":checkbox[name='birds']" diff --git a/packages/driver/test/cypress/integration/commands/actions/click_spec.js b/packages/driver/test/cypress/integration/commands/actions/click_spec.js index 0d6b920a436a..6290b77e6165 100644 --- a/packages/driver/test/cypress/integration/commands/actions/click_spec.js +++ b/packages/driver/test/cypress/integration/commands/actions/click_spec.js @@ -613,11 +613,19 @@ describe('src/cy/commands/actions/click', () => { }) describe('actionability', () => { - it('can click on inline elements that wrap lines', () => { cy.get('#overflow-link').find('.wrapped').click() }) + // readonly should only limit typing, not clicking + it('can click on readonly inputs', () => { + cy.get('#readonly-attr').click() + }) + + it('can click on readonly submit inputs', () => { + cy.get('#readonly-submit').click() + }) + it('can click elements which are hidden until scrolled within parent container', () => { cy.get('#overflow-auto-container').contains('quux').click() }) 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 a50da7112212..dcf6c0b1490b 100644 --- a/packages/driver/test/cypress/integration/commands/actions/focus_spec.coffee +++ b/packages/driver/test/cypress/integration/commands/actions/focus_spec.coffee @@ -46,7 +46,7 @@ describe "src/cy/commands/actions/focus", -> .get("input:first").focus() .get("#focus input").focus() .then -> - expect(blurred).to.be.true + expect(blurred).to.be.true it "matches cy.focused()", -> button = cy.$$("#button") @@ -153,6 +153,14 @@ describe "src/cy/commands/actions/focus", -> cy.get("[data-cy=rect]").focus().then -> expect(onFocus).to.be.calledOnce + it "can focus on readonly inputs", -> + onFocus = cy.stub() + + cy.$$("#readonly-attr").focus(onFocus) + + cy.get("#readonly-attr").focus().then -> + expect(onFocus).to.be.calledOnce + describe "assertion verification", -> beforeEach -> cy.on "log:added", (attrs, log) => @@ -290,6 +298,7 @@ describe "src/cy/commands/actions/focus", -> cy.focus() + ## TODO: dont skip this it.skip "slurps up failed promises", (done) -> cy.timeout(1000) diff --git a/packages/driver/test/cypress/integration/commands/actions/select_spec.coffee b/packages/driver/test/cypress/integration/commands/actions/select_spec.coffee index d52c37dfda40..1988a2bf6dc0 100644 --- a/packages/driver/test/cypress/integration/commands/actions/select_spec.coffee +++ b/packages/driver/test/cypress/integration/commands/actions/select_spec.coffee @@ -66,6 +66,11 @@ describe "src/cy/commands/actions/select", -> cy.get("select[name=movies]").select(["The Human Condition", "There Will Be Blood"]).then ($select) -> expect($select.val()).to.deep.eq ["thc", "twbb"] + ## readonly should only be limited to inputs, not checkboxes + it "can select a readonly select", -> + cy.get("select[name=hunter]").select("gon").then ($select) -> + expect($select.val()).to.eq("gon-val") + it "clears previous values when providing an array", -> ## make sure we have a previous value select = cy.$$("select[name=movies]").val(["2001"]) @@ -75,17 +80,16 @@ describe "src/cy/commands/actions/select", -> expect($select.val()).to.deep.eq ["apoc", "br"] it "lists the select as the focused element", -> - select = cy.$$("select:first") + select = cy.$$("#select-maps") - cy.get("select:first").select("de_train").focused().then ($focused) -> + cy.get("#select-maps").select("de_train").focused().then ($focused) -> expect($focused.get(0)).to.eq select.get(0) it "causes previous input to receive blur", (done) -> cy.$$("input:text:first").blur -> done() - cy - .get("input:text:first").type("foo") - .get("select:first").select("de_train") + cy.get("input:text:first").type("foo") + cy.get("#select-maps").select("de_train") it "can forcibly click even when being covered by another element", (done) -> select = $("").attr("id", "select-covered-in-span").prependTo(cy.$$("body")) @@ -107,19 +111,19 @@ describe "src/cy/commands/actions/select", -> cy.get("#select-covered-in-span").select("foobar", {timeout: 1000, interval: 60}) it "can forcibly click even when element is invisible", (done) -> - select = cy.$$("select:first").hide() + select = cy.$$("#select-maps").hide() select.click -> done() - cy.get("select:first").select("de_dust2", {force: true}) + cy.get("#select-maps").select("de_dust2", {force: true}) it "retries until ") cy.on "command:retry", _.once => - cy.$$("select:first").append option + cy.$$("#select-maps").append option - cy.get("select:first").select("foo") + cy.get("#select-maps").select("foo") it "retries until