From 8d7f1a0bedc3c45a2ebf1ff50324b34129fdc683 Mon Sep 17 00:00:00 2001 From: Ben Kucera <14625260+Bkucera@users.noreply.github.com> Date: Thu, 27 Feb 2020 23:11:40 +0000 Subject: [PATCH] fix: iframe type error with body[contenteditable] (#6571) Co-authored-by: Jennifer Shehane --- packages/driver/src/cy/actionability.js | 2 + .../driver/src/cy/commands/actions/type.js | 5 +- packages/driver/src/cy/focused.coffee | 4 +- packages/driver/src/cy/mouse.js | 21 +++-- packages/driver/src/dom/elements.ts | 38 ++++++++- packages/driver/src/dom/selection.ts | 8 +- .../integration/commands/actions/type_spec.js | 85 +++++++++++++++---- 7 files changed, 131 insertions(+), 32 deletions(-) diff --git a/packages/driver/src/cy/actionability.js b/packages/driver/src/cy/actionability.js index dc14b73eb6b1..6aa9dc3096dc 100644 --- a/packages/driver/src/cy/actionability.js +++ b/packages/driver/src/cy/actionability.js @@ -315,6 +315,8 @@ const verify = function (cy, $el, options, callbacks) { // scroll the element into view $el.get(0).scrollIntoView() + debug('scrollIntoView:', $el[0]) + if (onScroll) { onScroll($el, 'element') } diff --git a/packages/driver/src/cy/commands/actions/type.js b/packages/driver/src/cy/commands/actions/type.js index ccf55008c8d5..fb273445e470 100644 --- a/packages/driver/src/cy/commands/actions/type.js +++ b/packages/driver/src/cy/commands/actions/type.js @@ -349,9 +349,12 @@ module.exports = function (Commands, Cypress, cy, state, config) { const handleFocused = function () { // if it's the body, don't need to worry about focus - const isBody = options.$el.is('body') + // (unless it can be modified i.e we're in designMode or contenteditable) + const isBody = options.$el.is('body') && !$elements.isContentEditable(options.$el[0]) if (isBody) { + debug('typing into body') + return type() } diff --git a/packages/driver/src/cy/focused.coffee b/packages/driver/src/cy/focused.coffee index bcff7e2318cf..72083dc11ffe 100644 --- a/packages/driver/src/cy/focused.coffee +++ b/packages/driver/src/cy/focused.coffee @@ -66,9 +66,9 @@ create = (state) -> el.dispatchEvent(focusoutEvt) fireFocus = (el) -> - ## body will never emit focus events + ## body will never emit focus events (unless it's contenteditable) ## so we avoid simulating this - if $elements.isBody(el) + if $elements.isBody(el) && !$elements.isContentEditable(el) return ## if we are focusing a different element diff --git a/packages/driver/src/cy/mouse.js b/packages/driver/src/cy/mouse.js index ff38a13797be..83d8b1943aee 100644 --- a/packages/driver/src/cy/mouse.js +++ b/packages/driver/src/cy/mouse.js @@ -152,22 +152,29 @@ const create = (state, keyboard, focused, Cypress) => { } const shouldMoveCursorToEndAfterMousedown = (el) => { + const _debug = debug.extend(':shouldMoveCursorToEnd') + + _debug('shouldMoveToEnd?', el) if (!$elements.isElement(el)) { + _debug('false: not element') + return false } if (!($elements.isInput(el) || $elements.isTextarea(el) || $elements.isContentEditable(el))) { - return false - } + _debug('false: not input/textarea/contentedtable') - if (!$elements.isFocused(el)) { return false } if ($elements.isNeedSingleValueChangeInputElement(el)) { + _debug('false: is single value change input') + return false } + _debug('true: should move to end') + return true } @@ -458,7 +465,9 @@ const create = (state, keyboard, focused, Cypress) => { //# retrieve the first focusable $el in our parent chain const $elToFocus = $elements.getFirstFocusableEl($(el)) + debug('elToFocus:', $elToFocus[0]) if (focused.needsFocus($elToFocus, $previouslyFocused)) { + debug('el needs focus') if ($dom.isWindow($elToFocus)) { // if the first focusable element from the click // is the window, then we can skip the focus event @@ -474,9 +483,9 @@ const create = (state, keyboard, focused, Cypress) => { } } - if (shouldMoveCursorToEndAfterMousedown($elToFocus[0])) { - debug('moveSelectionToEnd due to click') - $selection.moveSelectionToEnd($elToFocus[0], { onlyIfEmptySelection: true }) + if (shouldMoveCursorToEndAfterMousedown(el)) { + debug('moveSelectionToEnd due to click', el) + $selection.moveSelectionToEnd(el, { onlyIfEmptySelection: true }) } return mouseDownPhase diff --git a/packages/driver/src/dom/elements.ts b/packages/driver/src/dom/elements.ts index 8d0fd1867d5b..15275d34c770 100644 --- a/packages/driver/src/dom/elements.ts +++ b/packages/driver/src/dom/elements.ts @@ -352,7 +352,7 @@ const getTagName = (el) => { // should be true for elements: // - with [contenteditable] // - with document.designMode = 'on' -const isContentEditable = (el: any): el is HTMLContentEditableElement => { +const isContentEditable = (el: HTMLElement): el is HTMLContentEditableElement => { return getNativeProp(el, 'isContentEditable') || $document.getDocumentFromElement(el).designMode === 'on' } @@ -401,9 +401,9 @@ const isSvg = function (el): el is SVGElement { } // active element is the default if its null -// or its equal to document.body -const activeElementIsDefault = (activeElement, body) => { - return !activeElement || activeElement === body +// or it's equal to document.body that is not contenteditable +const activeElementIsDefault = (activeElement, body: HTMLElement) => { + return !activeElement || (activeElement === body && !isContentEditable(body)) } const isFocused = (el) => { @@ -423,19 +423,49 @@ const isFocused = (el) => { } const isFocusedOrInFocused = (el: HTMLElement) => { + debug('isFocusedOrInFocus', el) + const doc = $document.getDocumentFromElement(el) + if (!doc.hasFocus()) { + return false + } + const { activeElement } = doc let elToCheckCurrentlyFocused + let isContentEditableEl = false + if (isFocusable($(el))) { elToCheckCurrentlyFocused = el } else if (isContentEditable(el)) { + isContentEditableEl = true elToCheckCurrentlyFocused = $selection.getHostContenteditable(el) } + debug('elToCheckCurrentlyFocused', elToCheckCurrentlyFocused) + if (elToCheckCurrentlyFocused && elToCheckCurrentlyFocused === activeElement) { + if (isContentEditableEl) { + // we make sure the the current document selection (blinking cursor) is inside the element + const sel = doc.getSelection() + + if (sel?.rangeCount) { + const range = sel.getRangeAt(0) + const curSelectionContainer = range.commonAncestorContainer + + const selectionInsideElement = el.contains(curSelectionContainer) + + debug('isInFocused by document selection?', selectionInsideElement, ':', curSelectionContainer, 'is inside', el) + + return selectionInsideElement + } + + // no selection, not in focused + return false + } + return true } diff --git a/packages/driver/src/dom/selection.ts b/packages/driver/src/dom/selection.ts index f5d69a0a3311..c14281a2191d 100644 --- a/packages/driver/src/dom/selection.ts +++ b/packages/driver/src/dom/selection.ts @@ -137,9 +137,13 @@ const getHostContenteditable = function (el) { } // if there's no host contenteditable, we must be in designmode - // so act as if the body element is the host contenteditable + // so act as if the documentElement (html element) is the host contenteditable if (!_hasContenteditableAttr(curEl)) { - return el.ownerDocument.body + if ($document.isDocument(curEl)) { + return curEl.documentElement + } + + return el.ownerDocument.documentElement } return curEl diff --git a/packages/driver/test/cypress/integration/commands/actions/type_spec.js b/packages/driver/test/cypress/integration/commands/actions/type_spec.js index 47ccbf58d7f1..23b23a71aaf2 100644 --- a/packages/driver/test/cypress/integration/commands/actions/type_spec.js +++ b/packages/driver/test/cypress/integration/commands/actions/type_spec.js @@ -12,6 +12,18 @@ const { shouldBeCalledOnce, } = require('../../../support/utils') +const expectTextEndsWith = (expected) => { + return ($el) => { + const text = $el.text().trim() + + const passed = text.endsWith(expected) + + const displayText = text.length > 300 ? (`${text.slice(0, 100)}...${text.slice(-100)}`) : text + + assert(passed, `expected ${displayText} to end with ${expected}`) + } +} + describe('src/cy/commands/actions/type - #type', () => { beforeEach(() => { cy.visit('/fixtures/dom.html') @@ -1436,13 +1448,13 @@ describe('src/cy/commands/actions/type - #type', () => { it(' element', () => { cy.$$(` - - area - - image - `).prependTo(cy.$$('body')) + + area + + image + `).prependTo(cy.$$('body')) let keydown = cy.stub() @@ -1698,8 +1710,7 @@ describe('src/cy/commands/actions/type - #type', () => { }) }) - it(`can type into an iframe with designmode = 'on'`, () => { - // append a new iframe to the body + function insertIframe () { cy.$$('') .appendTo(cy.$$('body')) @@ -1715,7 +1726,47 @@ describe('src/cy/commands/actions/type - #type', () => { .should(() => { expect(loaded).to.eq(true) }) + } + + it('can type in designmode="on"', () => { + cy.timeout(100) + cy.state('document').designMode = 'on' + cy.state('document').documentElement.focus() + cy.get('div.item:first') + .type('111') + + cy.get('body').then(expectTextEndsWith('111')) + }) + // TODO[breaking]: we should edit div.item:first text content instead of + // moving to the end of the host contenteditable. This will allow targeting + // specific elements to simplify testing rich editors + it('can type in body[contenteditable]', () => { + cy.state('document').body.setAttribute('contenteditable', true) + cy.state('document').documentElement.focus() + cy.get('div.item:first') + .type('111') + + cy.get('body') + .then(expectTextEndsWith('111')) + }) + + // https://github.com/cypress-io/cypress/issues/5930 + it('can type into an iframe with body[contenteditable]', () => { + insertIframe() + cy.get('#generic-iframe').then(($iframe) => { + cy.wrap($iframe.contents().find('html').first().find('body')) + .then(($body) => { + $body.attr('contenteditable', true) + }) + .type('111') + .then(expectTextEndsWith('111')) + }) + }) + + it(`can type into an iframe with designmode = 'on'`, () => { + // append a new iframe to the body + insertIframe() // type text into iframe cy.get('#generic-iframe') .then(($iframe) => { @@ -2501,11 +2552,11 @@ describe('src/cy/commands/actions/type - #type', () => { }) }) - it('accurately returns document body el with no falsey contenteditable="false" attr', () => { + it('accurately returns documentElement el when contenteditable="false" attr', () => { cy.$$('
foo
').appendTo(cy.$$('body')) cy.get('#ce-inner1').then(($el) => { - expect(Cypress.dom.getHostContenteditable($el[0])).to.eq($el[0].ownerDocument.body) + expect(Cypress.dom.getHostContenteditable($el[0])).to.eq($el[0].ownerDocument.documentElement) }) }) @@ -2650,8 +2701,8 @@ describe('src/cy/commands/actions/type - #type', () => { const el = $el.get(0) el.innerHTML = 'start' + - '
middle
' + - '
end
' + '
middle
' + + '
end
' cy.get('[contenteditable]:first') // move cursor to beginning of div @@ -2666,8 +2717,8 @@ describe('src/cy/commands/actions/type - #type', () => { const el = $el.get(0) el.innerHTML = 'start' + - '
middle
' + - '
end
' + '
middle
' + + '
end
' cy.get('[contenteditable]:first').type(`${'{leftarrow}'.repeat(12)}[_I_]`).then(($el) => { expect(trimInnerText($el)).to.eql('star[_I_]t\nmiddle\nend') @@ -2937,7 +2988,7 @@ describe('src/cy/commands/actions/type - #type', () => { const table = this.lastLog.invoke('consoleProps').table[2]() // eslint-disable-next-line - console.table(table.data, table.columns) + console.table(table.data, table.columns) expect(table.name).to.eq('Keyboard Events') const expectedTable = { @@ -2981,7 +3032,7 @@ describe('src/cy/commands/actions/type - #type', () => { const table = this.lastLog.invoke('consoleProps').table[2]() // eslint-disable-next-line - console.table(table.data, table.columns) + console.table(table.data, table.columns) expect(table.data).to.deep.eq({ 1: { Typed: 'f', 'Events Fired': 'keydown, keyup', 'Active Modifiers': null, Details: '{ code: KeyF, which: 70 }', 'Prevented Default': true, 'Target Element': $el[0] },