diff --git a/packages/app/src/runner/logger.ts b/packages/app/src/runner/logger.ts index 2542f89f04bc..82c04f943fcd 100644 --- a/packages/app/src/runner/logger.ts +++ b/packages/app/src/runner/logger.ts @@ -32,7 +32,7 @@ export const logger = { this._logValues(consoleProps) this._logArgs(consoleProps) this._logGroups(consoleProps) - this._logTable(consoleProps) + this._logTables(consoleProps) }, _logValues (consoleProps: any) { @@ -118,43 +118,46 @@ export const logger = { }) }, - _logTable (consoleProps: any) { - if (isMultiEntryTable(consoleProps.table)) { - _.each( - _.sortBy(consoleProps.table, (val, key) => key), - (table) => { - return this._logTable({ table }) - }, - ) + _logTables (consoleProps: any) { + const logTable = ({ name, data, columns }) => { + let tableData = data - return - } + if (Cypress.isBrowser('webkit')) { + // WebKit will hang when we attempt to log element references + // within a table. We replace the element with a simplified display + // string in this case. + // https://bugs.webkit.org/show_bug.cgi?id=244100 - const table = this._getTable(consoleProps) + const getSimplifiedElementDisplay = (element: Element) => { + let display = element.tagName.toLowerCase() - if (!table) return + if (element.id) { + display += `#${element.id}` + } - if (_.isArray(table)) { - console.table(table) - } else { - console.group(table.name) - console.table(table.data, table.columns) - console.groupEnd() - } - }, + element.classList.forEach((className) => { + display += `.${className}` + }) - _getTable (consoleProps: any): Table | Table[] | undefined { - const table = _.result(consoleProps, 'table') + return display + } - if (!table) return + tableData = data.map((rowObj) => { + return Object.entries(rowObj).reduce((acc: any, value) => { + acc[value[0]] = _.isElement(value[1]) ? getSimplifiedElementDisplay(value[1] as Element) : value[1] - return table - }, -} + return acc + }, {}) + }) + } + + console.group(name) + console.table(tableData, columns) + console.groupEnd() + } -const isMultiEntryTable = (table: Table) => { - return !_.isFunction(table) && - !_.some(_.keys(table) - .map((x) => isNaN(parseInt(x, 10))) - .filter(Boolean), true) + _.each(_.sortBy(consoleProps.table, (val, key) => key), (getTableData: () => Table) => { + return logTable(getTableData()) + }) + }, } diff --git a/packages/driver/cypress/e2e/commands/actions/click.cy.js b/packages/driver/cypress/e2e/commands/actions/click.cy.js index 1ec6075c6269..3fa9e8bf358f 100644 --- a/packages/driver/cypress/e2e/commands/actions/click.cy.js +++ b/packages/driver/cypress/e2e/commands/actions/click.cy.js @@ -45,9 +45,9 @@ const getMidPoint = (el) => { } const isFirefox = Cypress.isBrowser('firefox') +const isWebKit = Cypress.isBrowser('webkit') -// TODO(webkit): fix+unskip for experimental webkit -describe('src/cy/commands/actions/click', { browser: '!webkit' }, () => { +describe('src/cy/commands/actions/click', () => { beforeEach(() => { cy.visit('/fixtures/dom.html') }) @@ -3596,7 +3596,8 @@ describe('src/cy/commands/actions/click', { browser: '!webkit' }, () => { cy.getAll('el', 'mousedown contextmenu mouseup').each(shouldNotBeCalled) - cy.getAll('el', 'pointerdown pointerup').each(isFirefox ? shouldNotBeCalled : shouldBeCalled) + // On disabled inputs, pointer events are still fired in chrome, not in firefox or webkit + cy.getAll('el', 'pointerdown pointerup').each(isFirefox || isWebKit ? shouldNotBeCalled : shouldBeCalled) }) it('rightclick cancel contextmenu', () => { @@ -3929,8 +3930,7 @@ describe('src/cy/commands/actions/click', { browser: '!webkit' }, () => { }) }) -// TODO(webkit): fix+unskip for experimental webkit -describe('shadow dom', { browser: '!webkit' }, () => { +describe('shadow dom', () => { beforeEach(() => { cy.visit('/fixtures/shadow-dom.html') }) @@ -3993,8 +3993,7 @@ describe('shadow dom', { browser: '!webkit' }, () => { }) }) -// TODO(webkit): fix+unskip for experimental webkit -describe('mouse state', { browser: '!webkit' }, () => { +describe('mouse state', () => { describe('mouse/pointer events', () => { beforeEach(() => { cy.visit('http://localhost:3500/fixtures/dom.html') @@ -4064,12 +4063,28 @@ describe('mouse state', { browser: '!webkit' }, () => { y: 10, } + const coordsWebKit = { + clientX: 500, + clientY: 10, + layerX: 500, + layerY: 226, + pageX: 500, + pageY: 226, + screenX: 500, + screenY: 10, + x: 500, + y: 10, + } + let coords switch (Cypress.browser.family) { case 'firefox': coords = coordsFirefox break + case 'webkit': + coords = coordsWebKit + break default: coords = coordsChrome break @@ -4498,9 +4513,10 @@ describe('mouse state', { browser: '!webkit' }, () => { // cy.wrap(onAction).should('calledOnce') cy.getAll('btn', 'pointerover pointerenter').each(shouldBeCalledOnce) - cy.getAll('btn', 'pointerdown pointerup').each(isFirefox ? shouldNotBeCalled : shouldBeCalledOnce) - cy.getAll('btn', 'mouseover mouseenter').each(isFirefox ? shouldBeCalled : shouldNotBeCalled) + // On disabled inputs, pointer events are still fired in chrome, not in firefox or webkit + cy.getAll('btn', 'pointerdown pointerup').each(isFirefox || isWebKit ? shouldNotBeCalled : shouldBeCalledOnce) + cy.getAll('btn', 'mouseover mouseenter').each(isFirefox || isWebKit ? shouldBeCalled : shouldNotBeCalled) cy.getAll('btn', 'mousedown mouseup click').each(shouldNotBeCalled) }) @@ -4524,7 +4540,9 @@ describe('mouse state', { browser: '!webkit' }, () => { cy.get('#btn').click() cy.getAll('btn', 'pointerdown mousedown').each(shouldBeCalledOnce) - cy.getAll('btn', 'pointerup').each(isFirefox ? shouldNotBeCalled : shouldBeCalledOnce) + + // On disabled inputs, pointer events are still fired in chrome, not in firefox or webkit + cy.getAll('btn', 'pointerup').each(isFirefox || isWebKit ? shouldNotBeCalled : shouldBeCalledOnce) cy.getAll('btn', 'mouseup click').each(shouldNotBeCalled) }) @@ -4599,8 +4617,8 @@ describe('mouse state', { browser: '!webkit' }, () => { cy.getAll('btn', 'mousedown mouseup click').each(shouldNotBeCalled) - // on disabled inputs, pointer events are still fired in chrome, not in firefox - cy.getAll('btn', 'pointerdown pointerup').each(isFirefox ? shouldNotBeCalled : shouldBeCalled) + // On disabled inputs, pointer events are still fired in chrome, not in firefox or webkit + cy.getAll('btn', 'pointerdown pointerup').each(isFirefox || isWebKit ? shouldNotBeCalled : shouldBeCalled) }) it('can target new element after mousedown sequence', () => { diff --git a/packages/driver/cypress/e2e/commands/actions/type.cy.js b/packages/driver/cypress/e2e/commands/actions/type.cy.js index f958043dddd9..301f660b098e 100644 --- a/packages/driver/cypress/e2e/commands/actions/type.cy.js +++ b/packages/driver/cypress/e2e/commands/actions/type.cy.js @@ -22,8 +22,9 @@ const expectTextEndsWith = (expected) => { } } -// TODO(webkit): fix+unskip for experimental webkit -describe('src/cy/commands/actions/type - #type', { browser: '!webkit' }, () => { +const isWebKit = Cypress.isBrowser('webkit') + +describe('src/cy/commands/actions/type - #type', () => { beforeEach(() => { cy.visit('/fixtures/dom.html') }) @@ -957,6 +958,25 @@ describe('src/cy/commands/actions/type - #type', { browser: '!webkit' }, () => { .should('have.value', 'baroo') }) + // WebKit will select all input content on focus. This causes our + // cursor placement logic to be ignored, as we interpret the default + // selection as a user-provided selection that we do not want to override. + // We work around this by preventing the default selection on focus using + // our own capture-phase 'focus' event handler; this test ensures that user-set + // capture-phase events continue to function as expected for the purpose + // of selection updates. + it('respects changed selection in focus handler during capture phase', () => { + cy.get('#input-without-value') + .then(($el) => { + $el.val('foo') + $el.get(0).addEventListener('focus', (e) => { + e.currentTarget.setSelectionRange(0, 1) + }, { capture: true }) + }) + .type('bar') + .should('have.value', 'baroo') + }) + it('overwrites text when selectAll in mouseup handler', () => { cy.$$('#input-without-value').val('0').mouseup(function () { $(this).select() @@ -1197,7 +1217,7 @@ describe('src/cy/commands/actions/type - #type', { browser: '!webkit' }, () => { }) }) - it('inserts text after existing text ', () => { + it('inserts text after existing text', () => { cy.get('#number-with-value').type('34').then(($text) => { expect($text).to.have.value('1234') }) @@ -1794,33 +1814,36 @@ describe('src/cy/commands/actions/type - #type', { browser: '!webkit' }, () => { }) // https://github.com/cypress-io/cypress/issues/7088 + // In WebKit, setting the inputType is not supported by the InputEvent constructor. + // This results in the inputType being unset in any of the simulated beforeinput events. + // https://bugs.webkit.org/show_bug.cgi?id=170416 describe('beforeInput event', () => { it('sends beforeinput in text input', () => { const call1 = (e) => { expect(e.code).not.exist expect(e.data).eq(' ') - expect(e.inputType).eq('insertText') + !isWebKit && expect(e.inputType).eq('insertText') stub.callsFake(call2) } const call2 = (e) => { expect(e.code).not.exist expect(e.data).eq('f') - expect(e.inputType).eq('insertText') + !isWebKit && expect(e.inputType).eq('insertText') stub.callsFake(call3) } const call3 = (e) => { expect(e.data).eq(null) - expect(e.inputType).eq('insertLineBreak') + !isWebKit && expect(e.inputType).eq('insertLineBreak') stub.callsFake(call4) } const call4 = (e) => { expect(e.data).eq(null) - expect(e.inputType).eq('deleteContentBackward') + !isWebKit && expect(e.inputType).eq('deleteContentBackward') stub.callsFake(call5) } const call5 = (e) => { expect(e.data).eq(null) - expect(e.inputType).eq('deleteContentForward') + !isWebKit && expect(e.inputType).eq('deleteContentForward') } const stub = cy.stub() @@ -1843,28 +1866,28 @@ describe('src/cy/commands/actions/type - #type', { browser: '!webkit' }, () => { const call1 = (e) => { expect(e.code).not.exist expect(e.data).eq(' ') - expect(e.inputType).eq('insertText') + !isWebKit && expect(e.inputType).eq('insertText') stub.callsFake(call2) } const call2 = (e) => { expect(e.code).not.exist expect(e.data).eq('f') - expect(e.inputType).eq('insertText') + !isWebKit && expect(e.inputType).eq('insertText') stub.callsFake(call3) } const call3 = (e) => { expect(e.data).eq(null) - expect(e.inputType).eq('insertLineBreak') + !isWebKit && expect(e.inputType).eq('insertLineBreak') stub.callsFake(call4) } const call4 = (e) => { expect(e.data).eq(null) - expect(e.inputType).eq('deleteContentBackward') + !isWebKit && expect(e.inputType).eq('deleteContentBackward') stub.callsFake(call5) } const call5 = (e) => { expect(e.data).eq(null) - expect(e.inputType).eq('deleteContentForward') + !isWebKit && expect(e.inputType).eq('deleteContentForward') } const stub = cy.stub() @@ -1883,6 +1906,10 @@ describe('src/cy/commands/actions/type - #type', { browser: '!webkit' }, () => { }) }) + // In WebKit, simulated `beforeinput` events are not emitted for + // contenteditable inputs. The stubs are receiving the + // browser-emitted events, which is why the inputType values + // of these events are populated. it('sends beforeinput in [contenteditable]', () => { const call1 = (e) => { expect(e.code).not.exist @@ -1908,7 +1935,14 @@ describe('src/cy/commands/actions/type - #type', { browser: '!webkit' }, () => { } const call5 = (e) => { expect(e.data).eq(null) - expect(e.inputType).eq('deleteContentForward') + + if (isWebKit) { + // WebKit does not distinguish between forward/backward + // deletion within a contenteditable field + expect(e.inputType).eq('deleteContentBackward') + } else { + expect(e.inputType).eq('deleteContentForward') + } } const stub = cy.stub() @@ -1927,7 +1961,7 @@ describe('src/cy/commands/actions/type - #type', { browser: '!webkit' }, () => { }) }) - it('beforeinput special inputTypes', () => { + it('beforeinput special inputTypes in [contenteditable] (not WebKit)', { browser: '!webkit' }, () => { const call1 = (e) => { expect(e.code).not.exist expect(e.data).eq(null) @@ -1967,6 +2001,50 @@ describe('src/cy/commands/actions/type - #type', { browser: '!webkit' }, () => { }) }) + // We do not emit simulated `beforeinput` events for the WebKit browser's + // contenteditable fields, as `execCommand('insertText')` will emit `beforeinput` + // when called. As a result, we do not emit as many events as other browsers in + // this test case, which includes no-op deletions due to a terminal cursor position. + it('beforeinput special inputTypes in [contenteditable] (WebKit)', { browser: 'webkit' }, () => { + const call1 = (e) => { + expect(e.code).not.exist + expect(e.data).eq(null) + + // WebKit does not distinguish between forward/backward + // deletion within a contenteditable field using `execCommand('delete')` + expect(e.inputType).eq('deleteContentBackward') + + stub.callsFake(call2) + } + const call2 = (e) => { + expect(e.code).not.exist + expect(e.data).eq(null) + + // WebKit does not distinguish between forward/backward + // deletion within a contenteditable field using `execCommand('delete')` + expect(e.inputType).eq('deleteContentBackward') + } + + const stub = cy.stub() + .callsFake(call1) + + cy.get('#input-types [contenteditable]') + .then(($el) => { + $el.text('foo bar baz') + $el[0].addEventListener('beforeinput', stub) + }) + // This command does not result in a change, as the cursor is in the right-most position + // and there is nothing to delete. This causes WebKit to not emit a beforeinput event. + .type('{ctrl}{del}') + // This command also does not result in a change, for the same reason. + .type('{ctrl}{shift}{del}') + .type('{ctrl}{backspace}') + .type('{ctrl}{shift}{backspace}') + .then(($el) => { + expect(stub).callCount(2) + }) + }) + it('can cancel beforeinput', () => { let callCount = 0 @@ -2356,19 +2434,16 @@ describe('src/cy/commands/actions/type - #type', { browser: '!webkit' }, () => { .then(function ($input) { const table = this.lastLog.invoke('consoleProps').table[2]() - // eslint-disable-next-line - console.table(table.data, table.columns) - expect(table.name).to.eq('Keyboard Events') - const expectedTable = { - 1: { 'Details': '{ code: KeyH, which: 72 }', Typed: 'h', 'Events Fired': `keydown, keypress, beforeinput, textInput, input, keyup`, 'Active Modifiers': null, 'Prevented Default': null, 'Target Element': $input[0] }, - 2: { 'Details': '{ code: ControlLeft, which: 17 }', Typed: '{ctrl}', 'Events Fired': 'keydown', 'Active Modifiers': 'ctrl', 'Prevented Default': null, 'Target Element': $input[0] }, - 3: { 'Details': '{ code: AltLeft, which: 18 }', Typed: '{alt}', 'Events Fired': 'keydown', 'Active Modifiers': 'alt, ctrl', 'Prevented Default': null, 'Target Element': $input[0] }, - 4: { 'Details': '{ code: Equal, which: 187 }', Typed: '+', 'Events Fired': 'keydown, keyup', 'Active Modifiers': 'alt, ctrl', 'Prevented Default': null, 'Target Element': $input[0] }, - 5: { 'Details': '{ code: AltLeft, which: 18 }', Typed: '{alt}', 'Events Fired': 'keyup', 'Active Modifiers': 'ctrl', 'Prevented Default': null, 'Target Element': $input[0] }, - 6: { 'Details': '{ code: ControlLeft, which: 17 }', Typed: '{ctrl}', 'Events Fired': 'keyup', 'Active Modifiers': null, 'Prevented Default': null, 'Target Element': $input[0] }, - 7: { 'Details': '{ code: KeyI, which: 73 }', Typed: 'i', 'Events Fired': `keydown, keypress, beforeinput, textInput, input, keyup`, 'Active Modifiers': null, 'Prevented Default': null, 'Target Element': $input[0] }, - } + const expectedTable = [ + { 'Details': '{ code: KeyH, which: 72 }', Typed: 'h', 'Events Fired': `keydown, keypress, beforeinput, textInput, input, keyup`, 'Active Modifiers': null, 'Prevented Default': null, 'Target Element': $input[0] }, + { 'Details': '{ code: ControlLeft, which: 17 }', Typed: '{ctrl}', 'Events Fired': 'keydown', 'Active Modifiers': 'ctrl', 'Prevented Default': null, 'Target Element': $input[0] }, + { 'Details': '{ code: AltLeft, which: 18 }', Typed: '{alt}', 'Events Fired': 'keydown', 'Active Modifiers': 'alt, ctrl', 'Prevented Default': null, 'Target Element': $input[0] }, + { 'Details': '{ code: Equal, which: 187 }', Typed: '+', 'Events Fired': 'keydown, keyup', 'Active Modifiers': 'alt, ctrl', 'Prevented Default': null, 'Target Element': $input[0] }, + { 'Details': '{ code: AltLeft, which: 18 }', Typed: '{alt}', 'Events Fired': 'keyup', 'Active Modifiers': 'ctrl', 'Prevented Default': null, 'Target Element': $input[0] }, + { 'Details': '{ code: ControlLeft, which: 17 }', Typed: '{ctrl}', 'Events Fired': 'keyup', 'Active Modifiers': null, 'Prevented Default': null, 'Target Element': $input[0] }, + { 'Details': '{ code: KeyI, which: 73 }', Typed: 'i', 'Events Fired': `keydown, keypress, beforeinput, textInput, input, keyup`, 'Active Modifiers': null, 'Prevented Default': null, 'Target Element': $input[0] }, + ] // uncomment for debugging // _.each(table.data, (v, i) => expect(v).containSubset(expectedTable[i])) @@ -2934,27 +3009,23 @@ describe('src/cy/commands/actions/type - #type', { browser: '!webkit' }, () => { .then(function ($input) { const table = this.lastLog.invoke('consoleProps').table[2]() - // eslint-disable-next-line - console.table(table.data, table.columns) - expect(table.name).to.eq('Keyboard Events') - const expectedTable = { - 1: { 'Details': '{ code: MetaLeft, which: 91 }', Typed: '{cmd}', 'Events Fired': 'keydown', 'Active Modifiers': 'meta', 'Prevented Default': null, 'Target Element': $input[0] }, - 2: { 'Details': '{ code: AltLeft, which: 18 }', Typed: '{option}', 'Events Fired': 'keydown', 'Active Modifiers': 'alt, meta', 'Prevented Default': null, 'Target Element': $input[0] }, - 3: { 'Details': '{ code: KeyF, which: 70 }', Typed: 'f', 'Events Fired': `keydown, keypress, beforeinput, textInput, input, keyup`, 'Active Modifiers': 'alt, meta', 'Prevented Default': null, 'Target Element': $input[0] }, - 4: { 'Details': '{ code: KeyO, which: 79 }', Typed: 'o', 'Events Fired': `keydown, keypress, beforeinput, textInput, input, keyup`, 'Active Modifiers': 'alt, meta', 'Prevented Default': null, 'Target Element': $input[0] }, - 5: { 'Details': '{ code: KeyO, which: 79 }', Typed: 'o', 'Events Fired': `keydown, keypress, beforeinput, textInput, input, keyup`, 'Active Modifiers': 'alt, meta', 'Prevented Default': null, 'Target Element': $input[0] }, - 6: { 'Details': '{ code: Enter, which: 13 }', Typed: '{enter}', 'Events Fired': `keydown, keypress, beforeinput, keyup`, 'Active Modifiers': 'alt, meta', 'Prevented Default': null, 'Target Element': $input[0] }, - 7: { 'Details': '{ code: KeyB, which: 66 }', Typed: 'b', 'Events Fired': `keydown, keypress, beforeinput, textInput, input, keyup`, 'Active Modifiers': 'alt, meta', 'Prevented Default': null, 'Target Element': $input[0] }, - 8: { 'Details': '{ code: ArrowLeft, which: 37 }', Typed: '{leftarrow}', 'Events Fired': 'keydown, keyup', 'Active Modifiers': 'alt, meta', 'Prevented Default': null, 'Target Element': $input[0] }, - 9: { 'Details': '{ code: Delete, which: 46 }', Typed: '{del}', 'Events Fired': `keydown, beforeinput, input, keyup`, 'Active Modifiers': 'alt, meta', 'Prevented Default': null, 'Target Element': $input[0] }, - 10: { 'Details': '{ code: Enter, which: 13 }', Typed: '{enter}', 'Events Fired': `keydown, keypress, beforeinput, keyup`, 'Active Modifiers': 'alt, meta', 'Prevented Default': null, 'Target Element': $input[0] }, - 11: { 'Details': '{ code: MetaLeft, which: 91 }', Typed: '{cmd}', 'Events Fired': 'keyup', 'Active Modifiers': 'alt', 'Prevented Default': null, 'Target Element': $input[0] }, - 12: { 'Details': '{ code: AltLeft, which: 18 }', Typed: '{option}', 'Events Fired': 'keyup', 'Active Modifiers': null, 'Prevented Default': null, 'Target Element': $input[0] }, - } + const expectedTable = [ + { 'Details': '{ code: MetaLeft, which: 91 }', Typed: '{cmd}', 'Events Fired': 'keydown', 'Active Modifiers': 'meta', 'Prevented Default': null, 'Target Element': $input[0] }, + { 'Details': '{ code: AltLeft, which: 18 }', Typed: '{option}', 'Events Fired': 'keydown', 'Active Modifiers': 'alt, meta', 'Prevented Default': null, 'Target Element': $input[0] }, + { 'Details': '{ code: KeyF, which: 70 }', Typed: 'f', 'Events Fired': `keydown, keypress, beforeinput, textInput, input, keyup`, 'Active Modifiers': 'alt, meta', 'Prevented Default': null, 'Target Element': $input[0] }, + { 'Details': '{ code: KeyO, which: 79 }', Typed: 'o', 'Events Fired': `keydown, keypress, beforeinput, textInput, input, keyup`, 'Active Modifiers': 'alt, meta', 'Prevented Default': null, 'Target Element': $input[0] }, + { 'Details': '{ code: KeyO, which: 79 }', Typed: 'o', 'Events Fired': `keydown, keypress, beforeinput, textInput, input, keyup`, 'Active Modifiers': 'alt, meta', 'Prevented Default': null, 'Target Element': $input[0] }, + { 'Details': '{ code: Enter, which: 13 }', Typed: '{enter}', 'Events Fired': `keydown, keypress, beforeinput, keyup`, 'Active Modifiers': 'alt, meta', 'Prevented Default': null, 'Target Element': $input[0] }, + { 'Details': '{ code: KeyB, which: 66 }', Typed: 'b', 'Events Fired': `keydown, keypress, beforeinput, textInput, input, keyup`, 'Active Modifiers': 'alt, meta', 'Prevented Default': null, 'Target Element': $input[0] }, + { 'Details': '{ code: ArrowLeft, which: 37 }', Typed: '{leftarrow}', 'Events Fired': 'keydown, keyup', 'Active Modifiers': 'alt, meta', 'Prevented Default': null, 'Target Element': $input[0] }, + { 'Details': '{ code: Delete, which: 46 }', Typed: '{del}', 'Events Fired': `keydown, beforeinput, input, keyup`, 'Active Modifiers': 'alt, meta', 'Prevented Default': null, 'Target Element': $input[0] }, + { 'Details': '{ code: Enter, which: 13 }', Typed: '{enter}', 'Events Fired': `keydown, keypress, beforeinput, keyup`, 'Active Modifiers': 'alt, meta', 'Prevented Default': null, 'Target Element': $input[0] }, + { 'Details': '{ code: MetaLeft, which: 91 }', Typed: '{cmd}', 'Events Fired': 'keyup', 'Active Modifiers': 'alt', 'Prevented Default': null, 'Target Element': $input[0] }, + { 'Details': '{ code: AltLeft, which: 18 }', Typed: '{option}', 'Events Fired': 'keyup', 'Active Modifiers': null, 'Prevented Default': null, 'Target Element': $input[0] }, + ] // uncomment for debugging - // _.each(table.data, (v, i) => expect(v).containSubset(expectedTable[i])) expect(table.data).to.deep.eq(expectedTable) expect($input.val()).eq('foo') }) @@ -2964,9 +3035,9 @@ describe('src/cy/commands/actions/type - #type', { browser: '!webkit' }, () => { cy.get(':text:first').type('f').then(function ($el) { const table = this.lastLog.invoke('consoleProps').table[2]() - expect(table.data).to.deep.eq({ - 1: { Typed: 'f', 'Events Fired': `keydown, keypress, beforeinput, textInput, input, keyup`, 'Active Modifiers': null, Details: '{ code: KeyF, which: 70 }', 'Prevented Default': null, 'Target Element': $el[0] }, - }) + expect(table.data).to.deep.eq([ + { Typed: 'f', 'Events Fired': 'keydown, keypress, beforeinput, textInput, input, keyup', 'Active Modifiers': null, Details: '{ code: KeyF, which: 70 }', 'Prevented Default': null, 'Target Element': $el[0] }, + ]) }) }) @@ -2978,12 +3049,9 @@ describe('src/cy/commands/actions/type - #type', { browser: '!webkit' }, () => { cy.get(':text:first').type('f').then(function ($el) { const table = this.lastLog.invoke('consoleProps').table[2]() - // eslint-disable-next-line - 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] }, - }) + expect(table.data).to.deep.eq([ + { Typed: 'f', 'Events Fired': 'keydown, keyup', 'Active Modifiers': null, Details: '{ code: KeyF, which: 70 }', 'Prevented Default': true, 'Target Element': $el[0] }, + ]) }) }) }) diff --git a/packages/driver/cypress/e2e/commands/actions/type_errors.cy.js b/packages/driver/cypress/e2e/commands/actions/type_errors.cy.js index 00225dc4c44d..b5c864ee7a4e 100644 --- a/packages/driver/cypress/e2e/commands/actions/type_errors.cy.js +++ b/packages/driver/cypress/e2e/commands/actions/type_errors.cy.js @@ -1,8 +1,7 @@ const { assertLogLength } = require('../../../support/utils') const { _, $ } = Cypress -// TODO(webkit): fix+unskip for experimental webkit release -describe('src/cy/commands/actions/type - #type errors', { browser: '!webkit' }, () => { +describe('src/cy/commands/actions/type - #type errors', () => { beforeEach(() => { cy.visit('/fixtures/dom.html') }) diff --git a/packages/driver/cypress/e2e/commands/actions/type_special_chars.cy.js b/packages/driver/cypress/e2e/commands/actions/type_special_chars.cy.js index 847ba0b64cf6..405857a55713 100644 --- a/packages/driver/cypress/e2e/commands/actions/type_special_chars.cy.js +++ b/packages/driver/cypress/e2e/commands/actions/type_special_chars.cy.js @@ -6,8 +6,7 @@ const { trimInnerText, } = require('../../../support/utils') -// TODO(webkit): fix+unskip for experimental webkit release -describe('src/cy/commands/actions/type - #type special chars', { browser: '!webkit' }, () => { +describe('src/cy/commands/actions/type - #type special chars', () => { before(function () { cy .visit('/fixtures/dom.html') @@ -114,7 +113,13 @@ describe('src/cy/commands/actions/type - #type special chars', { browser: '!webk it('fires textInput event with e.data', (done) => { cy.$$(':text:first').on('textInput', (e) => { - expect(e.originalEvent.data).to.eq('{') + if (Cypress.isBrowser('webkit')) { + // For WebKit, the simulated textInput event is not + // emitted with the char data to prevent double entry. + expect(e.originalEvent.data).to.eq('') + } else { + expect(e.originalEvent.data).to.eq('{') + } done() }) @@ -309,8 +314,8 @@ describe('src/cy/commands/actions/type - #type special chars', { browser: '!webk attachKeyListeners({ input }) cy.get(':text:first').invoke('val', 'ab') - .then(($input) => $input[0].setSelectionRange(0, 0)) .focus() + .then(($input) => $input[0].setSelectionRange(0, 0)) .type('{backspace}') .should('have.value', 'ab') @@ -352,8 +357,8 @@ describe('src/cy/commands/actions/type - #type special chars', { browser: '!webk attachKeyListeners({ input }) cy.get('textarea:first').invoke('val', 'ab') - .then(($textarea) => $textarea[0].setSelectionRange(0, 0)) .focus() + .then(($textarea) => $textarea[0].setSelectionRange(0, 0)) .type('{backspace}') .should('have.value', 'ab') @@ -447,9 +452,8 @@ describe('src/cy/commands/actions/type - #type special chars', { browser: '!webk attachKeyListeners({ input }) cy.get(':text:first').invoke('val', 'ab') - - .then(($input) => $input[0].setSelectionRange(0, 0)) .focus() + .then(($input) => $input[0].setSelectionRange(0, 0)) .type('{del}') .should('have.value', 'b') @@ -493,8 +497,8 @@ describe('src/cy/commands/actions/type - #type special chars', { browser: '!webk attachKeyListeners({ textarea }) cy.get('textarea:first').invoke('val', 'ab') - .then(($textarea) => $textarea[0].setSelectionRange(0, 0)) .focus() + .then(($textarea) => $textarea[0].setSelectionRange(0, 0)) .type('{del}') .should('have.value', 'b') @@ -999,7 +1003,10 @@ describe('src/cy/commands/actions/type - #type special chars', { browser: '!webk cy.get('input[type="number"]:first') .invoke('val', '12.34') .type('{uparrow}{uparrow}') - .should('have.value', '14') + // WebKit does not round to the step value when calling stepUp/stepDown, + // as we do here for the ArrowUp handler. + // https://bugs.webkit.org/show_bug.cgi?id=244206 + .should('have.value', Cypress.isBrowser('webkit') ? '14.34' : '14') }) }) @@ -1057,7 +1064,10 @@ describe('src/cy/commands/actions/type - #type special chars', { browser: '!webk cy.get('input[type="number"]:first') .invoke('val', '12.34') .type('{downarrow}{downarrow}') - .should('have.value', '11') + // WebKit does not round to the step value when calling stepUp/stepDown, + // as we do here for the ArrowDown handler + // https://bugs.webkit.org/show_bug.cgi?id=244206 + .should('have.value', Cypress.isBrowser('webkit') ? '10.34' : '11') }) it('downarrow ignores current selection', () => { @@ -1292,7 +1302,6 @@ describe('src/cy/commands/actions/type - #type special chars', { browser: '!webk this.$forms.find('#single-input').submit((e) => { e.preventDefault() - events.push('submit') }) @@ -1736,7 +1745,8 @@ describe('src/cy/commands/actions/type - #type special chars', { browser: '!webk }) }) - it('will not submit the form', function (done) { + // WebKit still emits the submit event on the form in this configuration. + it('will not submit the form', { browser: '!webkit' }, function (done) { this.$forms.find('#multiple-inputs-and-multiple-submits').submit(() => { done(new Error('should not receive submit event')) }) diff --git a/packages/driver/cypress/e2e/e2e/origin/commands/actions.cy.ts b/packages/driver/cypress/e2e/e2e/origin/commands/actions.cy.ts index 3d6819a00e74..23495075d7f7 100644 --- a/packages/driver/cypress/e2e/e2e/origin/commands/actions.cy.ts +++ b/packages/driver/cypress/e2e/e2e/origin/commands/actions.cy.ts @@ -455,14 +455,14 @@ context('cy.origin actions', () => { expect(datum).to.have.property('Target Element').that.deep.equals(consoleProps['Applied To']) }) - expect(KeyboardEventsTable.data[1]).to.have.property('Details').that.equals('{ code: KeyF, which: 70 }') - expect(KeyboardEventsTable.data[1]).to.have.property('Typed').that.equals('f') + expect(KeyboardEventsTable.data[0]).to.have.property('Details').that.equals('{ code: KeyF, which: 70 }') + expect(KeyboardEventsTable.data[0]).to.have.property('Typed').that.equals('f') + + expect(KeyboardEventsTable.data[1]).to.have.property('Details').that.equals('{ code: KeyO, which: 79 }') + expect(KeyboardEventsTable.data[1]).to.have.property('Typed').that.equals('o') expect(KeyboardEventsTable.data[2]).to.have.property('Details').that.equals('{ code: KeyO, which: 79 }') expect(KeyboardEventsTable.data[2]).to.have.property('Typed').that.equals('o') - - expect(KeyboardEventsTable.data[3]).to.have.property('Details').that.equals('{ code: KeyO, which: 79 }') - expect(KeyboardEventsTable.data[3]).to.have.property('Typed').that.equals('o') }) }) diff --git a/packages/driver/src/cy/commands/actions/focus.ts b/packages/driver/src/cy/commands/actions/focus.ts index e866def4c133..414c336c9891 100644 --- a/packages/driver/src/cy/commands/actions/focus.ts +++ b/packages/driver/src/cy/commands/actions/focus.ts @@ -4,6 +4,7 @@ import $dom from '../../../dom' import $utils from '../../../cypress/utils' import $errUtils from '../../../cypress/error_utils' import $elements from '../../../dom/elements' +import $selection from '../../../dom/selection' import type { Log } from '../../../cypress/log' interface InternalFocusOptions extends Partial { @@ -89,6 +90,18 @@ export default (Commands, Cypress, cy) => { cy.fireFocus(el) + if (Cypress.isBrowser('webkit') && ( + $elements.isInput(el) || $elements.isTextarea(el) + )) { + // Force selection to end in WebKit, unless selection + // has been set by user. + // It's a curried function, so the 2 arguments are valid. + // @ts-ignore + $selection.moveSelectionToEnd(el, { + onlyIfEmptySelection: true, + }) + } + const verifyAssertions = () => { return cy.verifyUpcomingAssertions(options.$el, options, { onRetry: verifyAssertions, diff --git a/packages/driver/src/cy/commands/actions/selectFile.ts b/packages/driver/src/cy/commands/actions/selectFile.ts index a946250bc529..946587b68894 100644 --- a/packages/driver/src/cy/commands/actions/selectFile.ts +++ b/packages/driver/src/cy/commands/actions/selectFile.ts @@ -20,7 +20,7 @@ import { addEventCoords, dispatch } from './trigger' * override webkitGetAsEntry() throws an error. * */ -const tryMockWebkit = (item) => { +const tryMockWebKit = (item) => { try { item.webkitGetAsEntry = () => { return { @@ -58,7 +58,7 @@ const createDataTransfer = (files: Cypress.FileReferenceObject[], eventTarget: J // also cannot be constructed, so we have to use an array instead. Object.defineProperty(dataTransfer, 'items', { get () { - return _.map(oldItems, tryMockWebkit) + return _.map(oldItems, tryMockWebKit) }, }) diff --git a/packages/driver/src/cy/commands/actions/type.ts b/packages/driver/src/cy/commands/actions/type.ts index 9b083b55fdf8..e42400c62eb4 100644 --- a/packages/driver/src/cy/commands/actions/type.ts +++ b/packages/driver/src/cy/commands/actions/type.ts @@ -87,14 +87,8 @@ export default function (Commands, Cypress, cy, state, config) { } } - // transform table object into object with zero based index as keys const getTableData = () => { - return _.reduce(_.values(table), (memo, value, index) => { - memo[index + 1] = value - - return memo - } - , {}) + return _.values(table) } options._log = Cypress.log({ @@ -255,7 +249,10 @@ export default function (Commands, Cypress, cy, state, config) { // when we send {Enter} KeyboardEvent to the input fields. // Because of that, we don't have to click the submit buttons. // Otherwise, we trigger submit events twice. - if (!isFirefoxBefore98) { + // + // WebKit will send the submit with an Enter keypress event, + // so we do need to click the default button in this case. + if (!isFirefoxBefore98 && !Cypress.isBrowser('webkit')) { // issue the click event to the 'default button' of the form // we need this to be synchronous so not going through our // own click command diff --git a/packages/driver/src/cy/focused.ts b/packages/driver/src/cy/focused.ts index d1592919bbfa..730cac177c6a 100644 --- a/packages/driver/src/cy/focused.ts +++ b/packages/driver/src/cy/focused.ts @@ -123,12 +123,17 @@ export const create = (state: StateFunc) => ({ const $focused = this.getFocused(el.ownerDocument) let hasFocused = false + let onFocusCapture // 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 // currently focused const cleanup = () => { + if (onFocusCapture) { + $elements.callNativeMethod(el, 'removeEventListener', 'focus', onFocusCapture, { capture: true }) + } + return $elements.callNativeMethod(el, 'removeEventListener', 'focus', onFocus) } @@ -136,6 +141,48 @@ export const create = (state: StateFunc) => ({ return hasFocused = true } + if (Cypress.isBrowser('webkit')) { + // By default, WebKit will select the contents of an input element when the input is focused. + // This is problematic, as we use the existence of any selection to determine whether + // we adjust the input's cursor and prepare the input for receiving additional content. + // Without intervention, we will always interpret this default selection as a user-performed selection + // and persist it, leaving the selection contents to be overwritten rather than appended to + // on subsequent actions. + // + // In order to avoid this behavior, we use a focus event during the capture phase to set + // our own initial blank selection. This short-circuits WebKit's default behavior and ensures + // that any user-performed selections performed during the focus event's bubble phase are still applied. + + onFocusCapture = (event: FocusEvent) => { + const eventTarget = event.currentTarget as HTMLInputElement + + if (!eventTarget.setSelectionRange) { + return + } + + try { + // Prior to being focused, the element's selectionStart/End will be at 0. + // Even so, we need to explicitly call setSelectionRange here to prevent WebKit + // from selecting the contents after being focused. + // + // By re-setting the selection at the current start/end values, + // we ensure that any selection values set by previous event handlers + // are persisted. + eventTarget.setSelectionRange( + eventTarget.selectionStart, + eventTarget.selectionEnd, + ) + } catch (e) { + // Some input types do not support selections and will throw when + // setSelectionRange is called. We can ignore these errors, + // as these elements wouldn't have a selection to we need to + // prevent anyway. + } + } + + $elements.callNativeMethod(el, 'addEventListener', 'focus', onFocusCapture, { capture: true }) + } + $elements.callNativeMethod(el, 'addEventListener', 'focus', onFocus) $elements.callNativeMethod(el, 'focus', opts) diff --git a/packages/driver/src/cy/keyboard.ts b/packages/driver/src/cy/keyboard.ts index 485687fb13e0..6bf3660a5f89 100644 --- a/packages/driver/src/cy/keyboard.ts +++ b/packages/driver/src/cy/keyboard.ts @@ -912,7 +912,18 @@ export class Keyboard { keyCode = 0 which = 0 location = undefined - data = text === '\r' ? '↵' : text + + // WebKit will insert characters on a textInput event, resulting + // in double char entry when the default handler is executed. But values + // inserted by textInput aren't always correct/aren't filtered + // through our shouldUpdateValue logic, so we prevent textInput's + // default logic by removing the key data from the event. + if (Cypress.isBrowser('webkit')) { + data = '' + } else { + data = text === '\r' ? '↵' : text + } + break case 'beforeinput': @@ -1162,6 +1173,13 @@ export class Keyboard { if ($elements.isContentEditable(elToType)) { key.events.input = false + + if (Cypress.isBrowser('webkit')) { + // WebKit will emit beforeinput itself when the text is + // inserted into a contenteditable input using `execCommand('insertText')`. + // We prevent the simulated event from firing to avoid duplicative events. + key.events.beforeinput = false + } } else if ($elements.isReadOnlyInputOrTextarea(elToType)) { key.events.textInput = false } diff --git a/packages/driver/src/cy/mouse.ts b/packages/driver/src/cy/mouse.ts index 3ba3ac914881..2958b03881a8 100644 --- a/packages/driver/src/cy/mouse.ts +++ b/packages/driver/src/cy/mouse.ts @@ -57,6 +57,7 @@ type DefaultMouseOptions = ModifiersEventOptions & CoordsEventOptions & { export const create = (state: StateFunc, keyboard: Keyboard, focused: IFocused, Cypress: ICypress) => { const isFirefox = Cypress.browser.family === 'firefox' + const isWebKit = Cypress.isBrowser('webkit') const sendPointerEvent = (el, evtOptions, evtName, bubbles = false, cancelable = false) => { const constructor = el.ownerDocument.defaultView.PointerEvent @@ -72,14 +73,14 @@ export const create = (state: StateFunc, keyboard: Keyboard, focused: IFocused, } const sendPointerup = (el, evtOptions) => { - if (isFirefox && el.disabled) { + if ((isFirefox || isWebKit) && el.disabled) { return {} } return sendPointerEvent(el, evtOptions, 'pointerup', true, true) } const sendPointerdown = (el, evtOptions): {} | SentEvent => { - if (isFirefox && el.disabled) { + if ((isFirefox || isWebKit) && el.disabled) { return {} } @@ -102,14 +103,14 @@ export const create = (state: StateFunc, keyboard: Keyboard, focused: IFocused, } const sendMouseup = (el, evtOptions) => { - if (isFirefox && el.disabled) { + if ((isFirefox || isWebKit) && el.disabled) { return {} } return sendMouseEvent(el, evtOptions, 'mouseup', true, true) } const sendMousedown = (el, evtOptions): {} | SentEvent => { - if (isFirefox && el.disabled) { + if ((isFirefox || isWebKit) && el.disabled) { return {} } @@ -132,21 +133,21 @@ export const create = (state: StateFunc, keyboard: Keyboard, focused: IFocused, } const sendClick = (el, evtOptions, opts: { force?: boolean } = {}) => { // send the click event if firefox and force (needed for force check checkbox) - if (!opts.force && isFirefox && el.disabled) { + if (!opts.force && (isFirefox || isWebKit) && el.disabled) { return {} } return sendMouseEvent(el, evtOptions, 'click', true, true) } const sendDblclick = (el, evtOptions) => { - if (isFirefox && el.disabled) { + if ((isFirefox || isWebKit) && el.disabled) { return {} } return sendMouseEvent(el, evtOptions, 'dblclick', true, true) } const sendContextmenu = (el, evtOptions) => { - if (isFirefox && el.disabled) { + if ((isFirefox || isWebKit) && el.disabled) { return {} } diff --git a/packages/driver/src/dom/selection.ts b/packages/driver/src/dom/selection.ts index 649a3cfabd2a..a4ad9af6593a 100644 --- a/packages/driver/src/dom/selection.ts +++ b/packages/driver/src/dom/selection.ts @@ -575,19 +575,7 @@ const _moveSelectionTo = function (toStart: boolean, el: HTMLElement, options = $elements.callNativeMethod(doc, 'execCommand', 'selectAll', false, null) } } else { - let range - - // Sometimes, selection.rangeCount is 0 when there is no selection. - // In that case, it fails in Chrome. - // We're creating a new range and add it to the selection to avoid the case. - if (selection.rangeCount === 0) { - range = doc.createRange() - selection.addRange(range) - } else { - range = selection.getRangeAt(0) - } - - range.selectNodeContents(el) + selection.selectAllChildren(el) } toStart ? selection.collapseToStart() : selection.collapseToEnd() diff --git a/packages/server/lib/browsers/webkit-automation.ts b/packages/server/lib/browsers/webkit-automation.ts index 0ebac526187a..cccf6b52caa7 100644 --- a/packages/server/lib/browsers/webkit-automation.ts +++ b/packages/server/lib/browsers/webkit-automation.ts @@ -83,7 +83,7 @@ const _cookieMatches = (cookie: any, filter: Record) => { let requestIdCounter = 1 const requestIdMap = new WeakMap() -export class WebkitAutomation { +export class WebKitAutomation { private context!: playwright.BrowserContext private page!: playwright.Page @@ -91,7 +91,7 @@ export class WebkitAutomation { // static initializer to avoid "not definitively declared" static async create (automation: Automation, browser: playwright.Browser, initialUrl: string) { - const wkAutomation = new WebkitAutomation(automation, browser) + const wkAutomation = new WebKitAutomation(automation, browser) await wkAutomation.reset(initialUrl) diff --git a/packages/server/lib/browsers/webkit.ts b/packages/server/lib/browsers/webkit.ts index e236a7ecfcf4..58c7661856c6 100644 --- a/packages/server/lib/browsers/webkit.ts +++ b/packages/server/lib/browsers/webkit.ts @@ -3,11 +3,11 @@ import { EventEmitter } from 'events' import type playwright from 'playwright-webkit' import type { Browser, BrowserInstance } from './types' import type { Automation } from '../automation' -import { WebkitAutomation } from './webkit-automation' +import { WebKitAutomation } from './webkit-automation' const debug = Debug('cypress:server:browsers:webkit') -let wkAutomation: WebkitAutomation | undefined +let wkAutomation: WebKitAutomation | undefined export async function connectToNewSpec (browser: Browser, options, automation: Automation) { if (!wkAutomation) throw new Error('connectToNewSpec called without wkAutomation') @@ -31,7 +31,7 @@ export async function open (browser: Browser, url, options: any = {}, automation headless: browser.isHeadless, }) - wkAutomation = await WebkitAutomation.create(automation, pwBrowser, url) + wkAutomation = await WebKitAutomation.create(automation, pwBrowser, url) automation.use(wkAutomation) class WkInstance extends EventEmitter implements BrowserInstance {