From 3c74ac8444e8748fd5352e01254f3449c1e43e52 Mon Sep 17 00:00:00 2001 From: BlueWinds Date: Mon, 12 Sep 2022 14:10:45 -0700 Subject: [PATCH 01/54] First stab at removing old .get() implementation --- .../driver/cypress/e2e/commands/angular.cy.js | 332 --------- .../e2e/commands/querying/querying.cy.js | 6 +- packages/driver/cypress/e2e/cypress/cy.cy.js | 29 +- packages/driver/cypress/fixtures/angular.html | 63 -- packages/driver/cypress/support/utils.js | 16 +- packages/driver/package.json | 1 - packages/driver/src/cy/commands/angular.ts | 127 ---- packages/driver/src/cy/commands/index.ts | 3 - .../src/cy/commands/querying/querying.ts | 661 ++++-------------- packages/driver/src/cy/retries.ts | 2 +- packages/driver/src/cypress/command_queue.ts | 5 +- packages/driver/src/cypress/cy.ts | 7 + packages/driver/src/cypress/error_messages.ts | 2 +- 13 files changed, 169 insertions(+), 1085 deletions(-) delete mode 100644 packages/driver/cypress/e2e/commands/angular.cy.js delete mode 100644 packages/driver/cypress/fixtures/angular.html delete mode 100644 packages/driver/src/cy/commands/angular.ts diff --git a/packages/driver/cypress/e2e/commands/angular.cy.js b/packages/driver/cypress/e2e/commands/angular.cy.js deleted file mode 100644 index 089c35903016..000000000000 --- a/packages/driver/cypress/e2e/commands/angular.cy.js +++ /dev/null @@ -1,332 +0,0 @@ -const { assertLogLength } = require('../../support/utils') -const { _, $ } = Cypress - -describe('src/cy/commands/angular', () => { - beforeEach(() => { - cy.visit('/fixtures/angular.html') - }) - - describe('#ng', () => { - context('find by binding', () => { - it('finds color.name binding elements', () => { - const spans = cy.$$('.colors span.name') - - cy.ng('binding', 'color.name').then(($spans) => { - $spans.each((i, span) => { - expect(span).to.eq(spans[i]) - }) - }) - }) - - describe('errors', { - defaultCommandTimeout: 100, - }, () => { - beforeEach(function () { - this.angular = cy.state('window').angular - }) - - afterEach(function () { - cy.state('window').angular = this.angular - }) - - it('throws when cannot find angular', { retries: 2 }, (done) => { - delete cy.state('window').angular - - cy.on('fail', (err) => { - expect(err.message).to.include('Angular global (`window.angular`) was not found in your window. You cannot use `cy.ng()` methods without angular.') - - done() - }) - - cy.ng('binding', 'phone') - }) - - it('throws when binding cannot be found', (done) => { - cy.on('fail', (err) => { - expect(err.message).to.include('Could not find element for binding: \'not-found\'.') - - done() - }) - - cy.ng('binding', 'not-found') - }) - - it('cancels additional finds when aborted', (done) => { - cy.timeout(1000) - cy.stub(Cypress.runner, 'stop') - - let retry = _.after(2, () => { - Cypress.stop() - }) - - cy.on('command:retry', retry) - - cy.on('fail', (err) => { - done(err) - }) - - cy.on('stop', () => { - retry = cy.spy(cy, 'retry') - - _.delay(() => { - expect(retry.callCount).to.eq(0) - - done() - }, 100) - }) - - cy.ng('binding', 'not-found') - }) - }) - }) - - context('find by repeater', () => { - const ngPrefixes = { 'phone in phones': 'ng-', 'phone2 in phones': 'ng_', 'phone3 in phones': 'data-ng-', 'phone4 in phones': 'x-ng-' } - - _.each(ngPrefixes, (prefix, attr) => { - it(`finds by ${prefix}repeat`, () => { - // make sure we find this element - const li = cy.$$(`[${prefix}repeat*='${attr}']`) - - expect(li).to.exist - - // and make sure they are the same DOM element - cy.ng('repeater', attr).then(($li) => { - expect($li.get(0)).to.eq(li.get(0)) - }) - }) - }) - - it('favors earlier items in the array when duplicates are found', () => { - const li = cy.$$('[ng-repeat*=\'foo in foos\']') - - cy.ng('repeater', 'foo in foos').then(($li) => { - expect($li.get(0)).to.eq(li.get(0)) - }) - }) - - it('waits to find a missing input', () => { - const missingLi = $('
  • ', { 'data-ng-repeat': 'li in lis' }) - - // wait until we're ALMOST about to time out before - // appending the missingInput - cy.on('command:retry', _.after(2, () => { - cy.$$('body').append(missingLi) - })) - - cy.ng('repeater', 'li in lis').then(($li) => { - expect($li).to.match(missingLi) - }) - }) - - describe('errors', { - defaultCommandTimeout: 100, - }, () => { - beforeEach(function () { - this.angular = cy.state('window').angular - }) - - afterEach(function () { - cy.state('window').angular = this.angular - }) - - it('throws when repeater cannot be found', (done) => { - cy.on('fail', (err) => { - expect(err.message).to.include('Could not find element for repeater: \'not-found\'. Searched [ng-repeat*=\'not-found\'], [ng_repeat*=\'not-found\'], [data-ng-repeat*=\'not-found\'], [x-ng-repeat*=\'not-found\'].') - - done() - }) - - cy.ng('repeater', 'not-found') - }) - - it('cancels additional finds when aborted', (done) => { - cy.timeout(1000) - cy.stub(Cypress.runner, 'stop') - - let retry = _.after(2, () => { - Cypress.stop() - }) - - cy.on('command:retry', retry) - - cy.on('fail', (err) => { - done(err) - }) - - cy.on('stop', () => { - retry = cy.spy(cy, 'retry') - - _.delay(() => { - expect(retry.callCount).to.eq(0) - - done() - }, 100) - }) - - cy.ng('repeater', 'not-found') - }) - - it('throws when cannot find angular', (done) => { - delete cy.state('window').angular - - cy.on('fail', (err) => { - expect(err.message).to.include('Angular global (`window.angular`) was not found in your window. You cannot use `cy.ng()` methods without angular.') - - done() - }) - - cy.ng('repeater', 'phone in phones') - }) - }) - - describe('log', () => { - beforeEach(function () { - this.logs = [] - - cy.on('log:added', (attrs, log) => { - if (attrs.name === 'assert') { - this.lastLog = log - this.logs.push(log) - } - }) - - return null - }) - - it('does not incorrectly merge 2nd assertion into 1st', function () { - cy - .ng('repeater', 'foo in foos').should('have.length', 2) - .url().should('include', ':') - .then(() => { - assertLogLength(this.logs, 2) - expect(this.logs[0].get('state')).to.eq('passed') - expect(this.logs[1].get('state')).to.eq('passed') - }) - }) - }) - }) - - context('find by model', () => { - const ngPrefixes = { query: 'ng-', query2: 'ng_', query3: 'data-ng-', query4: 'x-ng-' } - - _.each(ngPrefixes, (prefix, attr) => { - it(`finds element by ${prefix}model`, () => { - // make sure we find this element - const input = cy.$$(`[${prefix}model=${attr}]`) - - expect(input).to.exist - - // and make sure they are the same DOM element - cy.ng('model', attr).then(($input) => { - expect($input.get(0)).to.eq(input.get(0)) - }) - }) - }) - - it('favors earlier items in the array when duplicates are found', () => { - const input = cy.$$('[ng-model=foo]') - - cy.ng('model', 'foo').then(($input) => { - expect($input.get(0)).to.eq(input.get(0)) - }) - }) - - it('waits to find a missing input', () => { - const missingInput = $('', { 'data-ng-model': 'missing-input' }) - - // wait until we're ALMOST about to time out before - // appending the missingInput - cy.on('command:retry', _.after(2, () => { - cy.$$('body').append(missingInput) - })) - - cy.ng('model', 'missing-input').then(($input) => { - expect($input).to.match(missingInput) - }) - }) - - it('cancels other retries when one resolves', () => { - const retry = cy.spy(cy, 'retry') - - const missingInput = $('', { 'data-ng-model': 'missing-input' }) - - cy.on('command:retry', _.after(6, _.once(() => { - cy.$$('body').append(missingInput) - }))) - - // we want to make sure that the ng promises do not continue - // to retry after the first one resolves - cy.ng('model', 'missing-input') - .then(() => { - return retry.resetHistory() - }) - .wait(100) - .then(() => { - expect(retry.callCount).to.eq(0) - }) - }) - - describe('errors', { - defaultCommandTimeout: 100, - }, () => { - beforeEach(function () { - this.angular = cy.state('window').angular - }) - - afterEach(function () { - cy.state('window').angular = this.angular - }) - - it('throws when model cannot be found', (done) => { - cy.ng('model', 'not-found') - - cy.on('fail', (err) => { - expect(err.message).to.include('Could not find element for model: \'not-found\'. Searched [ng-model=\'not-found\'], [ng_model=\'not-found\'], [data-ng-model=\'not-found\'], [x-ng-model=\'not-found\'].') - - done() - }) - }) - - it('cancels additional finds when aborted', (done) => { - cy.timeout(1000) - cy.stub(Cypress.runner, 'stop') - - let retry = _.after(2, () => { - Cypress.stop() - }) - - cy.on('command:retry', retry) - - cy.on('fail', (err) => { - done(err) - }) - - cy.on('stop', () => { - retry = cy.spy(cy, 'retry') - - _.delay(() => { - expect(retry.callCount).to.eq(0) - - done() - }, 100) - }) - - cy.ng('model', 'not-found') - }) - - it('throws when cannot find angular', (done) => { - delete cy.state('window').angular - - cy.on('fail', (err) => { - expect(err.message).to.include('Angular global (`window.angular`) was not found in your window. You cannot use `cy.ng()` methods without angular.') - - done() - }) - - cy.ng('model', 'query') - }) - }) - }) - }) -}) diff --git a/packages/driver/cypress/e2e/commands/querying/querying.cy.js b/packages/driver/cypress/e2e/commands/querying/querying.cy.js index a66e6f8498b1..2c2bc5267b6f 100644 --- a/packages/driver/cypress/e2e/commands/querying/querying.cy.js +++ b/packages/driver/cypress/e2e/commands/querying/querying.cy.js @@ -960,7 +960,7 @@ describe('src/cy/commands/querying', () => { }) }) - it('GET is scoped to the current subject', () => { + it('is scoped to the current subject', () => { const span = cy.$$('#click-me a span') cy.get('#click-me a').contains('click').then(($span) => { @@ -1562,7 +1562,7 @@ space }) it('sets type to child when used as a child command', () => { - cy.get('body').contains('foo').then(function () { + cy.get('#specific-contains').contains('foo').then(function () { expect(this.lastLog.get('type')).to.eq('child') }) }) @@ -1744,7 +1744,7 @@ space it('throws when assertion is have.length > 1', function (done) { cy.on('fail', (err) => { assertLogLength(this.logs, 2) - expect(err.message).to.eq('`cy.contains()` cannot be passed a `length` option because it will only ever return 1 element.') + expect(err.message).to.eq('`cy.contains()` only ever returns one element, so you cannot assert on a `length` greater than one.') expect(err.docsUrl).to.eq('https://on.cypress.io/contains') done() diff --git a/packages/driver/cypress/e2e/cypress/cy.cy.js b/packages/driver/cypress/e2e/cypress/cy.cy.js index d84572ad8a7a..da5b15c3c456 100644 --- a/packages/driver/cypress/e2e/cypress/cy.cy.js +++ b/packages/driver/cypress/e2e/cypress/cy.cy.js @@ -527,12 +527,31 @@ describe('driver/src/cypress/cy', () => { cy.aQuery() }) - // TODO: Make this work. Setting aside for now. - it.skip('does allow queries to use other queries', () => { - Cypress.Commands._overwriteQuery('aQuery', () => cy.bQuery()) - Cypress.Commands._overwriteQuery('bQuery', () => {}) + it('custom commands that return query chainers retry', () => { + Cypress.Commands.add('getButton', () => cy.get('button')) + cy.on('command:retry', () => cy.$$('button').first().remove()) - cy.aQuery() + cy.getButton().should('have.length', 23) + }) + + it('allows queries to use other queries', () => { + const logs = [] + + cy.on('log:added', (attrs, log) => logs.push(log)) + + Cypress.Commands.overwriteQuery('aQuery', () => { + cy.now('get', 'body') + + return cy.now('get', 'button') + }) + + Cypress.Commands.overwriteQuery('bQuery', () => cy.now('aQuery')) + + cy.aQuery().should('have.length', 24) + cy.then(() => { + // Length of 3: bQuery.body (from get), bQuery.button (from get), should.have.length.23 + expect(logs.length).to.eq(3) + }) }) }) }) diff --git a/packages/driver/cypress/fixtures/angular.html b/packages/driver/cypress/fixtures/angular.html deleted file mode 100644 index b9200da46e6d..000000000000 --- a/packages/driver/cypress/fixtures/angular.html +++ /dev/null @@ -1,63 +0,0 @@ - - - - Angular HTML Fixture - - - - - Angular! - -
    - - - - - - - - -
      -
    • - {{color.id}}: - {{color.name}} -
    • -
    - -
      -
    • -
    - -
      -
    • -
    - -
      -
    • -
    - -
      -
    • -
    - -
      -
    • -
    • -
    -
    - - diff --git a/packages/driver/cypress/support/utils.js b/packages/driver/cypress/support/utils.js index e24aee8c3d1b..357f75db65fc 100644 --- a/packages/driver/cypress/support/utils.js +++ b/packages/driver/cypress/support/utils.js @@ -98,15 +98,17 @@ export const attachListeners = (listenerArr) => { } const getAllFn = (...aliases) => { + let getFns + if (aliases.length > 1) { - return getAllFn((_.isArray(aliases[1]) ? aliases[1] : aliases[1].split(' ')).map((alias) => `@${aliases[0]}:${alias}`).join(' ')) + const aliasArray = _.isArray(aliases[1]) ? aliases[1] : aliases[1].split(' ') + + getFns = aliasArray.map((alias) => cy.now('get', `@${aliases[0]}:${alias}`)) + } else { + getFns = aliases[0].split(' ').map((alias) => cy.now('get', `@${aliases[0]}:${alias}`)) } - return Promise.all( - aliases[0].split(' ').map((alias) => { - return cy.now('get', alias) - }), - ) + return () => getFns.map((fn) => fn()) } const shouldWithTimeout = (cb, timeout = 250) => { @@ -137,7 +139,7 @@ export const expectCaret = (start, end) => { } } -Cypress.Commands.add('getAll', getAllFn) +Cypress.Commands.addQuery('getAll', getAllFn) Cypress.Commands.add('shouldWithTimeout', shouldWithTimeout) diff --git a/packages/driver/package.json b/packages/driver/package.json index 196be0f7bd3f..859030ff33d3 100644 --- a/packages/driver/package.json +++ b/packages/driver/package.json @@ -33,7 +33,6 @@ "@types/jquery.scrollto": "1.4.29", "@types/mocha": "^8.0.3", "@types/sinonjs__fake-timers": "8.1.1", - "angular": "1.8.0", "basic-auth": "2.0.1", "blob-util": "2.0.2", "bluebird": "3.5.3", diff --git a/packages/driver/src/cy/commands/angular.ts b/packages/driver/src/cy/commands/angular.ts deleted file mode 100644 index f6b4bf374979..000000000000 --- a/packages/driver/src/cy/commands/angular.ts +++ /dev/null @@ -1,127 +0,0 @@ -import _ from 'lodash' -import $ from 'jquery' -import Promise from 'bluebird' - -import $errUtils from '../../cypress/error_utils' -import type { Log } from '../../cypress/log' - -const ngPrefixes = ['ng-', 'ng_', 'data-ng-', 'x-ng-'] - -interface InternalNgOptions extends Partial { - _log?: Log -} - -export default (Commands, Cypress, cy, state) => { - const findByNgBinding = (binding, options) => { - const selector = '.ng-binding' - - const { angular } = state('window') - - _.extend(options, { verify: false, log: false }) - - const getEl = ($elements) => { - const filtered = $elements.filter((index, el) => { - const dataBinding = angular.element(el).data('$binding') - - if (dataBinding) { - const bindingName = dataBinding.exp || dataBinding[0].exp || dataBinding - - return bindingName.includes(binding) - } - }) - - // if we have items return - // those filtered items - if (filtered.length) { - return filtered - } - - // else return null element - return $(null as any) // cast to any to satisfy typescript - } - - const resolveElements = () => { - return cy.now('get', selector, options).then(($elements) => { - return cy.verifyUpcomingAssertions(getEl($elements), options, { - onRetry: resolveElements, - onFail (err) { - err.message = `Could not find element for binding: '${binding}'.` - }, - }) - }) - } - - return resolveElements() - } - - const findByNgAttr = (name, attr, el, options) => { - const selectors: string[] = [] - let error = `Could not find element for ${name}: '${el}'. Searched ` - - _.extend(options, { verify: false, log: false }) - - const finds = _.map(ngPrefixes, (prefix) => { - const selector = `[${prefix}${attr}'${el}']` - - selectors.push(selector) - - const resolveElements = () => { - return cy.now('get', selector, options).then(($elements) => { - return cy.verifyUpcomingAssertions($elements, options, { - onRetry: resolveElements, - }) - }) - } - - return resolveElements() - }) - - error += `${selectors.join(', ')}.` - - const cancelAll = () => { - return _.invokeMap(finds, 'cancel') - } - - return Promise - .any(finds) - .then((subject) => { - cancelAll() - - return subject - }).catch(Promise.AggregateError, () => { - return $errUtils.throwErr(error) - }) - } - - Commands.addAll({ - ng (type: string, selector: string, userOptions: Partial = {}) { - // what about requirejs / browserify? - // we need to intelligently check to see if we're using those - // and if angular is available through them. throw a very specific - // error message here that's different depending on what module - // system you're using - if (!state('window').angular) { - $errUtils.throwErrByPath('ng.no_global') - } - - const options: InternalNgOptions = _.defaults({}, userOptions, { log: true }) - - if (options.log) { - options._log = Cypress.log({ - timeout: options.timeout, - }) - } - - switch (type) { - case 'model': - return findByNgAttr('model', 'model=', selector, options) - case 'repeater': - return findByNgAttr('repeater', 'repeat*=', selector, options) - case 'binding': - return findByNgBinding(selector, options) - default: - return - } - }, - }) -} diff --git a/packages/driver/src/cy/commands/index.ts b/packages/driver/src/cy/commands/index.ts index d9f6ccd1003b..82199f4c3785 100644 --- a/packages/driver/src/cy/commands/index.ts +++ b/packages/driver/src/cy/commands/index.ts @@ -4,8 +4,6 @@ import * as Agents from './agents' import * as Aliasing from './aliasing' -import * as Angular from './angular' - import * as Asserting from './asserting' import * as Clock from './clock' @@ -58,7 +56,6 @@ export const allCommands = { ...Actions, Agents, Aliasing, - Angular, Asserting, Clock, Commands, diff --git a/packages/driver/src/cy/commands/querying/querying.ts b/packages/driver/src/cy/commands/querying/querying.ts index 1b1df2ca9a55..10a8029b4440 100644 --- a/packages/driver/src/cy/commands/querying/querying.ts +++ b/packages/driver/src/cy/commands/querying/querying.ts @@ -1,10 +1,8 @@ import _ from 'lodash' -import Promise from 'bluebird' import $dom from '../../../dom' import $elements from '../../../dom/elements' import $errUtils from '../../../cypress/error_utils' -import type { Log } from '../../../cypress/log' import $utils from '../../../cypress/utils' import { resolveShadowDomInclusion } from '../../../cypress/shadow_dom_utils' import { getAliasedRequests, isDynamicAliasingPossible } from '../../net-stubbing/aliasing' @@ -148,375 +146,8 @@ function getAlias (selector, log, cy) { } } -interface InternalGetOptions extends Partial { - _log?: Log - _retries?: number - filter?: any - onRetry?: Function - verify?: boolean -} - -interface InternalContainsOptions extends Partial { - _log?: Log -} - export default (Commands, Cypress, cy, state) => { - /* - * cy.get() is currently in a strange state: There are two implementations of it in this file, registered one after - * another. It first is registered as a command (Commands.addAll()) - but below it, we *also* add .get() - * via Commands._overwriteQuery(), which overwrites it. - * - * This is because other commands in the driver rely on the original .get() implementation, via - * `cy.now('get', selector, getOptions)`. - * - * The key is that cy.now() relies on `cy.commandFns[name]` - which addAll() sets, but _overwriteQuery() does not. - * - * The upshot is that any test that relies on `cy.get()` is using the query-based implementation, but various - * driver commands have access to the original implementation of .get() via cy.now(). This is a temporary state - * of affairs while we refactor other commands to also be queries - we'll eventually be able to delete this - * original version of .get() entirely. - */ - Commands.addAll({ - get (selector, userOptions: Partial = {}) { - const ctx = this - - if ((userOptions === null) || _.isArray(userOptions) || !_.isPlainObject(userOptions)) { - return $errUtils.throwErrByPath('get.invalid_options', { - args: { options: userOptions }, - }) - } - - const options: InternalGetOptions = _.defaults({}, userOptions, { - retry: true, - withinSubject: state('withinSubject'), - log: true, - command: null, - verify: true, - }) - - options.includeShadowDom = resolveShadowDomInclusion(Cypress, options.includeShadowDom) - - let aliasObj - const consoleProps: Record = {} - const start = (aliasType) => { - if (options.log === false) { - return - } - - if (options._log == null) { - options._log = Cypress.log({ - message: selector, - referencesAlias: (aliasObj != null && aliasObj.alias) ? { name: aliasObj.alias } : undefined, - aliasType, - timeout: options.timeout, - consoleProps: () => { - return consoleProps - }, - }) - } - } - - const log = (value, aliasType = 'dom') => { - if (options.log === false) { - return - } - - if (!_.isObject(options._log)) { - start(aliasType) - } - - const obj: any = {} - - if (aliasType === 'dom') { - _.extend(obj, { - $el: value, - numRetries: options._retries, - }) - } - - obj.consoleProps = () => { - const key = aliasObj ? 'Alias' : 'Selector' - - consoleProps[key] = selector - - switch (aliasType) { - case 'dom': - _.extend(consoleProps, { - Yielded: $dom.getElements(value), - Elements: (value != null ? value.length : undefined), - }) - - break - case 'primitive': - _.extend(consoleProps, { - Yielded: value, - }) - - break - case 'route': - _.extend(consoleProps, { - Yielded: value, - }) - - break - default: - break - } - - return consoleProps - } - - options._log!.set(obj) - } - - let allParts - let toSelect - - // We want to strip everything after the last '.' - // only when it is potentially a number or 'all' - if ((_.indexOf(selector, '.') === -1) || - (_.keys(state('aliases')).includes(selector.slice(1)))) { - toSelect = selector - } else { - allParts = _.split(selector, '.') - toSelect = _.join(_.dropRight(allParts, 1), '.') - } - - try { - aliasObj = cy.getAlias(toSelect) - } catch (err) { - // possibly this is a dynamic alias, check to see if there is a request - const alias = toSelect.slice(1) - const [request] = getAliasedRequests(alias, state) - - if (!isDynamicAliasingPossible(state) || !request) { - throw err - } - - aliasObj = { - alias, - command: state('routes')[request.routeId].command, - } - } - - if (!aliasObj && isDynamicAliasingPossible(state)) { - const requests = getAliasedRequests(toSelect, state) - - if (requests.length) { - aliasObj = { - alias: toSelect, - command: state('routes')[requests[0].routeId].command, - } - } - } - - if (aliasObj) { - let { alias, command } = aliasObj - let subject = $utils.getSubjectFromChain(aliasObj.subjectChain, cy) - - const resolveAlias = () => { - // if this is a DOM element - if ($dom.isElement(subject)) { - let replayFrom = false - - const replay = () => { - cy.replayCommandsFrom(command) - - // its important to return undefined - // here else we trick cypress into thinking - // we have a promise violation - return undefined - } - - // if we're missing any element - // within our subject then filter out - // anything not currently in the DOM - if ($dom.isDetached(subject)) { - subject = (subject as any).filter((index, el) => $dom.isAttached(el)) - - // if we have nothing left - // just go replay the commands - if (!subject.length) { - return replay() - } - } - - log(subject) - - return cy.verifyUpcomingAssertions(subject, options, { - onFail (err) { - // if we are failing because our aliased elements - // are less than what is expected then we know we - // need to requery for them and can thus replay - // the commands leading up to the alias - if ((err.type === 'length') && (err.actual < err.expected)) { - return replayFrom = true - } - - return false - }, - onRetry () { - if (replayFrom) { - return replay() - } - - return resolveAlias() - }, - }) - } - - // if this is a route command - if (command.get('name') === 'route') { - if (!((_.indexOf(selector, '.') === -1) || - (_.keys(state('aliases')).includes(selector.slice(1)))) - ) { - allParts = _.split(selector, '.') - const index = _.last(allParts) - - alias = _.join([alias, index], '.') - } - - const requests = cy.getRequestsByAlias(alias) || null - - log(requests, 'route') - - return requests - } - - if (command.get('name') === 'intercept') { - const requests = getAliasedRequests(alias, state) - // detect alias.all and alias.index - const specifier = /\.(all|[\d]+)$/.exec(selector) - - if (specifier) { - const [, index] = specifier - - if (index === 'all') { - return requests - } - - return requests[Number(index)] || null - } - - log(requests, command.get('name')) - - // by default return the latest match - return _.last(requests) || null - } - - // log as primitive - log(subject, 'primitive') - - const verifyAssertions = () => { - return cy.verifyUpcomingAssertions(subject, options, { - ensureExistenceFor: false, - onRetry: verifyAssertions, - }) - } - - return verifyAssertions() - } - - return resolveAlias() - } - - start('dom') - - const setEl = ($el) => { - if (options.log === false) { - return - } - - consoleProps.Yielded = $dom.getElements($el) - consoleProps.Elements = $el != null ? $el.length : undefined - - options._log!.set({ $el }) - } - - const getElements = () => { - let $el - - try { - let scope: (typeof options.withinSubject) | Node[] = options.withinSubject - - if (options.includeShadowDom) { - const root = options.withinSubject ? options.withinSubject[0] : cy.state('document') - const elementsWithShadow = $dom.findAllShadowRoots(root) - - scope = elementsWithShadow.concat(root) - } - - $el = cy.$$(selector, scope) - - // jQuery v3 has removed its deprecated properties like ".selector" - // https://jquery.com/upgrade-guide/3.0/breaking-change-deprecated-context-and-selector-properties-removed - // but our error messages use this property to actually show the missing element - // so let's put it back - if ($el.selector == null) { - $el.selector = selector - } - } catch (err: any) { - // this is usually a sizzle error (invalid selector) - err.onFail = () => { - if (options.log === false) { - return err - } - - options._log!.error(err) - } - - throw err - } - - // if that didnt find anything and we have a within subject - // and we have been explictly told to filter - // then just attempt to filter out elements from our within subject - if (!$el.length && options.withinSubject && options.filter) { - const filtered = (options.withinSubject as JQuery).filter(selector) - - // reset $el if this found anything - if (filtered.length) { - $el = filtered - } - } - - // store the $el now in case we fail - setEl($el) - - // allow retry to be a function which we ensure - // returns truthy before returning its - if (_.isFunction(options.onRetry)) { - const ret = options.onRetry.call(ctx, $el) - - if (ret) { - log($el) - - return ret - } - } else { - log($el) - - return $el - } - } - - const resolveElements = () => { - return Promise.try(getElements).then(($el) => { - if (options.verify === false) { - return $el - } - - return cy.verifyUpcomingAssertions($el, options, { - onRetry: resolveElements, - }) - }) - } - - return cy.retryIfCommandAUTOriginMismatch(resolveElements, options.timeout) - }, - }) - - Commands._overwriteQuery('get', function get (selector, userOptions: Partial = {}) { + Commands.addQuery('get', function get (selector, userOptions: Partial = {}) { if ((userOptions === null) || _.isArray(userOptions) || !_.isPlainObject(userOptions)) { $errUtils.throwErrByPath('get.invalid_options', { args: { options: userOptions }, @@ -536,7 +167,6 @@ export default (Commands, Cypress, cy, state) => { return getAlias.call(this, selector, log, cy) } - const withinSubject = cy.state('withinSubject') const includeShadowDom = resolveShadowDomInclusion(Cypress, userOptions.includeShadowDom) return () => { @@ -545,10 +175,10 @@ export default (Commands, Cypress, cy, state) => { let $el try { - let scope: (typeof withinSubject) | Node[] = withinSubject + let scope = cy.state('withinSubject') || userOptions.withinSubject if (includeShadowDom) { - const root = withinSubject ? withinSubject[0] : cy.state('document') + const root = scope ? scope[0] : cy.state('document') const elementsWithShadow = $dom.findAllShadowRoots(root) scope = elementsWithShadow.concat(root) @@ -591,216 +221,167 @@ export default (Commands, Cypress, cy, state) => { } }) - Commands.addAll({ prevSubject: ['optional', 'window', 'document', 'element'] }, { - contains (subject, filter, text, userOptions: Partial = {}) { - // nuke our subject if its present but not an element. - // in these cases its either window or document but - // we dont care. - // we'll null out the subject so it will show up as a parent - // command since its behavior is identical to using it - // as a parent command: cy.contains() - // don't nuke if subject is a shadow root, is a document not an element - if (subject && !$dom.isElement(subject) && !$elements.isShadowRoot(subject[0])) { - subject = null - } - - if (_.isRegExp(text)) { - // .contains(filter, text) - // Do nothing - } else if (_.isObject(text)) { - // .contains(text, userOptions) - userOptions = text - text = filter - filter = '' - } else if (_.isUndefined(text)) { - // .contains(text) - text = filter - filter = '' - } + Commands.addQuery('contains', function contains (filter, text, userOptions: Partial = {}) { + if (_.isRegExp(text)) { + // .contains(filter, text) + // Do nothing + } else if (_.isObject(text)) { + // .contains(text, userOptions) + userOptions = text + text = filter + filter = '' + } else if (_.isUndefined(text)) { + // .contains(text) + text = filter + filter = '' + } - // https://github.com/cypress-io/cypress/issues/1119 - if (text === 0) { - // text can be 0 but should not be falsy - text = '0' - } + // https://github.com/cypress-io/cypress/issues/1119 + if (text === 0) { + // text can be 0 but should not be falsy + text = '0' + } - if (userOptions.matchCase === true && _.isRegExp(text) && text.flags.includes('i')) { - $errUtils.throwErrByPath('contains.regex_conflict') - } + if (userOptions.matchCase === true && _.isRegExp(text) && text.flags.includes('i')) { + $errUtils.throwErrByPath('contains.regex_conflict') + } - const options: InternalContainsOptions = _.defaults({}, userOptions, { log: true, matchCase: true }) + if (!(_.isString(text) || _.isFinite(text) || _.isRegExp(text))) { + $errUtils.throwErrByPath('contains.invalid_argument') + } - if (!(_.isString(text) || _.isFinite(text) || _.isRegExp(text))) { - $errUtils.throwErrByPath('contains.invalid_argument') - } + if (_.isBlank(text)) { + $errUtils.throwErrByPath('contains.empty_string') + } - if (_.isBlank(text)) { - $errUtils.throwErrByPath('contains.empty_string') - } + // find elements by the :cy-contains psuedo selector + // and any submit inputs with the attributeContainsWord selector + const selector = $dom.getContainsSelector(text, filter, userOptions) - const getPhrase = () => { - if (filter && subject) { - const node = $dom.stringify(subject, 'short') + const getOptions = _.extend({}, userOptions) + const getFn = cy.now('get', selector, getOptions) + const log = cy.state('current').get('_log') - return `within the element: ${node} and with the selector: '${filter}' ` - } + const getPhrase = () => { + if (filter && !getOptions.withinSubject.is('body')) { + const node = $dom.stringify(getOptions.withinSubject, 'short') - if (filter) { - return `within the selector: '${filter}' ` - } + return `within the element: ${node} and with the selector: '${filter}' ` + } - if (subject) { - const node = $dom.stringify(subject, 'short') + if (filter) { + return `within the selector: '${filter}' ` + } - return `within the element: ${node} ` - } + if (!getOptions.withinSubject.is('body')) { + const node = $dom.stringify(getOptions.withinSubject, 'short') - return '' + return `within the element: ${node} ` } - const getErr = (err) => { - const { type, negated } = err + return '' + } - if (type === 'existence') { - if (negated) { - return `Expected not to find content: '${text}' ${getPhrase()}but continuously found it.` + cy.state('current').set('timeout', userOptions.timeout) + cy.state('current').set('onFail', (err) => { + switch (err.type) { + case 'length': + if (err.expected > 1) { + const { message, docsUrl } = $errUtils.cypressErrByPath('shadow.no_shadow_root') + + err.message = message + err.docsUrl = docsUrl + err.retry = false } - return `Expected to find content: '${text}' ${getPhrase()}but never did.` - } + break + case 'existence': + if (err.negated) { + err.message = `Expected not to find content: '${text}' ${getPhrase()}but continuously found it.` + } else { + err.message = `Expected to find content: '${text}' ${getPhrase()}but never did.` + } - return null + break + default: + break } + }) - let consoleProps + return (subject) => { + cy.ensureSubjectByType(subject, ['optional', 'window', 'document', 'element']) - if (options.log !== false) { - consoleProps = { - Content: text, - 'Applied To': $dom.getElements(subject || state('withinSubject')), - } - - options._log = Cypress.log({ - message: _.compact([filter, text]), - type: subject ? 'child' : 'parent', - timeout: options.timeout, - consoleProps: () => { - return consoleProps - }, - }) + if (!subject || (!$dom.isElement(subject) && !$elements.isShadowRoot(subject[0]))) { + subject = cy.$$('body') } - const setEl = ($el) => { - if (options.log === false) { - return - } + getOptions.withinSubject = subject + let $el = getFn() - consoleProps.Yielded = $dom.getElements($el) - consoleProps.Elements = $el != null ? $el.length : undefined + // .get() looks for elements *inside* the current subject, while contains() wants to also match the current + // subject itself if no child matches. + if (!$el.length) { + $el = (subject as JQuery).filter(selector) + } - options._log!.set({ $el }) + if ($el.length) { + $el = $dom.getFirstDeepestElement($el) } - // find elements by the :cy-contains psuedo selector - // and any submit inputs with the attributeContainsWord selector - const selector = $dom.getContainsSelector(text, filter, options) - - const resolveElements = () => { - const getOptions = _.extend({}, options, { - // error: getErr(text, phrase) - withinSubject: subject || state('withinSubject') || cy.$$('body'), - filter: true, - log: false, - // retry: false ## dont retry because we perform our own element validation - verify: false, // dont verify upcoming assertions, we do that ourselves - }) - - return cy.now('get', selector, getOptions).then(($el) => { - if ($el && $el.length) { - $el = $dom.getFirstDeepestElement($el) + log && log.set({ + message: $utils.stringify(_.compact([filter, text])), + $el, + type: subject.is('body') ? 'parent' : 'child', + consoleProps: () => { + return { + Content: text, + 'Applied To': $dom.getElements(subject), + Yielded: $el.get(0), + Elements: $el.length, } + }, + }) - setEl($el) - - return cy.verifyUpcomingAssertions($el, options, { - onRetry: resolveElements, - onFail (err) { - switch (err.type) { - case 'length': - if (err.expected > 1) { - return $errUtils.throwErrByPath('contains.length_option', { onFail: options._log }) - } - - break - case 'existence': - return err.message = getErr(err) - default: - break - } - - return null - }, - }) - }) - } - - return Promise - .try(resolveElements) - }, + return $el + } }) - Commands.add('shadow', { prevSubject: 'element' }, (subject, options) => { - const userOptions = options || {} - - options = _.defaults({}, userOptions, { log: true }) - - const consoleProps: Record = { - 'Applied To': $dom.getElements(subject), - } + Commands.addQuery('shadow', function contains (userOptions: Partial = {}) { + const log = userOptions.log !== false && Cypress.log({ + timeout: userOptions.timeout, + consoleProps: () => ({}), + }) - if (options.log !== false) { - options._log = Cypress.log({ - timeout: options.timeout, - consoleProps () { - return consoleProps - }, - }) - } + cy.state('current').set('timeout', userOptions.timeout) + cy.state('current').set('onFail', (err) => { + if (err.type === 'existence') { + const { message, docsUrl } = $errUtils.cypressErrByPath('shadow.no_shadow_root') - const setEl = ($el) => { - if (options.log === false) { - return + err.message = message + err.docsUrl = docsUrl } + }) - consoleProps.Yielded = $dom.getElements($el) - consoleProps.Elements = $el?.length - - return options._log.set({ $el }) - } + return (subject) => { + cy.ensureSubjectByType(subject, 'element') - const getShadowRoots = () => { // find all shadow roots of the subject(s), if any exist const $el = subject .map((i, node) => node.shadowRoot) .filter((i, node) => node !== undefined && node !== null) - setEl($el) - - return cy.verifyUpcomingAssertions($el, options, { - onRetry: getShadowRoots, - onFail (err) { - if (err.type !== 'existence') { - return + log && log.set({ + $el, + consoleProps: () => { + return { + 'Applied To': $dom.getElements(subject), + Yielded: $dom.getElements($el), + Elements: $el?.length, } - - const { message, docsUrl } = $errUtils.cypressErrByPath('shadow.no_shadow_root') - - err.message = message - err.docsUrl = docsUrl }, }) - } - return getShadowRoots() + return $el + } }) } diff --git a/packages/driver/src/cy/retries.ts b/packages/driver/src/cy/retries.ts index 088b0b2766c3..74c0023144b7 100644 --- a/packages/driver/src/cy/retries.ts +++ b/packages/driver/src/cy/retries.ts @@ -76,7 +76,7 @@ export const create = (Cypress: ICypress, state: StateFunc, timeout: $Cy['timeou ({ error, onFail } = options) - const prependMsg = errByPath('miscellaneous.retry_timed_out', { + const prependMsg = error.retry === false ? '' : errByPath('miscellaneous.retry_timed_out', { ms: options._runnableTimeout, }).message diff --git a/packages/driver/src/cypress/command_queue.ts b/packages/driver/src/cypress/command_queue.ts index fa3693091af4..983b2e176505 100644 --- a/packages/driver/src/cypress/command_queue.ts +++ b/packages/driver/src/cypress/command_queue.ts @@ -95,8 +95,9 @@ function retryQuery (command: $Command, ret: any, cy: $Cy) { cy.ensureSubjectByType(subject, command.get('prevSubject')) - return ret(subject) - }, + return cy.verifyUpcomingAssertions(subject, options, { + onRetry, + onFail: command.get('onFail'), }) } diff --git a/packages/driver/src/cypress/cy.ts b/packages/driver/src/cypress/cy.ts index c6990885c033..caf025584994 100644 --- a/packages/driver/src/cypress/cy.ts +++ b/packages/driver/src/cypress/cy.ts @@ -220,6 +220,7 @@ export class $Cy extends EventEmitter2 implements ITimeouts, IStability, IAssert private testConfigOverride: TestConfigOverride private commandFns: Record = {} + private queryFns: Record = {} constructor (specWindow: SpecWindow, Cypress: ICypress, Cookies: ICookies, state: StateFunc, config: ICypress['config']) { super() @@ -832,6 +833,8 @@ export class $Cy extends EventEmitter2 implements ITimeouts, IStability, IAssert _addQuery ({ name, fn }) { const cy = this + this.queryFns[name] = fn + const callback = (chainer, userInvocationStack, args) => { // dont enqueue / inject any new commands if // onInjectCommand returns false @@ -896,6 +899,10 @@ export class $Cy extends EventEmitter2 implements ITimeouts, IStability, IAssert } now (name, ...args) { + if (this.queryFns[name]) { + return this.queryFns[name].apply(this, args) + } + return Promise.resolve( this.commandFns[name].apply(this, args), ) diff --git a/packages/driver/src/cypress/error_messages.ts b/packages/driver/src/cypress/error_messages.ts index 55ec54819605..f8c521e6625f 100644 --- a/packages/driver/src/cypress/error_messages.ts +++ b/packages/driver/src/cypress/error_messages.ts @@ -298,7 +298,7 @@ export default { docsUrl: 'https://on.cypress.io/contains', }, length_option: { - message: `${cmd('contains')} cannot be passed a \`length\` option because it will only ever return 1 element.`, + message: `${cmd('contains')} only ever returns one element, so you cannot assert on a \`length\` greater than one.`, docsUrl: 'https://on.cypress.io/contains', }, regex_conflict: { From ed9765b28df1521cf692e62441700adf04ccedf2 Mon Sep 17 00:00:00 2001 From: BlueWinds Date: Mon, 12 Sep 2022 16:06:44 -0700 Subject: [PATCH 02/54] Fix TS and a couple of tests --- .../src/cy/commands/querying/querying.ts | 28 ++++++++----------- packages/driver/src/cypress/commands.ts | 6 +++- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/packages/driver/src/cy/commands/querying/querying.ts b/packages/driver/src/cy/commands/querying/querying.ts index 10a8029b4440..be2285df1c98 100644 --- a/packages/driver/src/cy/commands/querying/querying.ts +++ b/packages/driver/src/cy/commands/querying/querying.ts @@ -8,6 +8,10 @@ import { resolveShadowDomInclusion } from '../../../cypress/shadow_dom_utils' import { getAliasedRequests, isDynamicAliasingPossible } from '../../net-stubbing/aliasing' import { aliasRe, aliasIndexRe } from '../../aliases' +type GetOptions = Partial +type ContainsOptions = Partial +type ShadowOptions = Partial + function getAlias (selector, log, cy) { const alias = selector.slice(1) @@ -147,7 +151,7 @@ function getAlias (selector, log, cy) { } export default (Commands, Cypress, cy, state) => { - Commands.addQuery('get', function get (selector, userOptions: Partial = {}) { + Commands.addQuery('get', function get (selector, userOptions: GetOptions = {}) { if ((userOptions === null) || _.isArray(userOptions) || !_.isPlainObject(userOptions)) { $errUtils.throwErrByPath('get.invalid_options', { args: { options: userOptions }, @@ -221,7 +225,7 @@ export default (Commands, Cypress, cy, state) => { } }) - Commands.addQuery('contains', function contains (filter, text, userOptions: Partial = {}) { + Commands.addQuery('contains', function contains (filter, text, userOptions: ContainsOptions = {}) { if (_.isRegExp(text)) { // .contains(filter, text) // Do nothing @@ -258,12 +262,12 @@ export default (Commands, Cypress, cy, state) => { // and any submit inputs with the attributeContainsWord selector const selector = $dom.getContainsSelector(text, filter, userOptions) - const getOptions = _.extend({}, userOptions) + const getOptions = _.extend({}, userOptions) as GetOptions const getFn = cy.now('get', selector, getOptions) const log = cy.state('current').get('_log') const getPhrase = () => { - if (filter && !getOptions.withinSubject.is('body')) { + if (filter && !(getOptions.withinSubject as JQuery).is('body')) { const node = $dom.stringify(getOptions.withinSubject, 'short') return `within the element: ${node} and with the selector: '${filter}' ` @@ -273,7 +277,7 @@ export default (Commands, Cypress, cy, state) => { return `within the selector: '${filter}' ` } - if (!getOptions.withinSubject.is('body')) { + if (!(getOptions.withinSubject as JQuery).is('body')) { const node = $dom.stringify(getOptions.withinSubject, 'short') return `within the element: ${node} ` @@ -287,7 +291,7 @@ export default (Commands, Cypress, cy, state) => { switch (err.type) { case 'length': if (err.expected > 1) { - const { message, docsUrl } = $errUtils.cypressErrByPath('shadow.no_shadow_root') + const { message, docsUrl } = $errUtils.cypressErrByPath('contains.length_option') err.message = message err.docsUrl = docsUrl @@ -315,7 +319,7 @@ export default (Commands, Cypress, cy, state) => { subject = cy.$$('body') } - getOptions.withinSubject = subject + getOptions.withinSubject = subject[0] ?? subject let $el = getFn() // .get() looks for elements *inside* the current subject, while contains() wants to also match the current @@ -346,21 +350,13 @@ export default (Commands, Cypress, cy, state) => { } }) - Commands.addQuery('shadow', function contains (userOptions: Partial = {}) { + Commands.addQuery('shadow', function contains (userOptions: ShadowOptions = {}) { const log = userOptions.log !== false && Cypress.log({ timeout: userOptions.timeout, consoleProps: () => ({}), }) cy.state('current').set('timeout', userOptions.timeout) - cy.state('current').set('onFail', (err) => { - if (err.type === 'existence') { - const { message, docsUrl } = $errUtils.cypressErrByPath('shadow.no_shadow_root') - - err.message = message - err.docsUrl = docsUrl - } - }) return (subject) => { cy.ensureSubjectByType(subject, 'element') diff --git a/packages/driver/src/cypress/commands.ts b/packages/driver/src/cypress/commands.ts index 74d62e1bfa50..f04943039c92 100644 --- a/packages/driver/src/cypress/commands.ts +++ b/packages/driver/src/cypress/commands.ts @@ -161,7 +161,11 @@ export default { internalError('miscellaneous.invalid_new_query', name) } - cy._addQuery({ name, fn }) + if (addingBuiltIns) { + builtInCommandNames[name] = true + } + + cy.addQuery({ name, fn }) }, _overwriteQuery (name, fn) { From 6e408b3660865bdf0c57b8a84cac8ac818d02fd0 Mon Sep 17 00:00:00 2001 From: BlueWinds Date: Tue, 27 Sep 2022 14:24:14 -0700 Subject: [PATCH 03/54] Fix tests and TS --- .../src/cy/commands/querying/querying.ts | 31 ++++++++++++++----- .../driver/src/cy/commands/querying/root.ts | 2 +- packages/driver/src/cy/retries.ts | 6 ++-- packages/driver/src/cypress/cy.ts | 2 +- 4 files changed, 29 insertions(+), 12 deletions(-) diff --git a/packages/driver/src/cy/commands/querying/querying.ts b/packages/driver/src/cy/commands/querying/querying.ts index be2285df1c98..75df7fe84d58 100644 --- a/packages/driver/src/cy/commands/querying/querying.ts +++ b/packages/driver/src/cy/commands/querying/querying.ts @@ -164,8 +164,8 @@ export default (Commands, Cypress, cy, state) => { consoleProps: () => ({}), }) - cy.state('current').set('timeout', userOptions.timeout) - cy.state('current').set('_log', log) + this.set('timeout', userOptions.timeout) + this.set('_log', log) if (aliasRe.test(selector)) { return getAlias.call(this, selector, log, cy) @@ -181,8 +181,12 @@ export default (Commands, Cypress, cy, state) => { try { let scope = cy.state('withinSubject') || userOptions.withinSubject + if (scope && scope[0]) { + scope = scope[0] + } + if (includeShadowDom) { - const root = scope ? scope[0] : cy.state('document') + const root = scope || cy.state('document') const elementsWithShadow = $dom.findAllShadowRoots(root) scope = elementsWithShadow.concat(root) @@ -264,7 +268,7 @@ export default (Commands, Cypress, cy, state) => { const getOptions = _.extend({}, userOptions) as GetOptions const getFn = cy.now('get', selector, getOptions) - const log = cy.state('current').get('_log') + const log = this.get('_log') const getPhrase = () => { if (filter && !(getOptions.withinSubject as JQuery).is('body')) { @@ -286,8 +290,8 @@ export default (Commands, Cypress, cy, state) => { return '' } - cy.state('current').set('timeout', userOptions.timeout) - cy.state('current').set('onFail', (err) => { + this.set('timeout', userOptions.timeout) + this.set('onFail', (err) => { switch (err.type) { case 'length': if (err.expected > 1) { @@ -356,7 +360,20 @@ export default (Commands, Cypress, cy, state) => { consoleProps: () => ({}), }) - cy.state('current').set('timeout', userOptions.timeout) + this.set('timeout', userOptions.timeout) + this.set('onFail', (err) => { + switch (err.type) { + case 'existence': { + const { message, docsUrl } = $errUtils.cypressErrByPath('shadow.no_shadow_root') + + err.message = message + err.docsUrl = docsUrl + break + } + default: + break + } + }) return (subject) => { cy.ensureSubjectByType(subject, 'element') diff --git a/packages/driver/src/cy/commands/querying/root.ts b/packages/driver/src/cy/commands/querying/root.ts index 2498f3f1a439..4df2f0dda571 100644 --- a/packages/driver/src/cy/commands/querying/root.ts +++ b/packages/driver/src/cy/commands/querying/root.ts @@ -4,7 +4,7 @@ export default (Commands, Cypress, cy, state) => { timeout: options.timeout, }) - cy.state('current').set('timeout', options.timeout) + this.set('timeout', options.timeout) return () => { cy.ensureCommandCanCommunicateWithAUT() diff --git a/packages/driver/src/cy/retries.ts b/packages/driver/src/cy/retries.ts index 74c0023144b7..fa1bf8a0023b 100644 --- a/packages/driver/src/cy/retries.ts +++ b/packages/driver/src/cy/retries.ts @@ -1,7 +1,7 @@ import _ from 'lodash' import Promise from 'bluebird' -import $errUtils from '../cypress/error_utils' +import $errUtils, { CypressError } from '../cypress/error_utils' import type { ICypress } from '../cypress' import type { $Cy } from '../cypress/cy' import type { StateFunc } from '../cypress/state' @@ -16,7 +16,7 @@ type retryOptions = { _runnable?: any _runnableTimeout?: number _start?: Date - error?: Error + error?: CypressError interval: number log: boolean onFail?: Function @@ -76,7 +76,7 @@ export const create = (Cypress: ICypress, state: StateFunc, timeout: $Cy['timeou ({ error, onFail } = options) - const prependMsg = error.retry === false ? '' : errByPath('miscellaneous.retry_timed_out', { + const prependMsg = error?.retry === false ? '' : errByPath('miscellaneous.retry_timed_out', { ms: options._runnableTimeout, }).message diff --git a/packages/driver/src/cypress/cy.ts b/packages/driver/src/cypress/cy.ts index caf025584994..9715f40db033 100644 --- a/packages/driver/src/cypress/cy.ts +++ b/packages/driver/src/cypress/cy.ts @@ -900,7 +900,7 @@ export class $Cy extends EventEmitter2 implements ITimeouts, IStability, IAssert now (name, ...args) { if (this.queryFns[name]) { - return this.queryFns[name].apply(this, args) + return this.queryFns[name].apply(this.state('current'), args) } return Promise.resolve( From c27dae2af2c574ba98501de24ca9c26898c22de3 Mon Sep 17 00:00:00 2001 From: BlueWinds Date: Thu, 29 Sep 2022 10:17:27 -0700 Subject: [PATCH 04/54] Fix case-sensitivity for .contains() --- .../driver/cypress/e2e/commands/querying/querying.cy.js | 1 + packages/driver/src/cy/commands/querying/querying.ts | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/driver/cypress/e2e/commands/querying/querying.cy.js b/packages/driver/cypress/e2e/commands/querying/querying.cy.js index 2c2bc5267b6f..eb95ea619ad8 100644 --- a/packages/driver/cypress/e2e/commands/querying/querying.cy.js +++ b/packages/driver/cypress/e2e/commands/querying/querying.cy.js @@ -1293,6 +1293,7 @@ space it('is case sensitive when matchCase is undefined', () => { cy.get('#test-button').contains('Test') + cy.contains('test').should('not.exist') }) it('is case sensitive when matchCase is true', () => { diff --git a/packages/driver/src/cy/commands/querying/querying.ts b/packages/driver/src/cy/commands/querying/querying.ts index 75df7fe84d58..9350caf24874 100644 --- a/packages/driver/src/cy/commands/querying/querying.ts +++ b/packages/driver/src/cy/commands/querying/querying.ts @@ -262,11 +262,11 @@ export default (Commands, Cypress, cy, state) => { $errUtils.throwErrByPath('contains.empty_string') } + const getOptions = _.extend({ matchCase: true }, userOptions) as GetOptions + // find elements by the :cy-contains psuedo selector // and any submit inputs with the attributeContainsWord selector - const selector = $dom.getContainsSelector(text, filter, userOptions) - - const getOptions = _.extend({}, userOptions) as GetOptions + const selector = $dom.getContainsSelector(text, filter, getOptions) const getFn = cy.now('get', selector, getOptions) const log = this.get('_log') From 4bfdec87d3d31c4e4ca550a7d528463e0dc48c90 Mon Sep 17 00:00:00 2001 From: BlueWinds Date: Thu, 29 Sep 2022 10:34:53 -0700 Subject: [PATCH 05/54] Stop TS complaining --- packages/driver/src/cy/commands/querying/querying.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/driver/src/cy/commands/querying/querying.ts b/packages/driver/src/cy/commands/querying/querying.ts index 9350caf24874..daffcef10d9a 100644 --- a/packages/driver/src/cy/commands/querying/querying.ts +++ b/packages/driver/src/cy/commands/querying/querying.ts @@ -262,11 +262,11 @@ export default (Commands, Cypress, cy, state) => { $errUtils.throwErrByPath('contains.empty_string') } - const getOptions = _.extend({ matchCase: true }, userOptions) as GetOptions - // find elements by the :cy-contains psuedo selector // and any submit inputs with the attributeContainsWord selector - const selector = $dom.getContainsSelector(text, filter, getOptions) + const selector = $dom.getContainsSelector(text, filter, { matchCase: true, ...userOptions }) + + const getOptions = _.extend({}, userOptions) as GetOptions const getFn = cy.now('get', selector, getOptions) const log = this.get('_log') From a881c11e79cc6c0eefeca47a02d9c72949db387b Mon Sep 17 00:00:00 2001 From: BlueWinds Date: Thu, 29 Sep 2022 15:31:08 -0700 Subject: [PATCH 06/54] Rework cy-contains jquery expression --- .../e2e/commands/querying/querying.cy.js | 62 ++---------- .../e2e/e2e/origin/commands/misc.cy.ts | 6 +- packages/driver/src/config/jquery.ts | 2 +- packages/driver/src/dom/elements/find.ts | 94 +++++++++++-------- 4 files changed, 66 insertions(+), 98 deletions(-) diff --git a/packages/driver/cypress/e2e/commands/querying/querying.cy.js b/packages/driver/cypress/e2e/commands/querying/querying.cy.js index eb95ea619ad8..be343a986fb4 100644 --- a/packages/driver/cypress/e2e/commands/querying/querying.cy.js +++ b/packages/driver/cypress/e2e/commands/querying/querying.cy.js @@ -919,6 +919,12 @@ describe('src/cy/commands/querying', () => { }) context('#contains', () => { + it('should keep multiple contains() separate', () => { + cy.contains('New York').as('alias') + cy.contains('Nested Find').invoke('remove') + cy.get('@alias').should('exist') + }) + it('is scoped to the body and will not return title elements', () => { cy.contains('DOM Fixture').then(($el) => { expect($el).not.to.match('title') @@ -1103,23 +1109,9 @@ describe('src/cy/commands/querying', () => { }) }) - it('finds text by regexp and restores contains', () => { - const { contains } = Cypress.$Cypress.$.expr[':'] - - cy.contains(/^asdf \d+/).then(($li) => { - expect($li).to.have.text('asdf 1') - - expect(Cypress.$Cypress.$.expr[':'].contains).to.eq(contains) - }) - }) - - it('finds text by regexp when second parameter is a regexp and restores contains', () => { - const { contains } = Cypress.$Cypress.$.expr[':'] - + it('finds text by regexp when second parameter is a regexp', () => { cy.contains('#asdf>li:first', /asdf 1/).then(($li) => { expect($li).to.have.text('asdf 1') - - expect(Cypress.$Cypress.$.expr[':'].contains).to.eq(contains) }) }) @@ -1753,46 +1745,6 @@ space cy.contains('Nested Find').should('have.length', 2) }) - - it('restores contains even when cy.get fails', (done) => { - const { contains } = Cypress.$Cypress.$.expr[':'] - - const cyNow = cy.now - - cy.on('fail', (err) => { - expect(err.message).to.include('Syntax error, unrecognized expression') - expect(Cypress.$Cypress.$.expr[':'].contains).to.eq(contains) - - done() - }) - - cy.stub(cy, 'now').callsFake(() => cyNow('get', 'aBad:jQuery^Selector', {})) - - cy.contains(/^asdf \d+/) - }) - - it('restores contains on abort', (done) => { - cy.timeout(1000) - - const { contains } = Cypress.$Cypress.$.expr[':'] - - cy.stub(Cypress.runner, 'stop') - - cy.on('stop', () => { - _.delay(() => { - expect(Cypress.$Cypress.$.expr[':'].contains).to.eq(contains) - - done() - } - , 50) - }) - - cy.on('command:retry', _.after(2, () => { - Cypress.stop() - })) - - cy.contains(/^does not contain asdfasdf at all$/) - }) }) }) }) diff --git a/packages/driver/cypress/e2e/e2e/origin/commands/misc.cy.ts b/packages/driver/cypress/e2e/e2e/origin/commands/misc.cy.ts index b3b2a9be836e..2a452806c74b 100644 --- a/packages/driver/cypress/e2e/e2e/origin/commands/misc.cy.ts +++ b/packages/driver/cypress/e2e/e2e/origin/commands/misc.cy.ts @@ -198,16 +198,16 @@ it('verifies number of cy commands', () => { // remove custom commands we added for our own testing const customCommands = ['getAll', 'shouldWithTimeout', 'originLoadUtils'] // @ts-ignore - const actualCommands = Cypress._.reject(Object.keys(cy.commandFns), (command) => customCommands.includes(command)) + const actualCommands = Cypress._.pullAll([...Object.keys(cy.commandFns), ...Object.keys(cy.queryFns)], customCommands) const expectedCommands = [ 'check', 'uncheck', 'click', 'dblclick', 'rightclick', 'focus', 'blur', 'hover', 'scrollIntoView', 'scrollTo', 'select', - 'selectFile', 'submit', 'type', 'clear', 'trigger', 'ng', 'should', 'and', 'clock', 'tick', 'spread', 'each', 'then', + 'selectFile', 'submit', 'type', 'clear', 'trigger', 'should', 'and', 'clock', 'tick', 'spread', 'each', 'then', 'invoke', 'its', 'getCookie', 'getCookies', 'setCookie', 'clearCookie', 'clearCookies', 'pause', 'debug', 'exec', 'readFile', 'writeFile', 'fixture', 'clearLocalStorage', 'url', 'hash', 'location', 'end', 'noop', 'log', 'wrap', 'reload', 'go', 'visit', 'focused', 'get', 'contains', 'shadow', 'within', 'request', 'session', 'screenshot', 'task', 'find', 'filter', 'not', 'children', 'eq', 'closest', 'first', 'last', 'next', 'nextAll', 'nextUntil', 'parent', 'parents', 'parentsUntil', 'prev', 'prevAll', 'prevUntil', 'siblings', 'wait', 'title', 'window', 'document', 'viewport', 'server', 'route', 'intercept', 'origin', - 'mount', + 'mount', 'as', 'root', ] const addedCommands = Cypress._.difference(actualCommands, expectedCommands) const removedCommands = Cypress._.difference(expectedCommands, actualCommands) diff --git a/packages/driver/src/config/jquery.ts b/packages/driver/src/config/jquery.ts index 11a35be65660..4f28675c3ce9 100644 --- a/packages/driver/src/config/jquery.ts +++ b/packages/driver/src/config/jquery.ts @@ -55,7 +55,7 @@ $.find.matchesSelector = function (elem, expr) { // When regex starts with =, it is a syntax error when nothing found. // Because Sizzle internally escapes = to handle attribute selectors. // @see https://github.com/jquery/sizzle/blob/20390f05731af380833b5aa805db97de0b91268a/external/jquery/jquery.js#L4363-L4370 - if (e.message.includes(`Syntax error, unrecognized expression: :cy-contains('`)) { + if (e.message.includes(`Syntax error, unrecognized expression: :cy-contains`)) { return false } diff --git a/packages/driver/src/dom/elements/find.ts b/packages/driver/src/dom/elements/find.ts index 61c7edd47289..2055a887cf88 100644 --- a/packages/driver/src/dom/elements/find.ts +++ b/packages/driver/src/dom/elements/find.ts @@ -5,7 +5,6 @@ import $jquery from '../jquery' import { getTagName } from './elementHelpers' import { isWithinShadowRoot, getShadowElementFromPoint } from './shadow' import { normalizeWhitespaces } from './utils' -import { escapeQuotes, escapeBackslashes } from '../../util/escape' /** * Find Parents relative to an initial element @@ -224,55 +223,72 @@ export const getElements = ($el) => { return els } -export const getContainsSelector = (text, filter = '', options: { - matchCase?: boolean -} = {}) => { - const $expr = $.expr[':'] +// We add three custom expressions to jquery. These let us use custom +// logic around case sensitivity, and match strings or regular expressions. - const escapedText = escapeQuotes( - escapeBackslashes(text), - ) +// They are used exclusively by cy.contains; see `getContainsSelector` below, +// for where we build the selectors. - // they may have written the filter as - // comma separated dom els, so we want to search all - // https://github.com/cypress-io/cypress/issues/2407 - const filters = filter.trim().split(',') +// Example: +// button:cy-contains("Login") +$.expr[':']['cy-contains'] = $.expr.createPseudo((text) => { + text = JSON.parse(`"${ text }"`) - let cyContainsSelector + return function (elem) { + let testText = normalizeWhitespaces(elem) - if (_.isRegExp(text)) { - if (options.matchCase === false && !text.flags.includes('i')) { - text = new RegExp(text.source, text.flags + 'i') // eslint-disable-line prefer-template - } + return testText.includes(text) + } +}) - // taken from jquery's normal contains method - cyContainsSelector = function (elem) { - if (elem.type === 'submit' && elem.tagName === 'INPUT') { - return text.test(elem.value) - } +// Example: +// .login-button:cy-contains-insensitive("login") +$.expr[':']['cy-contains-insensitive'] = $.expr.createPseudo((text) => { + text = JSON.parse(`"${ text }"`) - const testText = normalizeWhitespaces(elem) + return function (elem) { + let testText = normalizeWhitespaces(elem) - return text.test(testText) + testText = testText.toLowerCase() + text = text.toLowerCase() + + return testText.includes(text) + } +}) + +// Example: +// #login>li:first:cy-contains-regex('/asdf 1/i') +$.expr[':']['cy-contains-regex'] = $.expr.createPseudo((text) => { + const lastSlash = text.lastIndexOf('/') + const regex = new RegExp(text.slice(1, lastSlash), text.slice(lastSlash + 1)) + + // taken from jquery's normal contains method + return function (elem) { + if (elem.type === 'submit' && elem.tagName === 'INPUT') { + return regex.test(elem.value) } - } else if (_.isString(text)) { - cyContainsSelector = function (elem) { - let testText = normalizeWhitespaces(elem) - if (!options.matchCase) { - testText = testText.toLowerCase() - text = text.toLowerCase() - } + const testText = normalizeWhitespaces(elem) - return testText.includes(text) - } - } else { - cyContainsSelector = $expr.contains + return regex.test(testText) + } +}) + +export const getContainsSelector = (text, filter = '', options: { + matchCase?: boolean +} = {}) => { + if (_.isRegExp(text) && options.matchCase === false && !text.flags.includes('i')) { + text = new RegExp(text.source, text.flags + 'i') // eslint-disable-line prefer-template } - // we set the `cy-contains` jquery selector which will only be used - // in the context of cy.contains(...) command and selector playground. - $expr['cy-contains'] = cyContainsSelector + const escapedText = _.isString(text) ? JSON.stringify(text).slice(1, -1) : text.toString() + + // they may have written the filter as + // comma separated dom els, so we want to search all + // https://github.com/cypress-io/cypress/issues/2407 + const filters = filter.trim().split(',') + + let expr = _.isRegExp(text) ? 'cy-contains-regex' : (options.matchCase ? 'cy-contains' : 'cy-contains-insensitive') const selectors = _.map(filters, (filter) => { // https://github.com/cypress-io/cypress/issues/8626 @@ -281,7 +297,7 @@ export const getContainsSelector = (text, filter = '', options: { const textToFind = escapedText.includes(`\'`) ? `"${escapedText}"` : `'${escapedText}'` // use custom cy-contains selector that is registered above - return `${filter}:cy-contains(${textToFind}), ${filter}[type='submit'][value~=${textToFind}]` + return `${filter}:${expr}(${textToFind}), ${filter}[type='submit'][value~=${textToFind}]` }) return selectors.join() From 4dc9134df0f5fd34b55b8dbedafac6af6b31e6b2 Mon Sep 17 00:00:00 2001 From: BlueWinds Date: Thu, 29 Sep 2022 15:52:48 -0700 Subject: [PATCH 07/54] Add comments, make ts happy --- packages/driver/src/dom/elements/find.ts | 31 +++++++++++++++++++----- 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/packages/driver/src/dom/elements/find.ts b/packages/driver/src/dom/elements/find.ts index 2055a887cf88..61119bae0e32 100644 --- a/packages/driver/src/dom/elements/find.ts +++ b/packages/driver/src/dom/elements/find.ts @@ -223,11 +223,26 @@ export const getElements = ($el) => { return els } -// We add three custom expressions to jquery. These let us use custom -// logic around case sensitivity, and match strings or regular expressions. - -// They are used exclusively by cy.contains; see `getContainsSelector` below, -// for where we build the selectors. +/* + * We add three custom expressions to jquery. These let us use custom + * logic around case sensitivity, and match strings or regular expressions. + * See https://github.com/jquery/sizzle/wiki/#-pseudo-selectors for + * documentation on adding Sizzle selectors. + * + * Our use of + * + * $.expr[':']['cy-contains'] = $.expr.createPseudo() + * + * is equivelent to + * + * Sizzle.selectors.pseudos['cy-contains'] = Sizzle.selectors.createPseudo() + * + * in the documentation linked above. $.expr[':'] is jquery's alias for + * Sizzle.selectors.psuedos. + * + * These custom expressions are used exclusively by cy.contains; see + * `getContainsSelector` below. + */ // Example: // button:cy-contains("Login") @@ -256,6 +271,10 @@ $.expr[':']['cy-contains-insensitive'] = $.expr.createPseudo((text) => { } }) +function isSubmit (elem: Element): elem is HTMLInputElement { + return elem.tagName === 'INPUT' && (elem as HTMLInputElement).type === 'submit' +} + // Example: // #login>li:first:cy-contains-regex('/asdf 1/i') $.expr[':']['cy-contains-regex'] = $.expr.createPseudo((text) => { @@ -264,7 +283,7 @@ $.expr[':']['cy-contains-regex'] = $.expr.createPseudo((text) => { // taken from jquery's normal contains method return function (elem) { - if (elem.type === 'submit' && elem.tagName === 'INPUT') { + if (isSubmit(elem)) { return regex.test(elem.value) } From 7a62f79374dab561581181fb6ab2724b9bff9d2d Mon Sep 17 00:00:00 2001 From: BlueWinds Date: Mon, 3 Oct 2022 09:47:59 -0700 Subject: [PATCH 08/54] Fix one test, review feedback --- .../e2e/commands/querying/querying.cy.js | 20 +++++++++++++------ .../e2e/e2e/origin/commands/querying.cy.ts | 2 +- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/packages/driver/cypress/e2e/commands/querying/querying.cy.js b/packages/driver/cypress/e2e/commands/querying/querying.cy.js index be343a986fb4..479f98a8a19a 100644 --- a/packages/driver/cypress/e2e/commands/querying/querying.cy.js +++ b/packages/driver/cypress/e2e/commands/querying/querying.cy.js @@ -919,12 +919,6 @@ describe('src/cy/commands/querying', () => { }) context('#contains', () => { - it('should keep multiple contains() separate', () => { - cy.contains('New York').as('alias') - cy.contains('Nested Find').invoke('remove') - cy.get('@alias').should('exist') - }) - it('is scoped to the body and will not return title elements', () => { cy.contains('DOM Fixture').then(($el) => { expect($el).not.to.match('title') @@ -1179,6 +1173,20 @@ describe('src/cy/commands/querying', () => { cy.contains(/=[0-6]/, { timeout: 100 }).should('have.text', 'a=2') }) + it('does not interfere with other aliased .contains()', () => { + /* + * There was a regression (no github issue logged) while refactoring .contains() where if a test aliased + * a query using .contains(), future .contains() calls could overwrite its internal state, causing the first one + * to look for the second one's arguments rather than its own. + * + * This test guards against that regression; if the `contains('New York')` inside @newYork alias were + * overwritten by contains(`Nested Find`), then the existence assertion would fail. + */ + cy.contains('New York').as('newYork') + cy.contains('Nested Find').invoke('remove') + cy.get('@newYork').should('exist') + }) + describe('should(\'not.exist\')', () => { it('returns null when no content exists', () => { cy.contains('alksjdflkasjdflkajsdf').should('not.exist').then(($el) => { diff --git a/packages/driver/cypress/e2e/e2e/origin/commands/querying.cy.ts b/packages/driver/cypress/e2e/e2e/origin/commands/querying.cy.ts index 615d5fdadd64..49054466c506 100644 --- a/packages/driver/cypress/e2e/e2e/origin/commands/querying.cy.ts +++ b/packages/driver/cypress/e2e/e2e/origin/commands/querying.cy.ts @@ -146,7 +146,7 @@ context('cy.origin querying', () => { const { consoleProps } = findCrossOriginLogs('contains', logs, 'foobar.com') expect(consoleProps.Command).to.equal('contains') - expect(consoleProps['Applied To']).to.be.undefined + expect(consoleProps['Applied To']).to.have.property('tagName').that.equals('BODY') expect(consoleProps.Elements).to.equal(1) expect(consoleProps.Content).to.equal('Nested Find') expect(consoleProps.Yielded).to.have.property('tagName').that.equals('DIV') From a58e46bd9c302ce50f881979d5fe0103438d9100 Mon Sep 17 00:00:00 2001 From: BlueWinds Date: Mon, 3 Oct 2022 10:22:05 -0700 Subject: [PATCH 09/54] Review updates --- .../src/cy/commands/querying/querying.ts | 18 ++++++++++++------ packages/driver/src/cypress/cy.ts | 2 -- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/packages/driver/src/cy/commands/querying/querying.ts b/packages/driver/src/cy/commands/querying/querying.ts index daffcef10d9a..87c349bab241 100644 --- a/packages/driver/src/cy/commands/querying/querying.ts +++ b/packages/driver/src/cy/commands/querying/querying.ts @@ -158,14 +158,13 @@ export default (Commands, Cypress, cy, state) => { }) } - const log = userOptions.log !== false && Cypress.log({ + const log = userOptions.log !== false && (this.get('_log') || Cypress.log({ message: selector, timeout: userOptions.timeout, consoleProps: () => ({}), - }) + })) this.set('timeout', userOptions.timeout) - this.set('_log', log) if (aliasRe.test(selector)) { return getAlias.call(this, selector, log, cy) @@ -267,8 +266,17 @@ export default (Commands, Cypress, cy, state) => { const selector = $dom.getContainsSelector(text, filter, { matchCase: true, ...userOptions }) const getOptions = _.extend({}, userOptions) as GetOptions + + const log = userOptions.log !== false && Cypress.log({ + message: $utils.stringify(_.compact([filter, text])), + type: this.hasPreviouslyLinkedCommand ? 'child' : 'parent', + timeout: userOptions.timeout, + consoleProps: () => ({}), + }) + + this.set('_log', log) + const getFn = cy.now('get', selector, getOptions) - const log = this.get('_log') const getPhrase = () => { if (filter && !(getOptions.withinSubject as JQuery).is('body')) { @@ -337,9 +345,7 @@ export default (Commands, Cypress, cy, state) => { } log && log.set({ - message: $utils.stringify(_.compact([filter, text])), $el, - type: subject.is('body') ? 'parent' : 'child', consoleProps: () => { return { Content: text, diff --git a/packages/driver/src/cypress/cy.ts b/packages/driver/src/cypress/cy.ts index 9715f40db033..4855d1bd54e3 100644 --- a/packages/driver/src/cypress/cy.ts +++ b/packages/driver/src/cypress/cy.ts @@ -854,11 +854,9 @@ export class $Cy extends EventEmitter2 implements ITimeouts, IStability, IAssert const command = $Command.create({ name, args, - type: 'dual', chainerId: chainer.chainerId, userInvocationStack, query: true, - prevSubject: 'optional', }) const cyFn = function (chainerId, ...args) { From f62277a0beabbef5a54fbde439ac23c04febdb45 Mon Sep 17 00:00:00 2001 From: BlueWinds Date: Mon, 3 Oct 2022 10:48:19 -0700 Subject: [PATCH 10/54] Fix additional tests --- .../driver/cypress/e2e/commands/assertions.cy.js | 4 +--- .../cypress/e2e/commands/querying/querying.cy.js | 14 ++++---------- .../driver/src/cy/commands/querying/querying.ts | 1 + 3 files changed, 6 insertions(+), 13 deletions(-) diff --git a/packages/driver/cypress/e2e/commands/assertions.cy.js b/packages/driver/cypress/e2e/commands/assertions.cy.js index 4762203d16dd..58cc39936bdd 100644 --- a/packages/driver/cypress/e2e/commands/assertions.cy.js +++ b/packages/driver/cypress/e2e/commands/assertions.cy.js @@ -418,8 +418,6 @@ describe('src/cy/commands/assertions', () => { assertLogLength(this.logs, 6) expect(this.logs[3].get('name')).to.eq('get') - expect(this.logs[3].get('state')).to.eq('failed') - expect(this.logs[3].get('error')).to.eq(err) expect(this.logs[4].get('name')).to.eq('assert') expect(this.logs[4].get('state')).to.eq('failed') @@ -448,7 +446,7 @@ describe('src/cy/commands/assertions', () => { done() }) - cy.contains('Nested Find').should('have.length', 2) + cy.contains('Nested Find', { timeout: 50 }).should('have.length', 2) }) // https://github.com/cypress-io/cypress/issues/6384 diff --git a/packages/driver/cypress/e2e/commands/querying/querying.cy.js b/packages/driver/cypress/e2e/commands/querying/querying.cy.js index 479f98a8a19a..33fe30860bc1 100644 --- a/packages/driver/cypress/e2e/commands/querying/querying.cy.js +++ b/packages/driver/cypress/e2e/commands/querying/querying.cy.js @@ -1552,19 +1552,13 @@ space }) }) - it('sets type to parent when subject isnt element', () => { - cy.window().contains('foo').then(function () { - expect(this.lastLog.get('type')).to.eq('parent') - - cy.document().contains('foo').then(function () { - expect(this.lastLog.get('type')).to.eq('parent') - }) - }) - }) - it('sets type to child when used as a child command', () => { cy.get('#specific-contains').contains('foo').then(function () { expect(this.lastLog.get('type')).to.eq('child') + + cy.document().contains('foo').then(function () { + expect(this.lastLog.get('type')).to.eq('child') + }) }) }) diff --git a/packages/driver/src/cy/commands/querying/querying.ts b/packages/driver/src/cy/commands/querying/querying.ts index 87c349bab241..de592b4c3ba3 100644 --- a/packages/driver/src/cy/commands/querying/querying.ts +++ b/packages/driver/src/cy/commands/querying/querying.ts @@ -160,6 +160,7 @@ export default (Commands, Cypress, cy, state) => { const log = userOptions.log !== false && (this.get('_log') || Cypress.log({ message: selector, + type: 'parent', timeout: userOptions.timeout, consoleProps: () => ({}), })) From ed852c2df43118e9f1d3843af07acc94e8da98d0 Mon Sep 17 00:00:00 2001 From: BlueWinds Date: Mon, 3 Oct 2022 11:03:35 -0700 Subject: [PATCH 11/54] Fix accidental deletion of vital code --- packages/driver/src/cy/commands/querying/querying.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/driver/src/cy/commands/querying/querying.ts b/packages/driver/src/cy/commands/querying/querying.ts index de592b4c3ba3..47487cbebeb1 100644 --- a/packages/driver/src/cy/commands/querying/querying.ts +++ b/packages/driver/src/cy/commands/querying/querying.ts @@ -166,6 +166,7 @@ export default (Commands, Cypress, cy, state) => { })) this.set('timeout', userOptions.timeout) + this.set('_log', log) if (aliasRe.test(selector)) { return getAlias.call(this, selector, log, cy) From e4818581ca984da2b5f2e7cb9f3bfaf129e756e7 Mon Sep 17 00:00:00 2001 From: BlueWinds Date: Mon, 3 Oct 2022 11:28:52 -0700 Subject: [PATCH 12/54] One more try at getting logs right --- .../driver/src/cy/commands/querying/querying.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/driver/src/cy/commands/querying/querying.ts b/packages/driver/src/cy/commands/querying/querying.ts index 47487cbebeb1..8991e6cc49af 100644 --- a/packages/driver/src/cy/commands/querying/querying.ts +++ b/packages/driver/src/cy/commands/querying/querying.ts @@ -4,11 +4,15 @@ import $dom from '../../../dom' import $elements from '../../../dom/elements' import $errUtils from '../../../cypress/error_utils' import $utils from '../../../cypress/utils' +import type { Log } from '../../../cypress/log' import { resolveShadowDomInclusion } from '../../../cypress/shadow_dom_utils' import { getAliasedRequests, isDynamicAliasingPossible } from '../../net-stubbing/aliasing' import { aliasRe, aliasIndexRe } from '../../aliases' -type GetOptions = Partial +type GetOptions = Partial + type ContainsOptions = Partial type ShadowOptions = Partial @@ -158,7 +162,7 @@ export default (Commands, Cypress, cy, state) => { }) } - const log = userOptions.log !== false && (this.get('_log') || Cypress.log({ + const log = userOptions.log !== false && (userOptions._log || Cypress.log({ message: selector, type: 'parent', timeout: userOptions.timeout, @@ -267,8 +271,6 @@ export default (Commands, Cypress, cy, state) => { // and any submit inputs with the attributeContainsWord selector const selector = $dom.getContainsSelector(text, filter, { matchCase: true, ...userOptions }) - const getOptions = _.extend({}, userOptions) as GetOptions - const log = userOptions.log !== false && Cypress.log({ message: $utils.stringify(_.compact([filter, text])), type: this.hasPreviouslyLinkedCommand ? 'child' : 'parent', @@ -276,8 +278,7 @@ export default (Commands, Cypress, cy, state) => { consoleProps: () => ({}), }) - this.set('_log', log) - + const getOptions = _.extend({ _log: log }, userOptions) as GetOptions const getFn = cy.now('get', selector, getOptions) const getPhrase = () => { From 5b7335391f22f430c1d920157cd0e7a5492426c7 Mon Sep 17 00:00:00 2001 From: BlueWinds Date: Mon, 3 Oct 2022 13:12:56 -0700 Subject: [PATCH 13/54] Fix race condition in cross-origin .contains --- packages/driver/src/cy/commands/querying/querying.ts | 4 ++-- packages/driver/src/cy/ensures.ts | 8 +++----- packages/driver/src/cypress/command_queue.ts | 2 +- packages/driver/src/cypress/cy.ts | 2 +- 4 files changed, 7 insertions(+), 9 deletions(-) diff --git a/packages/driver/src/cy/commands/querying/querying.ts b/packages/driver/src/cy/commands/querying/querying.ts index 8991e6cc49af..ebccab6b4223 100644 --- a/packages/driver/src/cy/commands/querying/querying.ts +++ b/packages/driver/src/cy/commands/querying/querying.ts @@ -328,7 +328,7 @@ export default (Commands, Cypress, cy, state) => { }) return (subject) => { - cy.ensureSubjectByType(subject, ['optional', 'window', 'document', 'element']) + cy.ensureSubjectByType(subject, ['optional', 'window', 'document', 'element'], this) if (!subject || (!$dom.isElement(subject) && !$elements.isShadowRoot(subject[0]))) { subject = cy.$$('body') @@ -385,7 +385,7 @@ export default (Commands, Cypress, cy, state) => { }) return (subject) => { - cy.ensureSubjectByType(subject, 'element') + cy.ensureSubjectByType(subject, 'element', this) // find all shadow roots of the subject(s), if any exist const $el = subject diff --git a/packages/driver/src/cy/ensures.ts b/packages/driver/src/cy/ensures.ts index b0db2c0f9779..1760005d14fc 100644 --- a/packages/driver/src/cy/ensures.ts +++ b/packages/driver/src/cy/ensures.ts @@ -20,7 +20,7 @@ export const create = (state: StateFunc, expect: $Cy['expect']) => { // into an array and loop through each and verify // each element in the array is valid. as it stands // we only validate the first - const validateType = (subject, type, cmd) => { + const validateType = (subject, type, cmd = state('current')) => { const name = cmd.get('name') switch (type) { @@ -45,9 +45,7 @@ export const create = (state: StateFunc, expect: $Cy['expect']) => { } } - const ensureSubjectByType = (subject, type) => { - const current = state('current') - + const ensureSubjectByType = (subject, type, command) => { let types: (string | boolean)[] = [].concat(type) // if we have an optional subject and nothing's @@ -71,7 +69,7 @@ export const create = (state: StateFunc, expect: $Cy['expect']) => { for (type of types) { try { - validateType(subject, type, current) + validateType(subject, type, command) } catch (error) { err = error errors.push(err) diff --git a/packages/driver/src/cypress/command_queue.ts b/packages/driver/src/cypress/command_queue.ts index 983b2e176505..957f90adc9c1 100644 --- a/packages/driver/src/cypress/command_queue.ts +++ b/packages/driver/src/cypress/command_queue.ts @@ -93,7 +93,7 @@ function retryQuery (command: $Command, ret: any, cy: $Cy) { subjectFn: () => { const subject = cy.currentSubject(command.get('chainerId')) - cy.ensureSubjectByType(subject, command.get('prevSubject')) + cy.ensureSubjectByType(subject, command.get('prevSubject'), command) return cy.verifyUpcomingAssertions(subject, options, { onRetry, diff --git a/packages/driver/src/cypress/cy.ts b/packages/driver/src/cypress/cy.ts index 4855d1bd54e3..e4552ea47c7e 100644 --- a/packages/driver/src/cypress/cy.ts +++ b/packages/driver/src/cypress/cy.ts @@ -1266,7 +1266,7 @@ export class $Cy extends EventEmitter2 implements ITimeouts, IStability, IAssert if (prevSubject !== undefined) { // make sure our current subject is valid for // what we expect in this command - this.ensureSubjectByType(subject, prevSubject) + this.ensureSubjectByType(subject, prevSubject, this.state('current')) } args.unshift(subject) From b1b7b38dbb976b4877c3a5de9002ecaa840928af Mon Sep 17 00:00:00 2001 From: BlueWinds Date: Mon, 3 Oct 2022 13:53:33 -0700 Subject: [PATCH 14/54] Add commented out test to ensure .within() works properly with selectors --- packages/app/cypress/e2e/reporter_header.cy.ts | 5 +++-- packages/driver/cypress/e2e/commands/aliasing.cy.js | 9 +++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/app/cypress/e2e/reporter_header.cy.ts b/packages/app/cypress/e2e/reporter_header.cy.ts index 679c9e29b6d7..3b6cb949486b 100644 --- a/packages/app/cypress/e2e/reporter_header.cy.ts +++ b/packages/app/cypress/e2e/reporter_header.cy.ts @@ -16,7 +16,8 @@ describe('Reporter Header', () => { cy.get('[data-selected-spec="false"]').should('have.length', '27') }) - it('filters the list of specs when searching for specs', () => { + // TODO: Reenable as part of https://github.com/cypress-io/cypress/issues/23902 + it.skip('filters the list of specs when searching for specs', () => { cy.get('body').type('f') cy.findByTestId('specs-list-panel').within(() => { @@ -28,7 +29,7 @@ describe('Reporter Header', () => { cy.get('@searchInput').clear() - cy.get('[data-cy="spec-file-item"]').should('have.length', 3) + cy.get('[data-cy="spec-file-item"]').should('have.length', 23) cy.get('@searchInput').type('asdf', { force: true }) diff --git a/packages/driver/cypress/e2e/commands/aliasing.cy.js b/packages/driver/cypress/e2e/commands/aliasing.cy.js index 18ff6704a99f..cdae08d337e5 100644 --- a/packages/driver/cypress/e2e/commands/aliasing.cy.js +++ b/packages/driver/cypress/e2e/commands/aliasing.cy.js @@ -495,5 +495,14 @@ describe('src/cy/commands/aliasing', () => { .get('@lastDiv') }) }) + + // TODO: Re-enable as part of https://github.com/cypress-io/cypress/issues/23902 + it.skip('maintains .within() context while reading aliases', () => { + cy.get('#specific-contains').within(() => { + cy.get('span').as('spanWithin').should('have.length', 1) + }) + + cy.get('@spanWithin').should('have.length', 1) + }) }) }) From 94f3d3f887f5b5b12b25752b98af5e5671320763 Mon Sep 17 00:00:00 2001 From: BlueWinds Date: Tue, 4 Oct 2022 12:21:39 -0700 Subject: [PATCH 15/54] Fix for sessions + query subject chaining --- packages/driver/src/cy/commands/sessions/index.ts | 6 +++++- yarn.lock | 5 ----- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/driver/src/cy/commands/sessions/index.ts b/packages/driver/src/cy/commands/sessions/index.ts index 2296ea77e077..960948c3be75 100644 --- a/packages/driver/src/cy/commands/sessions/index.ts +++ b/packages/driver/src/cy/commands/sessions/index.ts @@ -183,7 +183,11 @@ export default function (Commands, Cypress, cy) { return false }) - return existingSession.setup() + try { + return existingSession.setup() + } finally { + cy.breakSubjectLinksToCurrentChainer() + } }) .then(async () => { cy.state('onCommandFailed', null) diff --git a/yarn.lock b/yarn.lock index 09fadd2f26b4..b50d2cbf3a58 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8441,11 +8441,6 @@ amdefine@>=0.0.4: resolved "https://registry.yarnpkg.com/amdefine/-/amdefine-1.0.1.tgz#4a5282ac164729e93619bcfd3ad151f817ce91f5" integrity sha1-SlKCrBZHKek2Gbz9OtFR+BfOkfU= -angular@1.8.0: - version "1.8.0" - resolved "https://registry.yarnpkg.com/angular/-/angular-1.8.0.tgz#b1ec179887869215cab6dfd0df2e42caa65b1b51" - integrity sha512-VdaMx+Qk0Skla7B5gw77a8hzlcOakwF8mjlW13DpIWIDlfqwAbSSLfd8N/qZnzEmQF4jC4iofInd3gE7vL8ZZg== - ansi-align@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/ansi-align/-/ansi-align-2.0.0.tgz#c36aeccba563b89ceb556f3690f0b1d9e3547f7f" From 7fc0859dfe48f7ee3b36dac7c14081ca2ab16e7a Mon Sep 17 00:00:00 2001 From: BlueWinds Date: Tue, 4 Oct 2022 13:31:09 -0700 Subject: [PATCH 16/54] Fix mixing .within() shadow DOM and .contains() in same chainer --- packages/driver/src/cy/commands/querying/querying.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/driver/src/cy/commands/querying/querying.ts b/packages/driver/src/cy/commands/querying/querying.ts index ebccab6b4223..58f9399210df 100644 --- a/packages/driver/src/cy/commands/querying/querying.ts +++ b/packages/driver/src/cy/commands/querying/querying.ts @@ -184,7 +184,7 @@ export default (Commands, Cypress, cy, state) => { let $el try { - let scope = cy.state('withinSubject') || userOptions.withinSubject + let scope = userOptions.withinSubject || cy.state('withinSubject') if (scope && scope[0]) { scope = scope[0] From 4b511da9cc5aaf472918e419c5bd3d7e1c7c84b2 Mon Sep 17 00:00:00 2001 From: BlueWinds Date: Tue, 4 Oct 2022 14:09:01 -0700 Subject: [PATCH 17/54] One more attempt at .within + .contains --- packages/driver/src/cy/commands/querying/querying.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/driver/src/cy/commands/querying/querying.ts b/packages/driver/src/cy/commands/querying/querying.ts index 58f9399210df..7522861b09fe 100644 --- a/packages/driver/src/cy/commands/querying/querying.ts +++ b/packages/driver/src/cy/commands/querying/querying.ts @@ -331,7 +331,7 @@ export default (Commands, Cypress, cy, state) => { cy.ensureSubjectByType(subject, ['optional', 'window', 'document', 'element'], this) if (!subject || (!$dom.isElement(subject) && !$elements.isShadowRoot(subject[0]))) { - subject = cy.$$('body') + subject = cy.state('withinSubject') || cy.$$('body') } getOptions.withinSubject = subject[0] ?? subject From 0eecaed6a857e1d299474b1abf188812d8cafe45 Mon Sep 17 00:00:00 2001 From: BlueWinds Date: Wed, 5 Oct 2022 08:42:44 -0700 Subject: [PATCH 18/54] Fix rebase commits --- .../cypress/e2e/commands/querying/querying.cy.js | 2 -- packages/driver/cypress/e2e/cypress/cy.cy.js | 4 ++-- packages/driver/cypress/support/utils.js | 2 +- packages/driver/src/cy/commands/querying/querying.ts | 10 +++++----- packages/driver/src/cypress/command_queue.ts | 5 ++--- packages/driver/src/cypress/commands.ts | 2 +- 6 files changed, 11 insertions(+), 14 deletions(-) diff --git a/packages/driver/cypress/e2e/commands/querying/querying.cy.js b/packages/driver/cypress/e2e/commands/querying/querying.cy.js index 33fe30860bc1..69bbe497b10f 100644 --- a/packages/driver/cypress/e2e/commands/querying/querying.cy.js +++ b/packages/driver/cypress/e2e/commands/querying/querying.cy.js @@ -776,8 +776,6 @@ describe('src/cy/commands/querying', () => { cy.get('#missing-el').should('have.prop', 'foo') }) - it('throws when using an alias that does not exist') - it('throws after timing out after a .wait() alias reference', (done) => { cy.on('fail', (err) => { expect(err.message).to.include('Expected to find element: `getJsonButton`, but never found it.') diff --git a/packages/driver/cypress/e2e/cypress/cy.cy.js b/packages/driver/cypress/e2e/cypress/cy.cy.js index da5b15c3c456..909a16939e50 100644 --- a/packages/driver/cypress/e2e/cypress/cy.cy.js +++ b/packages/driver/cypress/e2e/cypress/cy.cy.js @@ -539,13 +539,13 @@ describe('driver/src/cypress/cy', () => { cy.on('log:added', (attrs, log) => logs.push(log)) - Cypress.Commands.overwriteQuery('aQuery', () => { + Cypress.Commands._overwriteQuery('aQuery', () => { cy.now('get', 'body') return cy.now('get', 'button') }) - Cypress.Commands.overwriteQuery('bQuery', () => cy.now('aQuery')) + Cypress.Commands._overwriteQuery('bQuery', () => cy.now('aQuery')) cy.aQuery().should('have.length', 24) cy.then(() => { diff --git a/packages/driver/cypress/support/utils.js b/packages/driver/cypress/support/utils.js index 357f75db65fc..c59634fccf28 100644 --- a/packages/driver/cypress/support/utils.js +++ b/packages/driver/cypress/support/utils.js @@ -139,7 +139,7 @@ export const expectCaret = (start, end) => { } } -Cypress.Commands.addQuery('getAll', getAllFn) +Cypress.Commands._addQuery('getAll', getAllFn) Cypress.Commands.add('shouldWithTimeout', shouldWithTimeout) diff --git a/packages/driver/src/cy/commands/querying/querying.ts b/packages/driver/src/cy/commands/querying/querying.ts index 7522861b09fe..a00aee8c3ae4 100644 --- a/packages/driver/src/cy/commands/querying/querying.ts +++ b/packages/driver/src/cy/commands/querying/querying.ts @@ -155,7 +155,7 @@ function getAlias (selector, log, cy) { } export default (Commands, Cypress, cy, state) => { - Commands.addQuery('get', function get (selector, userOptions: GetOptions = {}) { + Commands._addQuery('get', function get (selector, userOptions: GetOptions = {}) { if ((userOptions === null) || _.isArray(userOptions) || !_.isPlainObject(userOptions)) { $errUtils.throwErrByPath('get.invalid_options', { args: { options: userOptions }, @@ -234,7 +234,7 @@ export default (Commands, Cypress, cy, state) => { } }) - Commands.addQuery('contains', function contains (filter, text, userOptions: ContainsOptions = {}) { + Commands._addQuery('contains', function contains (filter, text, userOptions: ContainsOptions = {}) { if (_.isRegExp(text)) { // .contains(filter, text) // Do nothing @@ -282,7 +282,7 @@ export default (Commands, Cypress, cy, state) => { const getFn = cy.now('get', selector, getOptions) const getPhrase = () => { - if (filter && !(getOptions.withinSubject as JQuery).is('body')) { + if (filter && !(cy.$$(getOptions.withinSubject) as JQuery).is('body')) { const node = $dom.stringify(getOptions.withinSubject, 'short') return `within the element: ${node} and with the selector: '${filter}' ` @@ -292,7 +292,7 @@ export default (Commands, Cypress, cy, state) => { return `within the selector: '${filter}' ` } - if (!(getOptions.withinSubject as JQuery).is('body')) { + if (!(cy.$$(getOptions.withinSubject) as JQuery).is('body')) { const node = $dom.stringify(getOptions.withinSubject, 'short') return `within the element: ${node} ` @@ -363,7 +363,7 @@ export default (Commands, Cypress, cy, state) => { } }) - Commands.addQuery('shadow', function contains (userOptions: ShadowOptions = {}) { + Commands._addQuery('shadow', function contains (userOptions: ShadowOptions = {}) { const log = userOptions.log !== false && Cypress.log({ timeout: userOptions.timeout, consoleProps: () => ({}), diff --git a/packages/driver/src/cypress/command_queue.ts b/packages/driver/src/cypress/command_queue.ts index 957f90adc9c1..714f8f9da461 100644 --- a/packages/driver/src/cypress/command_queue.ts +++ b/packages/driver/src/cypress/command_queue.ts @@ -95,9 +95,8 @@ function retryQuery (command: $Command, ret: any, cy: $Cy) { cy.ensureSubjectByType(subject, command.get('prevSubject'), command) - return cy.verifyUpcomingAssertions(subject, options, { - onRetry, - onFail: command.get('onFail'), + return ret(subject) + }, }) } diff --git a/packages/driver/src/cypress/commands.ts b/packages/driver/src/cypress/commands.ts index f04943039c92..1b057554d133 100644 --- a/packages/driver/src/cypress/commands.ts +++ b/packages/driver/src/cypress/commands.ts @@ -165,7 +165,7 @@ export default { builtInCommandNames[name] = true } - cy.addQuery({ name, fn }) + cy._addQuery({ name, fn }) }, _overwriteQuery (name, fn) { From aab9565e15d1735e41473ac3f1bdd75c2cf1543b Mon Sep 17 00:00:00 2001 From: BlueWinds Date: Wed, 5 Oct 2022 15:23:08 -0700 Subject: [PATCH 19/54] Update many commands to be queries; improve log message around invalid subjects --- .../driver/cypress/e2e/commands/misc.cy.js | 6 +- .../cypress/e2e/commands/traversals.cy.js | 10 +- packages/driver/cypress/e2e/cypress/cy.cy.js | 15 +- packages/driver/src/cy/commands/connectors.ts | 7 +- packages/driver/src/cy/commands/debugging.ts | 114 ++++----- packages/driver/src/cy/commands/location.ts | 3 + packages/driver/src/cy/commands/misc.ts | 36 +-- .../src/cy/commands/querying/focused.ts | 1 + .../src/cy/commands/querying/querying.ts | 2 +- packages/driver/src/cy/commands/traversals.ts | 241 +++++++----------- packages/driver/src/cy/commands/window.ts | 3 + packages/driver/src/cy/ensures.ts | 26 +- packages/driver/src/cypress/command_queue.ts | 2 +- packages/driver/src/cypress/commands.ts | 7 + packages/driver/src/cypress/error_messages.ts | 23 ++ 15 files changed, 236 insertions(+), 260 deletions(-) diff --git a/packages/driver/cypress/e2e/commands/misc.cy.js b/packages/driver/cypress/e2e/commands/misc.cy.js index 34f81050f1cc..4238afd8e1bf 100644 --- a/packages/driver/cypress/e2e/commands/misc.cy.js +++ b/packages/driver/cypress/e2e/commands/misc.cy.js @@ -229,9 +229,8 @@ describe('src/cy/commands/misc', () => { it('throws when wrapping an array of windows', (done) => { cy.on('fail', (err) => { - expect(err.message).to.include('`cy.scrollTo()` failed because it requires a DOM element.') + expect(err.message).to.include('`cy.scrollTo()` failed because it requires a DOM element or window.') expect(err.message).to.include('[]') - expect(err.message).to.include('All 2 subject validations failed on this subject.') done() }) @@ -243,9 +242,8 @@ describe('src/cy/commands/misc', () => { it('throws when wrapping an array of documents', (done) => { cy.on('fail', (err) => { - expect(err.message).to.include('`cy.screenshot()` failed because it requires a DOM element.') + expect(err.message).to.include('`cy.screenshot()` failed because it requires a DOM element, window or document.') expect(err.message).to.include('[]') - expect(err.message).to.include('All 3 subject validations failed on this subject.') done() }) diff --git a/packages/driver/cypress/e2e/commands/traversals.cy.js b/packages/driver/cypress/e2e/commands/traversals.cy.js index 44de3d22b7f0..59e003bdfe18 100644 --- a/packages/driver/cypress/e2e/commands/traversals.cy.js +++ b/packages/driver/cypress/e2e/commands/traversals.cy.js @@ -344,13 +344,13 @@ describe('src/cy/commands/traversals', () => { const button = cy.$$('#button').hide() cy.on('fail', (err) => { - const log = this.logs[1] + const [, findLog, assertLog] = this.logs - expect(log.get('state')).to.eq('failed') - expect(err.message).to.include(log.get('error').message) - expect(log.get('$el').get(0)).to.eq(button.get(0)) + expect(assertLog.get('state')).to.eq('failed') + expect(assertLog.get('error').message).to.include(err.message) + expect(assertLog.get('$el').get(0)).to.eq(button.get(0)) - const consoleProps = log.invoke('consoleProps') + const consoleProps = findLog.invoke('consoleProps') expect(consoleProps.Yielded).to.eq(button.get(0)) expect(consoleProps.Elements).to.eq(button.length) diff --git a/packages/driver/cypress/e2e/cypress/cy.cy.js b/packages/driver/cypress/e2e/cypress/cy.cy.js index 909a16939e50..3c5e3854521b 100644 --- a/packages/driver/cypress/e2e/cypress/cy.cy.js +++ b/packages/driver/cypress/e2e/cypress/cy.cy.js @@ -314,7 +314,7 @@ describe('driver/src/cypress/cy', () => { done() }) - cy.get('#button').click().parent() + cy.get('#button').click().parent({ timeout: 50 }) }) it('fails when previous subject isnt window', (done) => { @@ -346,10 +346,9 @@ describe('driver/src/cypress/cy', () => { cy.on('fail', (err) => { expect(firstPassed).to.be.true - expect(err.message).to.include('`cy.elWinOnly()` failed because it requires a DOM element.') + expect(err.message).to.include('`cy.elWinOnly()` failed because it requires a DOM element or window.') expect(err.message).to.include('string') expect(err.message).to.include('> `cy.wrap()`') - expect(err.message).to.include('All 2 subject validations failed') done() }) @@ -416,10 +415,10 @@ describe('driver/src/cypress/cy', () => { return orig(`foo${arg1}`) }) - Cypress.Commands.overwrite('first', (orig, subject) => { - subject = $([1, 2]) + Cypress.Commands.overwrite('each', (orig, subject, cb) => { + subject = $([1]) - return orig(subject) + return orig(subject, cb) }) Cypress.Commands.overwrite('noop', function (orig, fn) { @@ -439,8 +438,8 @@ describe('driver/src/cypress/cy', () => { }) it('can modify child commands', () => { - cy.get('li').first().then((el) => { - expect(el[0]).to.eq(1) + cy.get('li').each((i) => { + expect(i).to.eq(1) }) }) diff --git a/packages/driver/src/cy/commands/connectors.ts b/packages/driver/src/cy/commands/connectors.ts index 2fcdf235e227..85f41df0b551 100644 --- a/packages/driver/src/cy/commands/connectors.ts +++ b/packages/driver/src/cy/commands/connectors.ts @@ -515,10 +515,6 @@ export default function (Commands, Cypress, cy, state) { }, }) - // temporarily keeping this as a dual command - // but it will move to a child command once - // cy.resolve + cy.wrap are upgraded to handle - // promises Commands.addAll({ prevSubject: 'optional' }, { then (subject, userOptions, fn) { // eslint-disable-next-line prefer-rest-params @@ -532,10 +528,13 @@ export default function (Commands, Cypress, cy, state) { // return values are undefined. prob should rethink // this and investigate why that is the default behavior // of child commands + + // TODO: query invoke (subject, optionsOrStr, ...args) { return invokeFn.apply(this, [subject, optionsOrStr, ...args]) }, + // TODO: query its (subject, str, options, ...args) { return invokeItsFn.apply(this, [subject, str, options, ...args]) }, diff --git a/packages/driver/src/cy/commands/debugging.ts b/packages/driver/src/cy/commands/debugging.ts index 8ac152cf0604..031a29f1eadc 100644 --- a/packages/driver/src/cy/commands/debugging.ts +++ b/packages/driver/src/cy/commands/debugging.ts @@ -1,7 +1,5 @@ import _ from 'lodash' - import $utils from '../../cypress/utils' -import type { Log } from '../../cypress/log' const resume = (state, resumeAll = true) => { const onResume = state('onResume') @@ -33,14 +31,6 @@ const getNextQueuedCommand = (state, queue) => { return search(state('index')) } -interface InternalPauseOptions extends Partial { - _log?: Log -} - -interface InternalDebugOptions extends Partial { - _log?: Log -} - export default (Commands, Cypress, cy, state, config) => { Cypress.on('resume:next', () => { return resume(state, false) @@ -50,47 +40,48 @@ export default (Commands, Cypress, cy, state, config) => { return resume(state) }) - Commands.addAll({ type: 'utility', prevSubject: 'optional' }, { - // pause should indefinitely pause until the user - // presses a key or clicks in the UI to continue - pause (subject, userOptions: Partial = {}) { - // bail if we're in run mode, unless --headed and --no-exit flags are passed - if (!config('isInteractive') && (!config('browser').isHeaded || config('exit'))) { - return subject - } + // pause should indefinitely pause until the user + // presses a key or clicks in the UI to continue + Commands._addQuery('pause', function pause (options: Partial = {}) { + if (!config('isInteractive') && (!config('browser').isHeaded || config('exit'))) { + return _.identity + } - const options: InternalPauseOptions = _.defaults({}, userOptions, { log: true }) + const log = options.log !== false && Cypress.log({ + snapshot: true, + autoEnd: false, + timeout: 0, + }) - if (options.log) { - options._log = Cypress.log({ - snapshot: true, - autoEnd: false, - timeout: 0, - }) - } + let hasPaused = false - const onResume = (fn, timeout) => { - return state('onResume', (resumeAll) => { - if (resumeAll) { + const onResume = (fn, timeout) => { + return state('onResume', (resumeAll) => { + if (resumeAll) { // nuke onPause only if // we've been told to resume // all the commands, else // pause on the very next one - state('onPaused', null) + state('onPaused', null) + + log && log.end() + } - if (options._log) { - options._log!.end() - } - } + // restore timeout + cy.timeout(timeout) - // restore timeout - cy.timeout(timeout) + // invoke callback fn + return fn() + }) + } - // invoke callback fn - return fn() - }) + return (subject) => { + if (hasPaused) { + return subject } + hasPaused = true + state('onPaused', (fn) => { const next = getNextQueuedCommand(state, cy.queue) @@ -107,35 +98,36 @@ export default (Commands, Cypress, cy, state, config) => { }) return subject - }, + } + }) - debug (subject, userOptions: Partial = {}) { - const options: InternalDebugOptions = _.defaults({}, userOptions, { - log: true, - }) + Commands._addQuery('debug', function debug (options: Partial = {}) { + options.log !== false && Cypress.log({ + snapshot: true, + end: true, + timeout: 0, + }) - if (options.log) { - options._log = Cypress.log({ - snapshot: true, - end: true, - timeout: 0, - }) - } + let hasPaused = false - const previous = state('current').get('prev') + return (subject) => { + if (!hasPaused) { + hasPaused = true + const previous = this.get('prev') - $utils.log('\n%c------------------------ Debug Info ------------------------', 'font-weight: bold;') - $utils.log('Command Name: ', previous && previous.get('name')) - $utils.log('Command Args: ', previous && previous.get('args')) - $utils.log('Current Subject: ', subject) + $utils.log('\n%c------------------------ Debug Info ------------------------', 'font-weight: bold;') + $utils.log('Command Name: ', previous && previous.get('name')) + $utils.log('Command Args: ', previous && previous.get('args')) + $utils.log('Current Subject: ', subject) - ////// HOVER OVER TO INSPECT THE CURRENT SUBJECT ////// - subject - /////////////////////////////////////////////////////// + ////// HOVER OVER TO INSPECT THE CURRENT SUBJECT ////// + subject + /////////////////////////////////////////////////////// - debugger // eslint-disable-line no-debugger + debugger // eslint-disable-line no-debugger + } return subject - }, + } }) } diff --git a/packages/driver/src/cy/commands/location.ts b/packages/driver/src/cy/commands/location.ts index 900d12cabca1..6c590753845f 100644 --- a/packages/driver/src/cy/commands/location.ts +++ b/packages/driver/src/cy/commands/location.ts @@ -15,6 +15,7 @@ interface InternalHashOptions extends Partial { Commands.addAll({ + // TODO: query url (userOptions: Partial = {}) { const options: InternalUrlOptions = _.defaults({}, userOptions, { log: true }) @@ -44,6 +45,7 @@ export default (Commands, Cypress, cy) => { return resolveHref() }, + // TODO: query hash (userOptions: Partial = {}) { const options: InternalHashOptions = _.defaults({}, userOptions, { log: true }) @@ -69,6 +71,7 @@ export default (Commands, Cypress, cy) => { return resolveHash() }, + // TODO: query location (key, options) { let userOptions = options diff --git a/packages/driver/src/cy/commands/misc.ts b/packages/driver/src/cy/commands/misc.ts index cf0f1238c5b3..d016373b040d 100644 --- a/packages/driver/src/cy/commands/misc.ts +++ b/packages/driver/src/cy/commands/misc.ts @@ -11,33 +11,21 @@ interface InternalWrapOptions extends Partial { - Commands.addAll({ prevSubject: 'optional' }, { - end () { - return null - }, - }) + Commands._addQuery('end', () => () => null) + Commands._addQuery('noop', () => _.identity) - Commands.addAll({ - noop (arg) { - return arg - }, + Commands._addQuery('log', (msg, ...args) => { + Cypress.log({ + end: true, + snapshot: true, + message: [msg, ...args], + consoleProps: () => ({ message: msg, args }), + }) - log (msg, ...args) { - Cypress.log({ - end: true, - snapshot: true, - message: [msg, ...args], - consoleProps () { - return { - message: msg, - args, - } - }, - }) - - return null - }, + return () => null + }) + Commands.addAll({ wrap (arg, userOptions: Partial = {}) { const options: InternalWrapOptions = _.defaults({}, userOptions, { log: true, diff --git a/packages/driver/src/cy/commands/querying/focused.ts b/packages/driver/src/cy/commands/querying/focused.ts index c96acf77cc4c..dd460a6f08cb 100644 --- a/packages/driver/src/cy/commands/querying/focused.ts +++ b/packages/driver/src/cy/commands/querying/focused.ts @@ -11,6 +11,7 @@ interface InternalFocusedOptions extends Partial { Commands.addAll({ + // TODO: query focused (userOptions: Partial = {}) { const options: InternalFocusedOptions = _.defaults({}, userOptions, { verify: true, diff --git a/packages/driver/src/cy/commands/querying/querying.ts b/packages/driver/src/cy/commands/querying/querying.ts index a00aee8c3ae4..2cdb58edbbb9 100644 --- a/packages/driver/src/cy/commands/querying/querying.ts +++ b/packages/driver/src/cy/commands/querying/querying.ts @@ -328,7 +328,7 @@ export default (Commands, Cypress, cy, state) => { }) return (subject) => { - cy.ensureSubjectByType(subject, ['optional', 'window', 'document', 'element'], this) + cy.ensureSubjectByType(subject, ['optional', 'element', 'window', 'document'], this) if (!subject || (!$dom.isElement(subject) && !$elements.isShadowRoot(subject[0]))) { subject = cy.state('withinSubject') || cy.$$('body') diff --git a/packages/driver/src/cy/commands/traversals.ts b/packages/driver/src/cy/commands/traversals.ts index 096bdb31fbeb..465f3c66c8be 100644 --- a/packages/driver/src/cy/commands/traversals.ts +++ b/packages/driver/src/cy/commands/traversals.ts @@ -4,198 +4,141 @@ import $dom from '../../dom' import $elements from '../../dom/elements' import { resolveShadowDomInclusion } from '../../cypress/shadow_dom_utils' +type TraversalOptions = Partial + const traversals = 'find filter not children eq closest first last next nextAll nextUntil parent parents parentsUntil prev prevAll prevUntil siblings'.split(' ') -const optInShadowTraversals = { - find: (cy, $el, arg1, arg2) => { - const roots = $el.map((i, el) => { - return $dom.findAllShadowRoots(el) - }) +export default (Commands, Cypress, cy) => { + const sortedUnique = ($el) => { + // we want _.uniq() to keep the elements with higher indexes instead of lower + // so we reverse, uniq, then reverse again + // so [div1, body, html, div2, body, html] + // becomes [div1, div2, body, html] and not [div1, body, html, div2] + return cy.$$(_($el).reverse().uniq().reverse().value()) + } + + const getEl = (traversal, includeShadowDom, subject, arg1, arg2) => { + if (traversal === 'find' && includeShadowDom) { + const roots = subject.map((i, el) => $dom.findAllShadowRoots(el)) + + // add the roots to the existing selection + const elementsWithShadow = subject.add(_.flatten(roots)) + + // query the entire set of [selection + shadow roots] + return elementsWithShadow.find(arg1, arg2) + } - // add the roots to the existing selection - const elementsWithShadow = $el.add(_.flatten(roots)) + if (traversal === 'closest' && $dom.isWithinShadowRoot(subject[0])) { + const nodes = _.reduce(subject, (nodes, el) => { + const getClosest = (node) => { + const closestNode = node.closest(arg1) - // query the entire set of [selection + shadow roots] - return elementsWithShadow.find(arg1, arg2) - }, -} + if (closestNode) return nodes.concat(closestNode) -const sortedUnique = (cy, $el) => { - // we want _.uniq() to keep the elements with higher indexes instead of lower - // so we reverse, uniq, then reverse again - // so [div1, body, html, div2, body, html] - // becomes [div1, div2, body, html] and not [div1, body, html, div2] - return cy.$$(_($el).reverse().uniq().reverse().value()) -} + const root = el.getRootNode() -const autoShadowTraversals = { - closest: (cy, $el, selector) => { - const nodes = _.reduce($el, (nodes, el) => { - const getClosest = (node) => { - const closestNode = node.closest(selector) + if (!$elements.isShadowRoot(root)) return nodes - if (closestNode) return nodes.concat(closestNode) - - const root = el.getRootNode() + return getClosest(root.host) + } - if (!$elements.isShadowRoot(root)) return nodes + return getClosest(el) + }, []) - return getClosest(root.host) - } + return sortedUnique(nodes) + } - return getClosest(el) - }, []) + if (traversal === 'parent' && $dom.isWithinShadowRoot(subject[0])) { + const parents = subject.map((i, el) => $elements.getParentNode(el)) - return sortedUnique(cy, nodes) - }, - parent: (cy, $el) => { - const parents = $el.map((i, el) => { - return $elements.getParentNode(el) - }) + return sortedUnique(parents) + } - return sortedUnique(cy, parents) - }, - parents: (cy, $el, selector) => { - let $parents = $el.map((i, el) => { - return $elements.getAllParents(el) - }) + if (traversal === 'parents' && $dom.isWithinShadowRoot(subject[0])) { + let $parents = subject.map((i, el) => $elements.getAllParents(el)) - if ($el.length > 1) { - $parents = sortedUnique(cy, $parents) - } + if (subject.length > 1) { + $parents = sortedUnique($parents) + } - if (!selector) { - return $parents + return arg1 ? $parents.filter(arg1) : $parents } - return $parents.filter(selector) - }, - parentsUntil: (cy, $el, selectorOrEl, filter) => { - let $parents = $el.map((i, el) => { - return $elements.getAllParents(el, selectorOrEl) - }) + if (traversal === 'parentsUntil' && $dom.isWithinShadowRoot(subject[0])) { + let $parents = subject.map((i, el) => $elements.getAllParents(el, arg1)) - if ($el.length > 1) { - $parents = sortedUnique(cy, $parents) - } + if (subject.length > 1) { + $parents = sortedUnique($parents) + } - if (!filter) { - return $parents + return arg2 ? $parents.filter(arg2) : $parents } - return $parents.filter(filter) - }, -} + return subject[traversal].call(subject, arg1, arg2) + } -type EachConsoleProps = { - Selector: string - 'Applied To': any - Yielded?: any - Elements?: number | undefined -} - -export default (Commands, Cypress, cy) => { _.each(traversals, (traversal) => { - Commands.add(traversal, { prevSubject: ['element', 'document'] }, (subject, arg1, arg2, options) => { + Commands._addQuery(traversal, function traversalFn (arg1, arg2, userOptions: TraversalOptions = {}) { if (_.isObject(arg1) && !_.isFunction(arg1)) { - options = arg1 + userOptions = arg1 + arg1 = undefined } if (_.isObject(arg2) && !_.isFunction(arg2)) { - options = arg2 + userOptions = arg2 + arg2 = undefined } - const userOptions = options || {} + // Omit any null or undefined arguments + const selector = _.filter([arg1, arg2], (a) => (a != null && !_.isFunction(a) && !_.isObject(a))).join(', ') - options = _.defaults({}, userOptions, { log: true }) + const log = userOptions.log !== false && Cypress.log({ + message: selector, + timeout: userOptions.timeout, + consoleProps: () => ({}), + }) - const getSelector = () => { - let args = _.chain([arg1, arg2]).reject(_.isFunction).reject(_.isObject).value() + this.set('timeout', userOptions.timeout) - args = _.without(args, null, undefined) + let sub - return args.join(', ') - } - - const consoleProps: EachConsoleProps = { - Selector: getSelector(), - 'Applied To': $dom.getElements(subject), - } - - if (options.log !== false) { - options._log = Cypress.log({ - message: getSelector(), - timeout: options.timeout, - consoleProps () { - return consoleProps - }, - }) - } - - const getEl = () => { - const includeShadowDom = resolveShadowDomInclusion(Cypress, userOptions.includeShadowDom) - const optInShadowTraversal = optInShadowTraversals[traversal] - const autoShadowTraversal = autoShadowTraversals[traversal] - - if (includeShadowDom && optInShadowTraversal) { - // if we're told explicitly to ignore shadow boundaries, - // use the replacement traversal function if one exists - // so we can cross boundaries - return optInShadowTraversal(cy, subject, arg1, arg2) - } - - if (autoShadowTraversal && $dom.isWithinShadowRoot(subject[0])) { - // if we detect the element is within a shadow root and we're using - // .closest() or .parents(), automatically cross shadow boundaries - return autoShadowTraversal(cy, subject, arg1, arg2) + this.set('onFail', (err) => { + switch (err.type) { + case 'existence': + err.message += ` Queried from element: ${$dom.stringify(sub, 'short')}` + break + default: + break } + }) - return subject[traversal].call(subject, arg1, arg2) - } - - const setEl = ($el) => { - if (options.log === false) { - return - } + const includeShadowDom = resolveShadowDomInclusion(Cypress, userOptions.includeShadowDom) - consoleProps.Yielded = $dom.getElements($el) - consoleProps.Elements = $el?.length + return (subject) => { + cy.ensureSubjectByType(subject, ['element', 'document'], this) - return options._log.set({ $el }) - } - - const getElements = () => { - let $el + const $el = getEl(traversal, includeShadowDom, subject, arg1, arg2) - try { - $el = getEl() + // normalize the selector since jQuery won't have it + // or completely borks it + $el.selector = selector - // normalize the selector since jQuery won't have it - // or completely borks it - $el.selector = getSelector() - } catch (e: any) { - e.onFail = () => { - return options._log.error(e) - } + sub = subject - throw e - } - - setEl($el) - - return cy.verifyUpcomingAssertions($el, options, { - onRetry: getElements, - onFail (err) { - if (err.type === 'existence') { - const node = $dom.stringify(subject, 'short') - - err.message += ` Queried from element: ${node}` + log && log.set({ + $el, + consoleProps: () => { + return { + Selector: selector, + 'Applied To': $dom.getElements(subject), + Yielded: $dom.getElements($el), + Elements: $el?.length, } }, }) - } - return getElements() + return $el + } }) }) } diff --git a/packages/driver/src/cy/commands/window.ts b/packages/driver/src/cy/commands/window.ts index a187a4c3ec7f..0b76f74c6c33 100644 --- a/packages/driver/src/cy/commands/window.ts +++ b/packages/driver/src/cy/commands/window.ts @@ -103,6 +103,7 @@ export default (Commands, Cypress, cy, state) => { } Commands.addAll({ + // TODO: query title (userOptions: Partial = {}) { const options: InternalTitleOptions = _.defaults({}, userOptions, { log: true }) @@ -123,6 +124,7 @@ export default (Commands, Cypress, cy, state) => { return resolveTitle() }, + // TODO: query window (userOptions: Partial = {}) { const options: InternalWindowOptions = _.defaults({}, userOptions, { log: true }) @@ -163,6 +165,7 @@ export default (Commands, Cypress, cy, state) => { return verifyAssertions() }, + // TODO: query document (userOptions: Partial = {}) { const options: InternalDocumentOptions = _.defaults({}, userOptions, { log: true }) diff --git a/packages/driver/src/cy/ensures.ts b/packages/driver/src/cy/ensures.ts index 1760005d14fc..80be1b5e8f9e 100644 --- a/packages/driver/src/cy/ensures.ts +++ b/packages/driver/src/cy/ensures.ts @@ -76,13 +76,21 @@ export const create = (state: StateFunc, expect: $Cy['expect']) => { } } + function wordJoin (array) { + const copy = [...array] + const last = copy.pop() + + return `${copy.join(', ')} or ${last}` + } + // every validation failed and we had more than one validation if (errors.length === types.length) { err = errors[0] if (types.length > 1) { // append a nice error message telling the user this - const errProps = $errUtils.appendErrMsg(err, `All ${types.length} subject validations failed on this subject.`) + const msg = err.message.replace(/(failed because it requires .*)\./, `${wordJoin(['$1', ...types.slice(1)]) }.`) + const errProps = $errUtils.modifyErrMsg(err, '', () => msg) $errUtils.mergeErrProps(err, errProps) } @@ -214,14 +222,26 @@ export const create = (state: StateFunc, expect: $Cy['expect']) => { const ensureElement = (subject, name, onFail?) => { if (!$dom.isElement(subject)) { - const prev = state('current').get('prev') + const current = state('current') + + if ($dom.isJquery(subject) && subject.length === 0) { + const subjectChain = (cy.state('subjects') || {})[current.get('chainerId')] + + $errUtils.throwErrByPath('subject.not_element_empty_subject', { + onFail, + args: { + name: current.get('name'), + subjectChain, + }, + }) + } $errUtils.throwErrByPath('subject.not_element', { onFail, args: { name, subject: $utils.stringifyActual(subject), - previous: prev.get('name'), + previous: current.get('prev').get('name'), }, }) } diff --git a/packages/driver/src/cypress/command_queue.ts b/packages/driver/src/cypress/command_queue.ts index 714f8f9da461..dc850af3dd90 100644 --- a/packages/driver/src/cypress/command_queue.ts +++ b/packages/driver/src/cypress/command_queue.ts @@ -315,8 +315,8 @@ export class CommandQueue extends Queue<$Command> { // For queries, the "subject" here is the query's return value, which is a function which // accepts a subject and returns a subject, and can be re-invoked at any time. - // We add the command name here only to make debugging easier; It should not be relied on functionally. subject.commandName = name + subject.args = command.get('args') // Even though we've snapshotted, we only end the logs a query's logs if we're at the end of a query // chain - either there is no next command (end of a test), the next command is an action, or the next diff --git a/packages/driver/src/cypress/commands.ts b/packages/driver/src/cypress/commands.ts index 1b057554d133..395fbd8776ef 100644 --- a/packages/driver/src/cypress/commands.ts +++ b/packages/driver/src/cypress/commands.ts @@ -45,6 +45,7 @@ export default { create: (Cypress, cy, state, config) => { const reservedCommandNames = new Set(Object.keys(cy)) const commands = {} + const queries = {} // we track built in commands to ensure users cannot // add custom commands with the same name @@ -124,6 +125,10 @@ export default { overwrite (name, fn) { const original = commands[name] + if (queries[name]) { + internalError('miscellaneous.invalid_overwrite_query_with_command', name) + } + if (!original) { internalError('miscellaneous.invalid_overwrite', name) } @@ -165,6 +170,8 @@ export default { builtInCommandNames[name] = true } + queries[name] = fn + cy._addQuery({ name, fn }) }, diff --git a/packages/driver/src/cypress/error_messages.ts b/packages/driver/src/cypress/error_messages.ts index f8c521e6625f..05b0c3479ed4 100644 --- a/packages/driver/src/cypress/error_messages.ts +++ b/packages/driver/src/cypress/error_messages.ts @@ -2,6 +2,7 @@ import _ from 'lodash' import { stripIndent } from 'common-tags' import capitalize from 'underscore.string/capitalize' import $stackUtils from './stack_utils' +import $utils from './utils' const divider = (num, char) => { return Array(num).join(char) @@ -53,6 +54,16 @@ const cmd = (command, args = '') => { return `\`${prefix}${command}(${args})\`` } +const queryFnToString = (queryFn) => `.${queryFn.commandName}(${queryFn.args.map($utils.stringifyActual).join(', ')})` + +const subjectChainToString = (subjectChain) => { + const [initial, ...queryFns] = subjectChain + + const prefix = initial == null ? 'cy' : `${$utils.stringifyActual(initial)} -> ` + + return prefix + queryFns.map(queryFnToString).join('') +} + const getScreenshotDocsPath = (cmd) => { if (cmd === 'Cypress.Screenshot.defaults') { return 'screenshot-api' @@ -873,6 +884,10 @@ export default { message: 'Cannot overwite command for: `{{name}}`. An existing command does not exist by that name.', docsUrl: 'https://on.cypress.io/api', }, + invalid_overwrite_query_with_command: { + message: 'Cannot overwite the `{{name}}` query with a command. Use `Commands._overwriteQuery()` instead.', + docsUrl: 'https://on.cypress.io/api', + }, invoking_child_without_parent (obj) { return stripIndent`\ Oops, it looks like you are trying to call a child command before running a parent command. @@ -1921,6 +1936,14 @@ export default { > ${cmd(obj.previous)}` }, + not_element_empty_subject (obj) { + return stripIndent`\ + ${cmd(obj.name)} failed because it requires a DOM element. + + No elements in the current DOM matched your query: + + > ${subjectChainToString(obj.subjectChain)}` + }, state_subject_deprecated: { message: `${cmd('state', '\'subject\'')} has been deprecated and will be removed in a future release. Consider migrating to ${cmd('currentSubject')} instead.`, }, From 6e694965d8c2fecdebaaf32561e249e2e8787609 Mon Sep 17 00:00:00 2001 From: BlueWinds Date: Thu, 6 Oct 2022 14:36:46 -0700 Subject: [PATCH 20/54] Update connectors, location, focused and window commands to queries --- .../cypress/e2e/commands/assertions.cy.js | 22 +- .../cypress/e2e/commands/connectors.cy.js | 148 ++----- .../cypress/e2e/commands/location.cy.js | 4 +- .../e2e/commands/querying/focused.cy.js | 2 +- packages/driver/src/cy/assertions.ts | 2 +- packages/driver/src/cy/commands/connectors.ts | 386 +++++------------- packages/driver/src/cy/commands/location.ts | 139 ++----- packages/driver/src/cy/commands/misc.ts | 2 +- .../src/cy/commands/querying/focused.ts | 84 +--- packages/driver/src/cy/commands/traversals.ts | 2 - packages/driver/src/cy/commands/window.ts | 139 ++----- packages/driver/src/cypress/command_queue.ts | 1 + 12 files changed, 231 insertions(+), 700 deletions(-) diff --git a/packages/driver/cypress/e2e/commands/assertions.cy.js b/packages/driver/cypress/e2e/commands/assertions.cy.js index 58cc39936bdd..09ce6d4a9a3a 100644 --- a/packages/driver/cypress/e2e/commands/assertions.cy.js +++ b/packages/driver/cypress/e2e/commands/assertions.cy.js @@ -61,8 +61,7 @@ describe('src/cy/commands/assertions', () => { .then((obj) => { expect(testCommands()).to.eql([ { name: 'visit', snapshots: 1, retries: 0 }, - { name: 'noop', snapshots: 0, retries: 0 }, - { name: 'should', snapshots: 1, retries: 0 }, + { name: 'noop', snapshots: 1, retries: 0 }, { name: 'then', snapshots: 0, retries: 0 }, ]) }) @@ -134,21 +133,6 @@ describe('src/cy/commands/assertions', () => { cy.noop(obj).its('requestJSON').should('have.property', 'teamIds').should('deep.eq', [2]) }) - // TODO: make cy.then retry - // https://github.com/cypress-io/cypress/issues/627 - it.skip('outer assertions retry on cy.then', () => { - const obj = { foo: 'bar' } - - cy.wrap(obj).then(() => { - setTimeout(() => { - obj.foo = 'baz' - } - , 1000) - - return obj - }).should('deep.eq', { foo: 'baz' }) - }) - it('does it retry when wrapped', () => { const obj = { foo: 'bar' } @@ -548,7 +532,7 @@ describe('src/cy/commands/assertions', () => { }, () => { it('should not be true', (done) => { cy.on('fail', (err) => { - expect(err.message).to.eq('expected false to be true') + expect(err.message).to.eq('Timed out retrying after 50ms: expected false to be true') done() }) @@ -828,7 +812,7 @@ describe('src/cy/commands/assertions', () => { return null }) - it('does not output should logs on failures', function (done) { + it('does not output should logs on failures', { defaultCommandTimeout: 50 }, function (done) { cy.on('fail', () => { const { length } = this.logs diff --git a/packages/driver/cypress/e2e/commands/connectors.cy.js b/packages/driver/cypress/e2e/commands/connectors.cy.js index 9d04419a777f..3e6e55bb568d 100644 --- a/packages/driver/cypress/e2e/commands/connectors.cy.js +++ b/packages/driver/cypress/e2e/commands/connectors.cy.js @@ -628,8 +628,8 @@ describe('src/cy/commands/connectors', () => { } cy.on('fail', (err) => { - expect(err.message).to.include('Timed out retrying after 100ms: `cy.invoke()` errored because the property: `bar` returned a `string` value instead of a function. `cy.invoke()` can only be used on properties that return callable functions.') - expect(err.message).to.include('`cy.invoke()` waited for the specified property `bar` to return a function, but it never did.') + expect(err.message).to.include('Timed out retrying after 100ms: `cy.invoke()` errored because the property: `foo.bar` returned a `string` value instead of a function. `cy.invoke()` can only be used on properties that return callable functions.') + expect(err.message).to.include('`cy.invoke()` waited for the specified property `foo.bar` to return a function, but it never did.') expect(err.message).to.include('If you want to assert on the property\'s value, then switch to use `cy.its()` and add an assertion such as:') expect(err.message).to.include('`cy.wrap({ foo: \'bar\' }).its(\'foo\').should(\'eq\', \'bar\')`') expect(err.docsUrl).to.eq('https://on.cypress.io/invoke') @@ -642,7 +642,7 @@ describe('src/cy/commands/connectors', () => { }) }) - describe('accepts a options argument', () => { + describe('accepts an options argument', () => { it('changes subject to function invocation', () => { cy.noop({ foo () { return 'foo' @@ -729,16 +729,15 @@ describe('src/cy/commands/connectors', () => { cy.wrap({ foo () { return 'foo' - } }).invoke(() => { - return {} - }) + } }) + .invoke(() => {}) }) it('throws when first parameter is neither of type object nor of type string nor of type number', function (done) { cy.on('fail', (err) => { const { lastLog } = this - expect(err.message).to.include('`cy.invoke()` only accepts a string or a number as the functionName argument.') + expect(err.message).to.include('`cy.invoke()` only accepts an object as the options argument.') expect(lastLog.get('error').message).to.include(err.message) done() @@ -903,30 +902,19 @@ describe('src/cy/commands/connectors', () => { Command: 'invoke', Function: '.bar()', Subject: this.obj, + 'With Arguments': [], Yielded: 'bar', }) }) }) - it('#consoleProps as a function property with args', function () { - cy.noop(this.obj).invoke('sum', 1, 2, 3).then(function () { - expect(this.lastLog.invoke('consoleProps')).to.deep.eq({ - Command: 'invoke', - Function: '.sum(1, 2, 3)', - 'With Arguments': [1, 2, 3], - Subject: this.obj, - Yielded: 6, - }) - }) - }) - - it('#consoleProps as a function reduced property with args', function () { + it('#consoleProps as a deep function property with args', function () { cy.noop(this.obj).invoke('math.sum', 1, 2, 3).then(function () { expect(this.lastLog.invoke('consoleProps')).to.deep.eq({ Command: 'invoke', Function: '.math.sum(1, 2, 3)', 'With Arguments': [1, 2, 3], - Subject: this.obj['math'], + Subject: this.obj, Yielded: 6, }) }) @@ -939,8 +927,9 @@ describe('src/cy/commands/connectors', () => { expect(consoleProps).to.deep.eq({ Command: 'invoke', Function: '.hide()', - Subject: $btn.get(0), - Yielded: $btn.get(0), + Subject: $btn, + 'With Arguments': [], + Yielded: $btn, }) }) }) @@ -1058,7 +1047,7 @@ describe('src/cy/commands/connectors', () => { cy.on('fail', (err) => { const { lastLog } = this - expect(err.message).to.include('Timed out retrying after 100ms: `cy.invoke()` errored because the property: `baz` does not exist on your subject.') + expect(err.message).to.include('Timed out retrying after 100ms: `cy.invoke()` errored because the property: `foo.bar.baz.fizz` does not exist on your subject.') expect(lastLog.get('error').message).to.include(err.message) expect(err.docsUrl).to.eq('https://on.cypress.io/invoke') @@ -1081,10 +1070,8 @@ describe('src/cy/commands/connectors', () => { this.remoteWindow = cy.state('window') }) - it('proxies to #invokeFn', () => { - const fn = () => { - return 'bar' - } + it('returns function properties', () => { + const fn = () => 'bar' cy.wrap({ foo: fn }).its('foo').should('eq', fn) }) @@ -1244,7 +1231,8 @@ describe('src/cy/commands/connectors', () => { cy.stub() .onCall(0).returns(undefined) .onCall(1).returns(undefined) - .onCall(2).returns(true), + .onCall(2).returns(undefined) + .onCall(3).returns(true), ) cy.wrap(obj).its('foo').should('eq', true) @@ -1268,15 +1256,6 @@ describe('src/cy/commands/connectors', () => { cy.wrap({}).its('foo').should('not.exist') cy.wrap({}).its('foo').should('be.undefined') cy.wrap({}).its('foo').should('not.be.ok') - - // TODO: should these really pass here? - // isn't this the same situation as: cy.should('not.have.class', '...') - // - // when we use the 'eq' and 'not.eq' chainer aren't we effectively - // saying that it must *have* a value as opposed to the property not - // existing at all? - // - // does a tree falling in the forest really make a sound? cy.wrap({}).its('foo').should('eq', undefined) cy.wrap({}).its('foo').should('not.eq', 'bar') }) @@ -1309,71 +1288,6 @@ describe('src/cy/commands/connectors', () => { cy.wrap(obj).its('foo').should('eq', undefined) }) - describe('accepts a options argument and works as without options argument', () => { - it('proxies to #invokeFn', () => { - const fn = () => { - return 'bar' - } - - cy.wrap({ foo: fn }).its('foo', { log: false }).should('eq', fn) - }) - - it('does not invoke a function and uses as a property', () => { - const fn = () => { - return 'fn' - } - - fn.bar = 'bar' - - cy.wrap(fn).its('bar', { log: false }).should('eq', 'bar') - }) - - it('works with numerical indexes', () => { - cy.wrap(['foo', 'bar']).its(1, {}).should('eq', 'bar') - }) - - describe('.log', () => { - beforeEach(function () { - this.obj = { - foo: 'foo bar baz', - num: 123, - } - - cy.on('log:added', (attrs, log) => { - this.lastLog = log - }) - - return null - }) - - it('logs obj as a property', function () { - cy.noop(this.obj).its('foo', { log: true }).then(function () { - const obj = { - name: 'its', - message: '.foo', - } - - const { lastLog } = this - - _.each(obj, (value, key) => { - expect(lastLog.get(key)).to.deep.eq(value) - }) - }) - }) - - it('#consoleProps as a regular property', function () { - cy.noop(this.obj).its('num', { log: true }).then(function () { - expect(this.lastLog.invoke('consoleProps')).to.deep.eq({ - Command: 'its', - Property: '.num', - Subject: this.obj, - Yielded: 123, - }) - }) - }) - }) - }) - describe('.log', () => { beforeEach(function () { this.obj = { @@ -1489,9 +1403,7 @@ describe('src/cy/commands/connectors', () => { this.logs = [] cy.on('log:added', (attrs, log) => { - if (attrs.name === 'its') { - this.lastLog = log - } + this.lastLog = log this.logs?.push(log) }) @@ -1602,15 +1514,13 @@ describe('src/cy/commands/connectors', () => { }, } - obj.foo.bar.baz = () => { - return 'baz' - } + obj.foo.bar.baz = () => 'baz' cy.on('fail', (err) => { - const { lastLog } = this + const [, itsLog, shouldLog] = this.logs - expect(lastLog.get('error').message).to.include(err.message) - expect(lastLog.invoke('consoleProps').Property).to.eq('.foo.bar.baz') + expect(shouldLog.get('error').message).to.include(err.message) + expect(itsLog.invoke('consoleProps').Property).to.eq('.foo.bar.baz') done() }) @@ -1642,7 +1552,7 @@ describe('src/cy/commands/connectors', () => { cy.on('fail', (err) => { const { lastLog } = this - expect(err.message).to.include('Timed out retrying after 100ms: `cy.its()` errored because the property: `baz` does not exist on your subject.') + expect(err.message).to.include('Timed out retrying after 100ms: `cy.its()` errored because the property: `foo.bar.baz.fizz` does not exist on your subject.') expect(err.docsUrl).to.eq('https://on.cypress.io/its') expect(lastLog.get('error').message).to.include(err.message) expect(lastLog.get('error').message).to.include(err.message) @@ -1659,19 +1569,19 @@ describe('src/cy/commands/connectors', () => { cy.wrap(obj).its('foo.bar.baz.fizz') }); - [null, undefined].forEach((val) => { + [null/*, undefined*/].forEach((val) => { it(`throws on traversed '${val}' subject`, (done) => { cy.on('fail', (err) => { - expect(err.message).to.include(`Timed out retrying after 100ms: \`cy.its()\` errored because the property: \`a\` returned a \`${val}\` value. The property: \`b\` does not exist on a \`${val}\` value.`) - expect(err.message).to.include('`cy.its()` waited for the specified property `b` to become accessible, but it never did.') - expect(err.message).to.include('If you do not expect the property `b` to exist, then add an assertion such as:') - expect(err.message).to.include(`\`cy.wrap({ foo: ${val} }).its('foo.baz').should('not.exist')\``) + expect(err.message).to.include(`Timed out retrying after 100ms: \`cy.its()\` errored because the property: \`a.b\` returned a \`${val}\` value.`) + expect(err.message).to.include('`cy.its()` waited for the specified property `a.b` to become accessible, but it never did.') + expect(err.message).to.include(`If you expect the property \`a.b\` to be \`${val}\`, then add an assertion such as:`) + expect(err.message).to.include(`\`cy.wrap({ foo: ${val} }).its('foo').should('be.null')\``) expect(err.docsUrl).to.eq('https://on.cypress.io/its') done() }) - cy.wrap({ a: val }).its('a.b.c') + cy.wrap({ a: { b: val } }).its('a.b') }) it(`throws on initial '${val}' subject`, (done) => { diff --git a/packages/driver/cypress/e2e/commands/location.cy.js b/packages/driver/cypress/e2e/commands/location.cy.js index a3885396240c..98b401f9f18f 100644 --- a/packages/driver/cypress/e2e/commands/location.cy.js +++ b/packages/driver/cypress/e2e/commands/location.cy.js @@ -517,9 +517,7 @@ describe('src/cy/commands/location', () => { const { lastLog } = this - _.each(obj, (value, key) => { - expect(lastLog.get(key)).to.deep.eq(value) - }) + expect(_.pick(lastLog.attributes, ['name', 'message'])).to.eql(obj) }) }) diff --git a/packages/driver/cypress/e2e/commands/querying/focused.cy.js b/packages/driver/cypress/e2e/commands/querying/focused.cy.js index ff7873099e39..1619091130c7 100644 --- a/packages/driver/cypress/e2e/commands/querying/focused.cy.js +++ b/packages/driver/cypress/e2e/commands/querying/focused.cy.js @@ -95,7 +95,7 @@ describe('src/cy/commands/querying', () => { cy.get('body').focused().then(function () { const { lastLog } = this - expect(lastLog.get('type')).to.eq('parent') + expect(lastLog.get('type')).not.to.eq('child') }) }) diff --git a/packages/driver/src/cy/assertions.ts b/packages/driver/src/cy/assertions.ts index 90ff0aac8e23..00325374c6fb 100644 --- a/packages/driver/src/cy/assertions.ts +++ b/packages/driver/src/cy/assertions.ts @@ -304,7 +304,7 @@ export const create = (Cypress: ICypress, cy: $Cy) => { // ensure the error is about existence not about // the downstream assertion. try { - ensureExistence() + callbacks.ensureExistenceFor === 'dom' && ensureExistence() } catch (e2) { err = e2 } diff --git a/packages/driver/src/cy/commands/connectors.ts b/packages/driver/src/cy/commands/connectors.ts index 85f41df0b551..abf1467b0cd2 100644 --- a/packages/driver/src/cy/commands/connectors.ts +++ b/packages/driver/src/cy/commands/connectors.ts @@ -3,7 +3,7 @@ import Promise from 'bluebird' import $dom from '../../dom' import $utils from '../../cypress/utils' -import $errUtils, { CypressError } from '../../cypress/error_utils' +import $errUtils from '../../cypress/error_utils' const returnFalseIfThenable = (key, ...args): boolean => { if ((key === 'then') && _.isFunction(args[0]) && _.isFunction(args[1])) { @@ -24,18 +24,6 @@ const returnFalseIfThenable = (key, ...args): boolean => { return true } -const primitiveToObject = (memo) => { - if (_.isString(memo)) { - return new String(memo) - } - - if (_.isNumber(memo)) { - return new Number(memo) - } - - return memo -} - const getFormattedElement = ($el) => { if ($dom.isElement($el)) { return $dom.getElements($el) @@ -44,6 +32,16 @@ const getFormattedElement = ($el) => { return $el } +const upcomingAssertion = (next) => { + if (!next || next.get('type') !== 'assertion') { + return false + } + + const arg = next.get('args')[0] + + return arg === 'not.exist' || arg === 'be.undefined' || arg === 'not.be.ok' || arg === 'be.null' || arg === 'eq' || arg === 'not.eq' +} + export default function (Commands, Cypress, cy, state) { // thens can return more "thenables" which are not resolved // until they're 'really' resolved, so naturally this API @@ -142,303 +140,153 @@ export default function (Commands, Cypress, cy, state) { .finally(cleanup) } - const invokeItsFn = (subject, str, userOptions, ...args) => { - return invokeBaseFn(userOptions || { log: true }, subject, str, ...args) - } - - const invokeFn = (subject, userOptionsOrStr, ...args) => { - const userOptionsPassed = _.isObject(userOptionsOrStr) && !_.isFunction(userOptionsOrStr) - let userOptions: Record | null = null - let str = null + // to allow the falsy value 0 to be used + const isPath = (str) => (!!str || str === 0) - if (!userOptionsPassed) { - str = userOptionsOrStr - userOptions = { log: true } - } else { - userOptions = userOptionsOrStr - if (args.length > 0) { - str = args[0] - args = args.slice(1) - } - } + Commands._addQuery('its', function its (path, options: Partial = {}, ...args) { + // If we're being used in .invoke(), we us it. For any other current command (.its itself or a custom command), + // we fall back to the .its() error messages. + const cmd = this.get('name') === 'invoke' ? 'invoke' : 'its' - return invokeBaseFn(userOptions, subject, str, ...args) - } - - const invokeBaseFn = (userOptions, subject, str, ...args) => { - const name = state('current').get('name') + cy.ensureChildCommand(this, arguments) - const isCmdIts = name === 'its' - const isCmdInvoke = name === 'invoke' - - const getMessage = () => { - if (isCmdIts) { - return `.${str}` - } - - return `.${str}(${$utils.stringify(args)})` + if (args.length) { + $errUtils.throwErrByPath('invoke_its.invalid_num_of_args', { args: { cmd } }) } - // to allow the falsy value 0 to be used - const isProp = (str) => { - return !!str || (str === 0) - } - - const message = getMessage() - - let traversalErr: CypressError | null = null - - // copy userOptions because _log is added below. - const options = _.extend({}, userOptions) - - if (options.log) { - options._log = Cypress.log({ - message, - $el: $dom.isElement(subject) ? subject : null, - timeout: options.timeout, - consoleProps () { - return { Subject: subject } - }, - }) + if (!_.isObject(options)) { + $errUtils.throwErrByPath('invoke_its.invalid_options_arg', { args: { cmd } }) } - // check for false positive (negative?) with 0 given as index - if (!isProp(str)) { + if (!isPath(path)) { $errUtils.throwErrByPath('invoke_its.null_or_undefined_property_name', { - onFail: options._log, - args: { cmd: name, identifier: isCmdIts ? 'property' : 'function' }, + args: { cmd, identifier: 'property' }, }) } - if (!_.isString(str) && !_.isNumber(str)) { + if (!_.isString(path) && !_.isNumber(path)) { $errUtils.throwErrByPath('invoke_its.invalid_prop_name_arg', { - onFail: options._log, - args: { cmd: name, identifier: isCmdIts ? 'property' : 'function' }, - }) - } - - if (!_.isObject(userOptions) || _.isFunction(userOptions)) { - $errUtils.throwErrByPath('invoke_its.invalid_options_arg', { - onFail: options._log, - args: { cmd: name }, + args: { cmd, identifier: 'property' }, }) } - if (isCmdIts && args && args.length > 0) { - $errUtils.throwErrByPath('invoke_its.invalid_num_of_args', { - onFail: options._log, - args: { cmd: name }, - }) - } + const log = options.log !== false && Cypress.log({ + message: `.${path}`, + timeout: options.timeout, + }) - const propertyNotOnSubjectErr = (prop) => { - return $errUtils.cypressErrByPath('invoke_its.nonexistent_prop', { - args: { - prop, - cmd: name, - }, - }) - } + this.set('_log', log) + this.set('timeout', options.timeout) + this.set('ensureExistenceFor', 'subject') - const propertyValueNullOrUndefinedErr = (prop, value) => { - const errMessagePath = isCmdIts ? 'its' : 'invoke' + return (subject) => { + if (subject == null) { + $errUtils.throwErrByPath(`${cmd}.subject_null_or_undefined`, { + args: { prop: path, cmd, value: subject }, + }) + } - return $errUtils.cypressErrByPath(`${errMessagePath}.null_or_undefined_prop_value`, { - args: { - prop, - value, - }, - cmd: name, - }) - } + subject = cy.getRemotejQueryInstance(subject) || subject - const subjectNullOrUndefinedErr = (prop, value) => { - const errMessagePath = isCmdIts ? 'its' : 'invoke' + const value = _.get(subject, path) - return $errUtils.cypressErrByPath(`${errMessagePath}.subject_null_or_undefined`, { - args: { - prop, - cmd: name, - value, - }, - }) - } + log && log.set({ + $el: $dom.isElement(subject) ? subject : null, + consoleProps () { + const obj = { + Property: `.${path}`, + Subject: subject, + Yielded: getFormattedElement(value), + } - const propertyNotOnPreviousNullOrUndefinedValueErr = (prop, value, previousProp) => { - return $errUtils.cypressErrByPath('invoke_its.previous_prop_null_or_undefined', { - args: { - prop, - value, - previousProp, - cmd: name, + return obj }, }) - } - - const traverseObjectAtPath = (acc, pathsArray, updatedSubject, index = 0) => { - // traverse at this depth - const prop = pathsArray[index] - const previousProp = pathsArray[index - 1] - const valIsNullOrUndefined = _.isNil(acc) - - // if we're attempting to tunnel into - // a null or undefined object... - if (isProp(prop) && valIsNullOrUndefined) { - if (index === 0) { - // give an error stating the current subject is nil - traversalErr = subjectNullOrUndefinedErr(prop, acc) - } else { - // else refer to the previous property so users know which prop - // caused us to hit this dead end - traversalErr = propertyNotOnPreviousNullOrUndefinedValueErr(prop, acc, previousProp) - } - - return { prop: acc, updatedSubject } - } - // if we have no more properties to traverse - if (!isProp(prop)) { - if (valIsNullOrUndefined) { - // set traversal error that the final value is null or undefined - traversalErr = propertyValueNullOrUndefinedErr(previousProp, acc) + if (value == null && !upcomingAssertion(this.get('next'))) { + if (!_.has(subject, path)) { + $errUtils.throwErrByPath('invoke_its.nonexistent_prop', { args: { cmd, prop: path, value } }) } - // finally return the reduced traversed accumulator here - return { prop: acc, updatedSubject } + $errUtils.throwErrByPath(`${cmd}.null_or_undefined_prop_value`, { args: { prop: path, value } }) } - // attempt to lookup this property on the acc - // if our property does not exist then allow - // undefined to pass through but set the traversalErr - // since if we don't have any assertions we want to - // provide a very specific error message and not the - // generic existence one - if (!(prop in primitiveToObject(acc))) { - traversalErr = propertyNotOnSubjectErr(prop) - - return { prop: undefined, updatedSubject } - } - - // if we succeeded then continue to traverse - return traverseObjectAtPath(acc[prop], pathsArray, acc, index + 1) + return value } + }) - const getSettledValue = (value, subject, propAtLastPath) => { - if (isCmdIts) { - return value - } + Commands._addQuery('invoke', function invoke (optionsOrPath, argOrOptions, ...args) { + let options + let path - if (_.isFunction(value)) { - return value.apply(subject, args) + if (_.isString(optionsOrPath) || _.isNumber(optionsOrPath)) { + options = {} + path = optionsOrPath + if (arguments.length > 1) { + args.unshift(argOrOptions) } + } else { + options = optionsOrPath + path = argOrOptions + } - // TODO: this logic should likely be part of - // traverseObjectAtPath(...) rather be further - // away from the handling of traversals. this - // causes us to need to separately handle - // the 'propAtLastPath' argument since we're - // outside of the reduced accumulator. - - // if we're not a function and we have a traversal - // error then throw it now - since that provide a - // more specific error regarding non-existant - // properties or null or undefined values - if (traversalErr) { - throw traversalErr + if (!_.isString(path) && !_.isNumber(path)) { + if (path == null && _.isObject(options) && !_.isFunction(options)) { + $errUtils.throwErrByPath('invoke_its.null_or_undefined_property_name', { args: { + cmd: 'invoke', + identifier: 'function', + } }) } - // else throw that prop isn't a function - $errUtils.throwErrByPath('invoke.prop_not_a_function', { - onFail: options._log, - args: { - prop: propAtLastPath, - type: $utils.stringifyFriendlyTypeof(value), - }, - }) + $errUtils.throwErrByPath('invoke_its.invalid_prop_name_arg', { args: { + cmd: 'invoke', + identifier: 'function', + } }) } - const getValue = () => { - // reset this on each go around so previous errors - // don't leak into new failures or upcoming assertion errors - traversalErr = null - - const remoteSubject = cy.getRemotejQueryInstance(subject) - - let actualSubject = remoteSubject || subject + const itsFn = cy.now('its', path, options) - let paths = _.isString(str) ? str.split('.') : [str] + // .its() has an implicit assertions that the return value shouldn't be null, but + // .invoke() has no such requirement. Removing ensureExistenceFor resests implicit + // assertion that .its() added + this.set('ensureExistenceFor', null) - const { prop, updatedSubject } = traverseObjectAtPath(actualSubject, paths, actualSubject) + const log = this.get('_log') - actualSubject = updatedSubject + log && log.set('message', `.${path}()`) - const value = getSettledValue(prop, actualSubject, _.last(paths)) + return (subject) => { + subject = cy.getRemotejQueryInstance(subject) || subject - if (options._log) { - options._log.set({ - consoleProps () { - const obj = {} + // We use its for its validation, even though we ignore the returned value. + itsFn(subject) - if (isCmdInvoke) { - obj['Function'] = message - if (args.length) { - obj['With Arguments'] = args - } - } else { - obj['Property'] = message - } + const pathParts = path.toString().split('.') + const last = pathParts.pop() + const parent = pathParts.length === 0 ? subject : _.get(subject, pathParts) - _.extend(obj, { - Subject: getFormattedElement(actualSubject), - Yielded: getFormattedElement(value), - }) - - return obj - }, - }) + if (!_.isFunction(parent[last])) { + $errUtils.throwErrByPath('invoke.prop_not_a_function', { args: { + prop: path, + type: $utils.stringifyFriendlyTypeof(parent[last]), + } }) } - return value - } - - // by default we want to only add the default assertion - // of ensuring existence for cy.its() not cy.invoke() because - // invoking a function can legitimately return null or undefined - const ensureExistenceFor = isCmdIts ? 'subject' : false - - // wrap retrying into its own - // separate function - const retryValue = () => { - return Promise - .try(getValue) - .catch((err) => { - options.error = err + let value = parent[last](...args) - return cy.retry(retryValue, options) + log && log.set('consoleProps', () => { + return { + Command: 'invoke', + Function: `.${path}(${$utils.stringify(args)})`, + Subject: subject, + 'With Arguments': args, + Yielded: value, + } }) - } - const resolveValue = () => { - return Promise - .try(retryValue) - .then((value) => { - return cy.verifyUpcomingAssertions(value, options, { - ensureExistenceFor, - onRetry: resolveValue, - onFail () { - // if we failed our upcoming assertions and also - // exited early out of getting the value of our - // subject then reset the error to this one - if (traversalErr) { - options.error = traversalErr - } - }, - }) - }) + return value } - - return resolveValue() - } + }) Commands.addAll({ prevSubject: true }, { spread (subject, options, fn) { @@ -521,22 +369,4 @@ export default function (Commands, Cypress, cy, state) { return thenFn.apply(this, [subject, userOptions, fn]) }, }) - - Commands.addAll({ prevSubject: true }, { - // making this a dual command due to child commands - // automatically returning their subject when their - // return values are undefined. prob should rethink - // this and investigate why that is the default behavior - // of child commands - - // TODO: query - invoke (subject, optionsOrStr, ...args) { - return invokeFn.apply(this, [subject, optionsOrStr, ...args]) - }, - - // TODO: query - its (subject, str, options, ...args) { - return invokeItsFn.apply(this, [subject, str, options, ...args]) - }, - }) } diff --git a/packages/driver/src/cy/commands/location.ts b/packages/driver/src/cy/commands/location.ts index 6c590753845f..f9a24381f6bc 100644 --- a/packages/driver/src/cy/commands/location.ts +++ b/packages/driver/src/cy/commands/location.ts @@ -1,124 +1,55 @@ import _ from 'lodash' -import Promise from 'bluebird' import $errUtils from '../../cypress/error_utils' -import type { Log } from '../../cypress/log' -const { throwErrByPath } = $errUtils - -interface InternalUrlOptions extends Partial { - _log?: Log -} - -interface InternalHashOptions extends Partial { - _log?: Log -} export default (Commands, Cypress, cy) => { - Commands.addAll({ - // TODO: query - url (userOptions: Partial = {}) { - const options: InternalUrlOptions = _.defaults({}, userOptions, { log: true }) - - if (options.log !== false) { - options._log = Cypress.log({ - message: '', - timeout: options.timeout, - }) - } + Commands._addQuery('url', function url (options: Partial = {}) { + this.set('timeout', options.timeout) - const getHref = () => { - return cy.getRemoteLocation('href') - } - - const resolveHref = () => { - return Promise.try(getHref).then((href) => { - if (options.decode) { - href = decodeURI(href) - } - - return cy.verifyUpcomingAssertions(href, options, { - onRetry: resolveHref, - }) - }) - } - - return resolveHref() - }, + options.log !== false && Cypress.log({ message: '', timeout: options.timeout }) - // TODO: query - hash (userOptions: Partial = {}) { - const options: InternalHashOptions = _.defaults({}, userOptions, { log: true }) + return () => { + const href = cy.getRemoteLocation('href') - if (options.log !== false) { - options._log = Cypress.log({ - message: '', - timeout: options.timeout, - }) - } - - const getHash = () => { - return cy.getRemoteLocation('hash') - } - - const resolveHash = () => { - return Promise.try(getHash).then((hash) => { - return cy.verifyUpcomingAssertions(hash, options, { - onRetry: resolveHash, - }) - }) - } - - return resolveHash() - }, - - // TODO: query - location (key, options) { - let userOptions = options + return options.decode ? decodeURI(href) : href + } + }) - // normalize arguments allowing key + options to be undefined - // key can represent the options - if (_.isObject(key) && _.isUndefined(userOptions)) { - userOptions = key - } + Commands._addQuery('hash', function url (options: Partial = {}) { + this.set('timeout', options.timeout) - userOptions = userOptions || {} + options.log !== false && Cypress.log({ message: '', timeout: options.timeout }) - options = _.defaults({}, userOptions, { log: true }) + return () => cy.getRemoteLocation('hash') + }) - const getLocation = () => { - const location = cy.getRemoteLocation() + Commands._addQuery('location', function location (key, options: Partial = {}) { + // normalize arguments allowing key + options to be undefined + // key can represent the options + if (_.isObject(key)) { + options = key + } - if (location === '') { - // maybe the page's domain is "invisible" to us - // and we cannot get the location. Return null - // so the command keeps retrying, maybe there is - // a redirect that puts us on the domain we can access - return null - } + this.set('timeout', options.timeout) - return _.isString(key) - // use existential here because we only want to throw - // on null or undefined values (and not empty strings) - ? location[key] ?? throwErrByPath('location.invalid_key', { args: { key } }) - : location - } + options.log !== false && Cypress.log({ message: _.isString(key) ? key : '', timeout: options.timeout }) - if (options.log !== false) { - options._log = Cypress.log({ - message: key != null ? key : '', - timeout: options.timeout, - }) - } + return () => { + const location = cy.getRemoteLocation() - const resolveLocation = () => { - return Promise.try(getLocation).then((ret) => { - return cy.verifyUpcomingAssertions(ret, options, { - onRetry: resolveLocation, - }) - }) + if (location === '') { + // maybe the page's domain is "invisible" to us + // and we cannot get the location. Return null + // so the command keeps retrying, maybe there is + // a redirect that puts us on the domain we can access + return null } - return resolveLocation() - }, + return _.isString(key) + // use existential here because we only want to throw + // on null or undefined values (and not empty strings) + ? location[key] ?? $errUtils.throwErrByPath('location.invalid_key', { args: { key } }) + : location + } }) } diff --git a/packages/driver/src/cy/commands/misc.ts b/packages/driver/src/cy/commands/misc.ts index d016373b040d..5a5f4fc78a0e 100644 --- a/packages/driver/src/cy/commands/misc.ts +++ b/packages/driver/src/cy/commands/misc.ts @@ -12,7 +12,7 @@ interface InternalWrapOptions extends Partial { Commands._addQuery('end', () => () => null) - Commands._addQuery('noop', () => _.identity) + Commands._addQuery('noop', (arg) => () => arg) Commands._addQuery('log', (msg, ...args) => { Cypress.log({ diff --git a/packages/driver/src/cy/commands/querying/focused.ts b/packages/driver/src/cy/commands/querying/focused.ts index dd460a6f08cb..e7059062ed0b 100644 --- a/packages/driver/src/cy/commands/querying/focused.ts +++ b/packages/driver/src/cy/commands/querying/focused.ts @@ -1,74 +1,28 @@ -import _ from 'lodash' -import Promise from 'bluebird' - import $dom from '../../../dom' -import type { Log } from '../../../cypress/log' - -interface InternalFocusedOptions extends Partial{ - _log?: Log - verify: boolean -} export default (Commands, Cypress, cy, state) => { - Commands.addAll({ - // TODO: query - focused (userOptions: Partial = {}) { - const options: InternalFocusedOptions = _.defaults({}, userOptions, { - verify: true, - log: true, - }) - - if (options.log) { - options._log = Cypress.log({ timeout: options.timeout }) - } - - const log = ($el) => { - if (options.log === false) { - return - } - - options._log!.set({ - $el, - consoleProps () { - const ret = $el ? $dom.getElements($el) : '--nothing--' - - return { - Yielded: ret, - Elements: $el != null ? $el.length : 0, - } - }, - }) - } - - const getFocused = () => { - const focused = cy.getFocused() - - log(focused) - - return focused - } - - const resolveFocused = () => { - return Promise - .try(getFocused) - .then(($el) => { - if (options.verify === false) { - return $el - } - - if (!$el) { - $el = $dom.wrap(null) - $el.selector = 'focused' + Commands._addQuery('focused', function focused (options: Partial = {}) { + const log = options.log !== false && Cypress.log({ timeout: options.timeout }) + + return () => { + let $el = cy.getFocused() + + log && log.set({ + $el, + consoleProps: () => { + return { + Yielded: $el?.length ? $dom.getElements($el) : '--nothing--', + Elements: $el != null ? $el.length : 0, } + }, + }) - // pass in a null jquery object for assertions - return cy.verifyUpcomingAssertions($el, options, { - onRetry: resolveFocused, - }) - }) + if (!$el) { + $el = $dom.wrap(null) + $el.selector = 'focused' } - return resolveFocused() - }, + return $el + } }) } diff --git a/packages/driver/src/cy/commands/traversals.ts b/packages/driver/src/cy/commands/traversals.ts index 465f3c66c8be..b3c0a0147b69 100644 --- a/packages/driver/src/cy/commands/traversals.ts +++ b/packages/driver/src/cy/commands/traversals.ts @@ -81,12 +81,10 @@ export default (Commands, Cypress, cy) => { Commands._addQuery(traversal, function traversalFn (arg1, arg2, userOptions: TraversalOptions = {}) { if (_.isObject(arg1) && !_.isFunction(arg1)) { userOptions = arg1 - arg1 = undefined } if (_.isObject(arg2) && !_.isFunction(arg2)) { userOptions = arg2 - arg2 = undefined } // Omit any null or undefined arguments diff --git a/packages/driver/src/cy/commands/window.ts b/packages/driver/src/cy/commands/window.ts index 0b76f74c6c33..d5f29246d24e 100644 --- a/packages/driver/src/cy/commands/window.ts +++ b/packages/driver/src/cy/commands/window.ts @@ -36,20 +36,6 @@ type CurrentViewport = Pick // refresh would cause viewport to hang let currentViewport: CurrentViewport | null = null -interface InternalTitleOptions extends Partial { - _log?: Log -} - -interface InternalWindowOptions extends Partial { - _log?: Log - error?: any -} - -interface InternalDocumentOptions extends Partial { - _log?: Log - error?: any -} - interface InternalViewportOptions extends Partial { _log?: Log } @@ -102,111 +88,50 @@ export default (Commands, Cypress, cy, state) => { }) } - Commands.addAll({ - // TODO: query - title (userOptions: Partial = {}) { - const options: InternalTitleOptions = _.defaults({}, userOptions, { log: true }) - - if (options.log) { - options._log = Cypress.log({ timeout: options.timeout }) - } - - const resolveTitle = () => { - const doc = state('document') - - const title = (doc && doc.title) || '' - - return cy.verifyUpcomingAssertions(title, options, { - onRetry: resolveTitle, - }) - } - - return resolveTitle() - }, - - // TODO: query - window (userOptions: Partial = {}) { - const options: InternalWindowOptions = _.defaults({}, userOptions, { log: true }) - - if (options.log) { - options._log = Cypress.log({ timeout: options.timeout }) - } - - const getWindow = () => { - const window = state('window') + Commands._addQuery('title', function title (options: Partial = {}) { + this.set('timeout', options.timeout) + if (options.log !== false) { + Cypress.log({ timeout: options.timeout }) + } - if (!window) { - $errUtils.throwErrByPath('window.iframe_undefined', { onFail: options._log }) - } - - return window - } - - // wrap retrying into its own - // separate function - const retryWindow = () => { - return Promise - .try(getWindow) - .catch((err) => { - options.error = err - - return cy.retry(retryWindow, options) - }) - } - - const verifyAssertions = () => { - return Promise.try(retryWindow).then((win) => { - return cy.verifyUpcomingAssertions(win, options, { - onRetry: verifyAssertions, - }) - }) - } + return () => (state('document')?.title || '') + }) - return verifyAssertions() - }, + Commands._addQuery('window', function windowFn (options: Partial = {}) { + this.set('timeout', options.timeout) + if (options.log !== false) { + Cypress.log({ timeout: options.timeout }) + } - // TODO: query - document (userOptions: Partial = {}) { - const options: InternalDocumentOptions = _.defaults({}, userOptions, { log: true }) + return () => { + const win = state('window') - if (options.log) { - options._log = Cypress.log({ timeout: options.timeout }) + if (!win) { + $errUtils.throwErrByPath('window.iframe_undefined') } - const getDocument = () => { - const win = state('window') - - // TODO: add failing test around logging twice - if (!win?.document) { - $errUtils.throwErrByPath('window.iframe_doc_undefined') - } - - return win.document - } + return win + } + }) - // wrap retrying into its own - // separate function - const retryDocument = () => { - return Promise - .try(getDocument) - .catch((err) => { - options.error = err + Commands._addQuery('document', function documentFn (options: Partial = {}) { + this.set('timeout', options.timeout) + if (options.log !== false) { + Cypress.log({ timeout: options.timeout }) + } - return cy.retry(retryDocument, options) - }) - } + return () => { + const win = state('window') - const verifyAssertions = () => { - return Promise.try(retryDocument).then((doc) => { - return cy.verifyUpcomingAssertions(doc, options, { - onRetry: verifyAssertions, - }) - }) + if (!win?.document) { + $errUtils.throwErrByPath('window.iframe_doc_undefined') } - return verifyAssertions() - }, + return win.document + } + }) + Commands.addAll({ viewport (presetOrWidth, heightOrOrientation, userOptions: Partial = {}) { if (_.isObject(heightOrOrientation)) { userOptions = heightOrOrientation diff --git a/packages/driver/src/cypress/command_queue.ts b/packages/driver/src/cypress/command_queue.ts index dc850af3dd90..cb9637b7df4b 100644 --- a/packages/driver/src/cypress/command_queue.ts +++ b/packages/driver/src/cypress/command_queue.ts @@ -90,6 +90,7 @@ function retryQuery (command: $Command, ret: any, cy: $Cy) { return cy.verifyUpcomingAssertions(undefined, options, { onRetry, onFail: command.get('onFail'), + ensureExistenceFor: command.get('ensureExistenceFor'), subjectFn: () => { const subject = cy.currentSubject(command.get('chainerId')) From 6a43c689b9dee930e44c88e8fc5028c0c07871c6 Mon Sep 17 00:00:00 2001 From: BlueWinds Date: Thu, 6 Oct 2022 15:03:44 -0700 Subject: [PATCH 21/54] Return noop to a command and not a query (to avoid implicit assertions) --- packages/driver/cypress/e2e/commands/actions/scroll.cy.js | 2 +- packages/driver/src/cy/commands/misc.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/driver/cypress/e2e/commands/actions/scroll.cy.js b/packages/driver/cypress/e2e/commands/actions/scroll.cy.js index cd6e456be732..3f2b01b2db6e 100644 --- a/packages/driver/cypress/e2e/commands/actions/scroll.cy.js +++ b/packages/driver/cypress/e2e/commands/actions/scroll.cy.js @@ -430,7 +430,7 @@ describe('src/cy/commands/actions/scroll', () => { context('subject errors', () => { it('throws when not passed DOM element as subject', (done) => { cy.on('fail', (err) => { - expect(err.message).to.include('`cy.scrollTo()` failed because it requires a DOM element.') + expect(err.message).to.include('`cy.scrollTo()` failed because it requires a DOM element or window.') expect(err.message).to.include('{foo: bar}') expect(err.message).to.include('> `cy.noop()`') diff --git a/packages/driver/src/cy/commands/misc.ts b/packages/driver/src/cy/commands/misc.ts index 5a5f4fc78a0e..c1862470f755 100644 --- a/packages/driver/src/cy/commands/misc.ts +++ b/packages/driver/src/cy/commands/misc.ts @@ -12,7 +12,6 @@ interface InternalWrapOptions extends Partial { Commands._addQuery('end', () => () => null) - Commands._addQuery('noop', (arg) => () => arg) Commands._addQuery('log', (msg, ...args) => { Cypress.log({ @@ -25,6 +24,8 @@ export default (Commands, Cypress, cy, state) => { return () => null }) + Commands.add('noop', (arg) => arg) + Commands.addAll({ wrap (arg, userOptions: Partial = {}) { const options: InternalWrapOptions = _.defaults({}, userOptions, { From 570b349ee0f1843881255a56f936b12a7b025d07 Mon Sep 17 00:00:00 2001 From: BlueWinds Date: Tue, 11 Oct 2022 07:50:17 -0700 Subject: [PATCH 22/54] More test fixes --- packages/driver/cypress/e2e/commands/assertions.cy.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/driver/cypress/e2e/commands/assertions.cy.js b/packages/driver/cypress/e2e/commands/assertions.cy.js index 09ce6d4a9a3a..98d2965271ad 100644 --- a/packages/driver/cypress/e2e/commands/assertions.cy.js +++ b/packages/driver/cypress/e2e/commands/assertions.cy.js @@ -61,7 +61,8 @@ describe('src/cy/commands/assertions', () => { .then((obj) => { expect(testCommands()).to.eql([ { name: 'visit', snapshots: 1, retries: 0 }, - { name: 'noop', snapshots: 1, retries: 0 }, + { name: 'noop', snapshots: 0, retries: 0 }, + { name: 'should', snapshots: 1, retries: 0 }, { name: 'then', snapshots: 0, retries: 0 }, ]) }) @@ -532,7 +533,7 @@ describe('src/cy/commands/assertions', () => { }, () => { it('should not be true', (done) => { cy.on('fail', (err) => { - expect(err.message).to.eq('Timed out retrying after 50ms: expected false to be true') + expect(err.message).to.eq('expected false to be true') done() }) From a080940e76aa16f8ec40d1497d6f4656eedac33b Mon Sep 17 00:00:00 2001 From: BlueWinds Date: Tue, 11 Oct 2022 11:30:58 -0700 Subject: [PATCH 23/54] Fix test failures --- .../e2e/e2e/origin/commands/connectors.cy.ts | 4 +-- .../e2e/e2e/origin/cookie_behavior.cy.ts | 36 +++++++++---------- .../cypress/e2e/e2e/origin/logging.cy.ts | 6 ++-- packages/driver/src/cy/commands/connectors.ts | 35 ++++++++++-------- 4 files changed, 42 insertions(+), 39 deletions(-) diff --git a/packages/driver/cypress/e2e/e2e/origin/commands/connectors.cy.ts b/packages/driver/cypress/e2e/e2e/origin/commands/connectors.cy.ts index ab8690605a3b..f0646d8dd7e8 100644 --- a/packages/driver/cypress/e2e/e2e/origin/commands/connectors.cy.ts +++ b/packages/driver/cypress/e2e/e2e/origin/commands/connectors.cy.ts @@ -111,8 +111,8 @@ context('cy.origin connectors', () => { expect(consoleProps.Function).to.equal('.text()') expect(consoleProps.Yielded).to.equal('button') - expect(consoleProps.Subject).to.have.property('tagName').that.equals('BUTTON') - expect(consoleProps.Subject).to.have.property('id').that.equals('button') + expect(consoleProps.Subject[0]).to.have.property('tagName').that.equals('BUTTON') + expect(consoleProps.Subject[0]).to.have.property('id').that.equals('button') }) }) }) diff --git a/packages/driver/cypress/e2e/e2e/origin/cookie_behavior.cy.ts b/packages/driver/cypress/e2e/e2e/origin/cookie_behavior.cy.ts index 370853db3575..f5ad444fc4bb 100644 --- a/packages/driver/cypress/e2e/e2e/origin/cookie_behavior.cy.ts +++ b/packages/driver/cypress/e2e/e2e/origin/cookie_behavior.cy.ts @@ -305,7 +305,7 @@ describe('Cookie Behavior with experimentalSessionAndOrigin=true', () => { // firefox actually sets the cookie correctly cy.getCookie('foo1').its('value').should('equal', 'bar1') } else { - cy.getCookie('foo1').its('value').should('equal', null) + cy.getCookie('foo1').should('equal', null) } // FIXME: Ideally, browser should have access to this cookie. Should be fixed in https://github.com/cypress-io/cypress/pull/23643. @@ -352,7 +352,7 @@ describe('Cookie Behavior with experimentalSessionAndOrigin=true', () => { // firefox actually sets the cookie correctly cy.getCookie('foo1').its('value').should('equal', 'bar1') } else { - cy.getCookie('foo1').its('value').should('equal', null) + cy.getCookie('foo1').should('equal', null) } // FIXME: Ideally, browser should have access to this cookie. Should be fixed in https://github.com/cypress-io/cypress/pull/23643. @@ -430,7 +430,7 @@ describe('Cookie Behavior with experimentalSessionAndOrigin=true', () => { // firefox actually sets the cookie correctly cy.getCookie('foo1').its('value').should('equal', 'bar1') } else { - cy.getCookie('foo1').its('value').should('equal', null) + cy.getCookie('foo1').should('equal', null) } // FIXME: Ideally, browser should have access to this cookie. Should be fixed in https://github.com/cypress-io/cypress/pull/23643. @@ -472,7 +472,7 @@ describe('Cookie Behavior with experimentalSessionAndOrigin=true', () => { // firefox actually sets the cookie correctly cy.getCookie('foo1').its('value').should('equal', 'bar1') } else { - cy.getCookie('foo1').its('value').should('equal', null) + cy.getCookie('foo1').should('equal', null) } // FIXME: Ideally, browser should have access to this cookie. Should be fixed in https://github.com/cypress-io/cypress/pull/23643. @@ -581,13 +581,13 @@ describe('Cookie Behavior with experimentalSessionAndOrigin=true', () => { // assert cookie value is actually set in the browser if (scheme === 'https') { // FIXME: cy.getCookie does not believe this cookie exists. Should be fixed in https://github.com/cypress-io/cypress/pull/23643. - cy.getCookie('bar1').its('value').should('equal', null) + cy.getCookie('bar1').should('equal', null) // can only set third-party SameSite=None with Secure attribute, which is only possibly over https //expected future assertion // cy.getCookie('bar1').its('value').should('equal', 'baz1') } else { - cy.getCookie('bar1').its('value').should('equal', null) + cy.getCookie('bar1').should('equal', null) } cy.window().then((win) => { @@ -622,7 +622,7 @@ describe('Cookie Behavior with experimentalSessionAndOrigin=true', () => { }) // FIXME: cy.getCookie does not believe this cookie exists. Should be fixed in https://github.com/cypress-io/cypress/pull/23643. - cy.getCookie('bar1').its('value').should('equal', null) + cy.getCookie('bar1').should('equal', null) // can only set third-party SameSite=None with Secure attribute, which is only possibly over https //expected future assertion @@ -664,7 +664,7 @@ describe('Cookie Behavior with experimentalSessionAndOrigin=true', () => { return cy.wrap(makeRequest(win, `${scheme}://www.barbaz.com:${sameOriginPort}/set-cookie?cookie=bar1=baz1; Domain=barbaz.com`, 'fetch', credentialOption as 'same-origin' | 'omit')) }) - cy.getCookie('bar1').its('value').should('equal', null) + cy.getCookie('bar1').should('equal', null) cy.window().then((win) => { return cy.wrap(makeRequest(win, `${scheme}://www.barbaz.com:${sameOriginPort}/test-request`, 'fetch', credentialOption as 'same-origin' | 'omit')) @@ -701,13 +701,13 @@ describe('Cookie Behavior with experimentalSessionAndOrigin=true', () => { // assert cookie value is actually set in the browser if (scheme === 'https') { // FIXME: cy.getCookie does not believe this cookie exists. Should be fixed in https://github.com/cypress-io/cypress/pull/23643. - cy.getCookie('bar1').its('value').should('equal', null) + cy.getCookie('bar1').should('equal', null) // can only set third-party SameSite=None with Secure attribute, which is only possibly over https //expected future assertion // cy.getCookie('bar1').its('value').should('equal', 'baz1') } else { - cy.getCookie('bar1').its('value').should('equal', null) + cy.getCookie('bar1').should('equal', null) } cy.window().then((win) => { @@ -747,7 +747,7 @@ describe('Cookie Behavior with experimentalSessionAndOrigin=true', () => { // assert cookie value is actually set in the browser // FIXME: cy.getCookie does not believe this cookie exists, though it is set in the browser. Should be fixed in https://github.com/cypress-io/cypress/pull/23643. - cy.getCookie('bar1').its('value').should('equal', null) + cy.getCookie('bar1').should('equal', null) // can only set third-party SameSite=None with Secure attribute, which is only possibly over https //expected future assertion @@ -1245,13 +1245,13 @@ describe('Cookie Behavior with experimentalSessionAndOrigin=true', () => { // assert cookie value is actually set in the browser if (scheme === 'https') { // FIXME: cy.getCookie does not believe this cookie exists, though it is set in the browser. Should be fixed in https://github.com/cypress-io/cypress/pull/23643. - cy.getCookie('bar1').its('value').should('equal', null) + cy.getCookie('bar1').should('equal', null) // can only set third-party SameSite=None with Secure attribute, which is only possibly over https //expected future assertion // cy.getCookie('bar1').its('value').should('equal', 'baz1') } else { - cy.getCookie('bar1').its('value').should('equal', null) + cy.getCookie('bar1').should('equal', null) } cy.window().then((win) => { @@ -1276,7 +1276,7 @@ describe('Cookie Behavior with experimentalSessionAndOrigin=true', () => { }) // FIXME: cy.getCookie does not believe this cookie exists, though it is set in the browser. Should be fixed in https://github.com/cypress-io/cypress/pull/23643 - cy.getCookie('bar1').its('value').should('equal', null) + cy.getCookie('bar1').should('equal', null) // can only set third-party SameSite=None with Secure attribute, which is only possibly over https //expected future assertion @@ -1307,7 +1307,7 @@ describe('Cookie Behavior with experimentalSessionAndOrigin=true', () => { return cy.wrap(makeRequest(win, `${scheme}://www.barbaz.com:${sameOriginPort}/set-cookie?cookie=bar1=baz1; Domain=barbaz.com`, 'fetch', credentialOption as 'same-origin' | 'omit')) }) - cy.getCookie('bar1').its('value').should('equal', null) + cy.getCookie('bar1').should('equal', null) cy.window().then((win) => { return cy.wrap(makeRequest(win, `${scheme}://www.barbaz.com:${sameOriginPort}/test-request`, 'fetch', credentialOption as 'same-origin' | 'omit')) @@ -1333,13 +1333,13 @@ describe('Cookie Behavior with experimentalSessionAndOrigin=true', () => { // assert cookie value is actually set in the browser if (scheme === 'https') { // FIXME: cy.getCookie does not believe this cookie exists, though it is set in the browser. Should be fixed in https://github.com/cypress-io/cypress/pull/23643 - cy.getCookie('bar1').its('value').should('equal', null) + cy.getCookie('bar1').should('equal', null) // can only set third-party SameSite=None with Secure attribute, which is only possibly over https //expected future assertion // cy.getCookie('bar1').its('value').should('equal', 'baz1') } else { - cy.getCookie('bar1').its('value').should('equal', null) + cy.getCookie('bar1').should('equal', null) } cy.window().then((win) => { @@ -1369,7 +1369,7 @@ describe('Cookie Behavior with experimentalSessionAndOrigin=true', () => { // assert cookie value is actually set in the browser // FIXME: cy.getCookie does not believe this cookie exists, though it is set in the browser. Should be fixed in https://github.com/cypress-io/cypress/pull/23643 - cy.getCookie('bar1').its('value').should('equal', null) + cy.getCookie('bar1').should('equal', null) // can only set third-party SameSite=None with Secure attribute, which is only possibly over https //expected future assertion diff --git a/packages/driver/cypress/e2e/e2e/origin/logging.cy.ts b/packages/driver/cypress/e2e/e2e/origin/logging.cy.ts index 7e0214dd563f..5c025f28c82e 100644 --- a/packages/driver/cypress/e2e/e2e/origin/logging.cy.ts +++ b/packages/driver/cypress/e2e/e2e/origin/logging.cy.ts @@ -30,8 +30,7 @@ describe('cy.origin logging', () => { }) }) - // TODO: fix flaky test https://github.com/cypress-io/cypress/issues/21300 - it.skip('logs cy.origin as group when failing with validation failure', () => { + it('logs cy.origin as group when failing with validation failure', () => { const logs: any[] = [] cy.on('log:added', (attrs) => { @@ -51,8 +50,7 @@ describe('cy.origin logging', () => { cy.origin(false, () => {}) }) - // TODO: fix flaky test https://github.com/cypress-io/cypress/issues/21300 - it.skip('logs cy.origin as group when failing with serialization failure', () => { + it('logs cy.origin as group when failing with serialization failure', () => { const logs: any[] = [] cy.on('log:added', (attrs) => { diff --git a/packages/driver/src/cy/commands/connectors.ts b/packages/driver/src/cy/commands/connectors.ts index abf1467b0cd2..bc9c43305a5d 100644 --- a/packages/driver/src/cy/commands/connectors.ts +++ b/packages/driver/src/cy/commands/connectors.ts @@ -170,12 +170,11 @@ export default function (Commands, Cypress, cy, state) { }) } - const log = options.log !== false && Cypress.log({ + const log = this.get('_log') || (options.log !== false && Cypress.log({ message: `.${path}`, timeout: options.timeout, - }) + })) - this.set('_log', log) this.set('timeout', options.timeout) this.set('ensureExistenceFor', 'subject') @@ -244,6 +243,13 @@ export default function (Commands, Cypress, cy, state) { } }) } + const log = options.log !== false && Cypress.log({ + message: `.${path}()`, + timeout: options.timeout, + }) + + this.set('_log', log) + const itsFn = cy.now('its', path, options) // .its() has an implicit assertions that the return value shouldn't be null, but @@ -251,10 +257,6 @@ export default function (Commands, Cypress, cy, state) { // assertion that .its() added this.set('ensureExistenceFor', null) - const log = this.get('_log') - - log && log.set('message', `.${path}()`) - return (subject) => { subject = cy.getRemotejQueryInstance(subject) || subject @@ -274,14 +276,17 @@ export default function (Commands, Cypress, cy, state) { let value = parent[last](...args) - log && log.set('consoleProps', () => { - return { - Command: 'invoke', - Function: `.${path}(${$utils.stringify(args)})`, - Subject: subject, - 'With Arguments': args, - Yielded: value, - } + log && log.set({ + $el: $dom.isElement(subject) ? subject : null, + consoleProps: () => { + return { + Command: 'invoke', + Function: `.${path}(${$utils.stringify(args)})`, + Subject: subject, + 'With Arguments': args, + Yielded: value, + } + }, }) return value From 34fd50e127fac971ee1052168fbfa7d607b3b2ab Mon Sep 17 00:00:00 2001 From: BlueWinds Date: Tue, 11 Oct 2022 12:10:10 -0700 Subject: [PATCH 24/54] Fix for weird-ass frontend-component test --- packages/frontend-shared/src/components/Select.cy.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/frontend-shared/src/components/Select.cy.tsx b/packages/frontend-shared/src/components/Select.cy.tsx index 93dbc1ebd812..83243c6d151d 100644 --- a/packages/frontend-shared/src/components/Select.cy.tsx +++ b/packages/frontend-shared/src/components/Select.cy.tsx @@ -152,7 +152,8 @@ describe('', () => { .then(selectFirstOption) // The options list should be closed - .get(optionsSelector).should('not.exist') + cy.get(optionsSelector).should('not.exist') .get(inputSelector).should('have.text', 'Selected') .then(openSelect) From f8f89cd351f31d673e3e627506ce000a6222d588 Mon Sep 17 00:00:00 2001 From: BlueWinds Date: Tue, 11 Oct 2022 14:37:31 -0700 Subject: [PATCH 25/54] Error message improvements --- .../cypress/e2e/commands/traversals.cy.js | 4 ++-- packages/driver/cypress/e2e/cypress/cy.cy.js | 15 +++++++++++++++ packages/driver/src/cy/assertions.ts | 12 +++--------- packages/driver/src/cy/commands/traversals.ts | 18 +++++++++++------- packages/driver/src/cypress/error_messages.ts | 2 +- 5 files changed, 32 insertions(+), 19 deletions(-) diff --git a/packages/driver/cypress/e2e/commands/traversals.cy.js b/packages/driver/cypress/e2e/commands/traversals.cy.js index 59e003bdfe18..b744f82d2120 100644 --- a/packages/driver/cypress/e2e/commands/traversals.cy.js +++ b/packages/driver/cypress/e2e/commands/traversals.cy.js @@ -106,7 +106,7 @@ describe('src/cy/commands/traversals', () => { node = dom.stringify(cy.$$(node), 'short') cy.on('fail', (err) => { - expect(err.message).to.include(`Expected to find element: \`${el}\`, but never found it. Queried from element: ${node}`) + expect(err.message).to.include(`Expected to find element: \`${el}\`, but never found it. Queried from:`) done() }) @@ -322,7 +322,7 @@ describe('src/cy/commands/traversals', () => { it('errors after timing out not finding element', (done) => { cy.on('fail', (err) => { - expect(err.message).to.include('Expected to find element: `span`, but never found it. Queried from element: ') + expect(err.message).to.include('Expected to find element: `span`, but never found it. Queried from:') done() }) diff --git a/packages/driver/cypress/e2e/cypress/cy.cy.js b/packages/driver/cypress/e2e/cypress/cy.cy.js index 3c5e3854521b..5da36ca6fe40 100644 --- a/packages/driver/cypress/e2e/cypress/cy.cy.js +++ b/packages/driver/cypress/e2e/cypress/cy.cy.js @@ -552,5 +552,20 @@ describe('driver/src/cypress/cy', () => { expect(logs.length).to.eq(3) }) }) + + it('ends all messages when query chain fails', (done) => { + const logs = [] + + cy.on('log:added', (attrs, log) => logs.push(log)) + + cy.on('fail', (err) => { + const state = logs.map((l) => l.get('state')) + + expect(state).to.eql(['passed', 'passed', 'passed', 'failed']) + done() + }) + + cy.get('body').find('#specific-contains').children().should('have.class', 'active') + }) }) }) diff --git a/packages/driver/src/cy/assertions.ts b/packages/driver/src/cy/assertions.ts index 00325374c6fb..bcda2e125efe 100644 --- a/packages/driver/src/cy/assertions.ts +++ b/packages/driver/src/cy/assertions.ts @@ -220,18 +220,12 @@ export const create = (Cypress: ICypress, cy: $Cy) => { const finishAssertions = () => { cy.state('current').get('logs').forEach((log) => { - if (log.get('next') || !log.get('snapshots')) { + if (!log.get('snapshots')) { log.snapshot() } - - const e = log.get('_error') - - if (e) { - return log.error(e) - } - - return log.end() }) + + cy.state('current').finishLogs() } type VerifyUpcomingAssertionsCallbacks = { diff --git a/packages/driver/src/cy/commands/traversals.ts b/packages/driver/src/cy/commands/traversals.ts index b3c0a0147b69..8c654cf3d559 100644 --- a/packages/driver/src/cy/commands/traversals.ts +++ b/packages/driver/src/cy/commands/traversals.ts @@ -3,6 +3,7 @@ import _ from 'lodash' import $dom from '../../dom' import $elements from '../../dom/elements' import { resolveShadowDomInclusion } from '../../cypress/shadow_dom_utils' +import { subjectChainToString } from '../../cypress/error_messages' type TraversalOptions = Partial @@ -98,13 +99,18 @@ export default (Commands, Cypress, cy) => { this.set('timeout', userOptions.timeout) - let sub - this.set('onFail', (err) => { switch (err.type) { - case 'existence': - err.message += ` Queried from element: ${$dom.stringify(sub, 'short')}` + case 'existence': { + const chainerId = this.get('chainerId') + const subjectChain = (cy.state('subjects') || {})[chainerId] + + err.message += ` Queried from: + + > ${subjectChainToString(subjectChain)}` + break + } default: break } @@ -119,9 +125,7 @@ export default (Commands, Cypress, cy) => { // normalize the selector since jQuery won't have it // or completely borks it - $el.selector = selector - - sub = subject + $el.selector = selector || traversal log && log.set({ $el, diff --git a/packages/driver/src/cypress/error_messages.ts b/packages/driver/src/cypress/error_messages.ts index 05b0c3479ed4..fc976bc96af1 100644 --- a/packages/driver/src/cypress/error_messages.ts +++ b/packages/driver/src/cypress/error_messages.ts @@ -56,7 +56,7 @@ const cmd = (command, args = '') => { const queryFnToString = (queryFn) => `.${queryFn.commandName}(${queryFn.args.map($utils.stringifyActual).join(', ')})` -const subjectChainToString = (subjectChain) => { +export const subjectChainToString = (subjectChain) => { const [initial, ...queryFns] = subjectChain const prefix = initial == null ? 'cy' : `${$utils.stringifyActual(initial)} -> ` From 4b1a0f6b795dd3a975259022e7784bd84c8a2c19 Mon Sep 17 00:00:00 2001 From: BlueWinds Date: Wed, 12 Oct 2022 09:54:30 -0700 Subject: [PATCH 26/54] Fix for broken system test --- .../cypress/e2e/spec.cy.ts | 26 ++++++++++--------- .../remote-debugging-disconnect/plugins.js | 2 ++ 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/system-tests/projects/remote-debugging-disconnect/cypress/e2e/spec.cy.ts b/system-tests/projects/remote-debugging-disconnect/cypress/e2e/spec.cy.ts index 491ba7e0916c..4cbd7c14e902 100644 --- a/system-tests/projects/remote-debugging-disconnect/cypress/e2e/spec.cy.ts +++ b/system-tests/projects/remote-debugging-disconnect/cypress/e2e/spec.cy.ts @@ -1,3 +1,9 @@ +const callAutomation = () => { + return Cypress.automation('remote:debugger:protocol', { + command: 'Browser.getVersion', + }) +} + describe('e2e remote debugging disconnect', () => { it('reconnects as expected', () => { // 1 probing connection and 1 real connection should have been made during startup @@ -9,15 +15,13 @@ describe('e2e remote debugging disconnect', () => { // now, kill all CDP sockets cy.task('kill:active:connections') + cy.then(() => { + const onRetry = () => { + return callAutomation().catch(() => cy.retry(onRetry, {})) + } - // this will attempt to run a CDP command, realize the socket is dead, enqueue it, - // and start the reconnection process - cy.wrap(Cypress) - // @ts-ignore - .invoke('automation', 'remote:debugger:protocol', { - command: 'Browser.getVersion', + return onRetry() }) - .should('have.keys', ['protocolVersion', 'product', 'revision', 'userAgent', 'jsVersion']) // TODO: We're only reconnecting the page client. See if we can find a way to reconnect the browser client // evidence of a reconnection: @@ -32,11 +36,9 @@ describe('e2e remote debugging disconnect', () => { cy.task('destroy:server') cy.task('kill:active:connections') - // this will cause a project-level error once we realize we can't talk to CDP anymore - cy.wrap(Cypress) - // @ts-ignore - .invoke('automation', 'remote:debugger:protocol', { - command: 'Browser.getVersion', + cy.then(() => { + // this will cause a project-level error once we realize we can't talk to CDP anymore + return callAutomation() }) }) }) diff --git a/system-tests/projects/remote-debugging-disconnect/plugins.js b/system-tests/projects/remote-debugging-disconnect/plugins.js index 59c3d3992db9..14e150c01323 100644 --- a/system-tests/projects/remote-debugging-disconnect/plugins.js +++ b/system-tests/projects/remote-debugging-disconnect/plugins.js @@ -12,6 +12,8 @@ let server // this is a transparent TCP proxy for Chrome's debugging port // it can kill all existing connections or shut the port down independently of Chrome or Cypress const startTcpProxy = () => { + console.error('starting tcp proxy ', { realPort, fakePort }) + return new Promise((resolve, reject) => { server = net.createServer((socket) => { const { remotePort } = socket From 10bde235f647ca7e6b967ab8bd24838cc87f1f18 Mon Sep 17 00:00:00 2001 From: BlueWinds Date: Wed, 12 Oct 2022 11:17:06 -0700 Subject: [PATCH 27/54] Update withinSubject to use subject chain --- .../driver/cypress/e2e/commands/querying/within.cy.js | 10 ++++++++++ packages/driver/src/cy/commands/querying/querying.ts | 8 ++++++-- packages/driver/src/cy/commands/querying/root.ts | 7 ++++++- packages/driver/src/cy/commands/querying/within.ts | 5 +++-- packages/driver/src/cy/commands/screenshot.ts | 4 ++-- 5 files changed, 27 insertions(+), 7 deletions(-) diff --git a/packages/driver/cypress/e2e/commands/querying/within.cy.js b/packages/driver/cypress/e2e/commands/querying/within.cy.js index 701ceac17ede..1bcc3d0356de 100644 --- a/packages/driver/cypress/e2e/commands/querying/within.cy.js +++ b/packages/driver/cypress/e2e/commands/querying/within.cy.js @@ -169,6 +169,16 @@ describe('src/cy/commands/querying/within', () => { cy.contains(`button`, `button`).should(`exist`) }) + it('re-queries if withinSubject is detached from dom', () => { + cy.on('command:retry', _.after(2, (options) => { + cy.$$('#wrapper').replaceWith('
    Newer York
    ') + })) + + cy.get('#wrapper').within(() => { + cy.get(`#upper`).should(`contain.text`, `Newer York`) + }) + }) + describe('.log', () => { beforeEach(function () { this.logs = [] diff --git a/packages/driver/src/cy/commands/querying/querying.ts b/packages/driver/src/cy/commands/querying/querying.ts index 2cdb58edbbb9..af17cff7ff6e 100644 --- a/packages/driver/src/cy/commands/querying/querying.ts +++ b/packages/driver/src/cy/commands/querying/querying.ts @@ -176,6 +176,8 @@ export default (Commands, Cypress, cy, state) => { return getAlias.call(this, selector, log, cy) } + const withinSubject = cy.state('withinSubject') || [] + const includeShadowDom = resolveShadowDomInclusion(Cypress, userOptions.includeShadowDom) return () => { @@ -184,7 +186,7 @@ export default (Commands, Cypress, cy, state) => { let $el try { - let scope = userOptions.withinSubject || cy.state('withinSubject') + let scope = userOptions.withinSubject || $utils.getSubjectFromChain(withinSubject, cy) if (scope && scope[0]) { scope = scope[0] @@ -327,11 +329,13 @@ export default (Commands, Cypress, cy, state) => { } }) + const withinSubject = cy.state('withinSubject') + return (subject) => { cy.ensureSubjectByType(subject, ['optional', 'element', 'window', 'document'], this) if (!subject || (!$dom.isElement(subject) && !$elements.isShadowRoot(subject[0]))) { - subject = cy.state('withinSubject') || cy.$$('body') + subject = $utils.getSubjectFromChain(withinSubject || [cy.$$('body')], cy) } getOptions.withinSubject = subject[0] ?? subject diff --git a/packages/driver/src/cy/commands/querying/root.ts b/packages/driver/src/cy/commands/querying/root.ts index 4df2f0dda571..fc8951a99185 100644 --- a/packages/driver/src/cy/commands/querying/root.ts +++ b/packages/driver/src/cy/commands/querying/root.ts @@ -1,3 +1,5 @@ +import $utils from '../../../cypress/utils' + export default (Commands, Cypress, cy, state) => { Commands._addQuery('root', function root (options: Partial = {}) { const log = options.log !== false && Cypress.log({ @@ -6,9 +8,12 @@ export default (Commands, Cypress, cy, state) => { this.set('timeout', options.timeout) + const withinSubject = cy.state('withinSubject') + return () => { cy.ensureCommandCanCommunicateWithAUT() - const $el = state('withinSubject') || cy.$$('html') + + const $el = $utils.getSubjectFromChain(withinSubject || [cy.$$('html')], cy) log && log.set({ $el, diff --git a/packages/driver/src/cy/commands/querying/within.ts b/packages/driver/src/cy/commands/querying/within.ts index 7b894e0f0066..4ae760903a68 100644 --- a/packages/driver/src/cy/commands/querying/within.ts +++ b/packages/driver/src/cy/commands/querying/within.ts @@ -9,7 +9,8 @@ export default (Commands, Cypress, cy, state) => { // reference the next command after this // within. when that command runs we'll // know to remove withinSubject - const next = state('current').get('next') + const current = state('current') + const next = current.get('next') // backup the current withinSubject // this prevents a bug where we null out @@ -18,7 +19,7 @@ export default (Commands, Cypress, cy, state) => { // once its done const prevWithinSubject = state('withinSubject') - state('withinSubject', subject) + state('withinSubject', state('subjects')[current.get('chainerId')]) // https://github.com/cypress-io/cypress/pull/8699 // An internal command is inserted to create a divider between diff --git a/packages/driver/src/cy/commands/screenshot.ts b/packages/driver/src/cy/commands/screenshot.ts index 39e31bd530a9..a31ad2534795 100644 --- a/packages/driver/src/cy/commands/screenshot.ts +++ b/packages/driver/src/cy/commands/screenshot.ts @@ -528,9 +528,9 @@ export default function (Commands, Cypress, cy, state, config) { // we are not limited to "within" subject // https://github.com/cypress-io/cypress/issues/14253 if (userOptions.capture !== 'runner') { - const withinSubject = state('withinSubject') + const withinSubject = $utils.getSubjectFromChain(cy.state('withinSubject') || [], cy) - if (withinSubject && $dom.isElement(withinSubject)) { + if ($dom.isElement(withinSubject)) { subject = withinSubject } } From 14b5e2307ceb9ba642750e389952f1fda327641b Mon Sep 17 00:00:00 2001 From: BlueWinds Date: Wed, 12 Oct 2022 11:27:01 -0700 Subject: [PATCH 28/54] Test clarifications --- packages/driver/cypress/e2e/commands/connectors.cy.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/driver/cypress/e2e/commands/connectors.cy.js b/packages/driver/cypress/e2e/commands/connectors.cy.js index 3e6e55bb568d..f0a91c71aff3 100644 --- a/packages/driver/cypress/e2e/commands/connectors.cy.js +++ b/packages/driver/cypress/e2e/commands/connectors.cy.js @@ -733,7 +733,7 @@ describe('src/cy/commands/connectors', () => { .invoke(() => {}) }) - it('throws when first parameter is neither of type object nor of type string nor of type number', function (done) { + it('throws when we can\'t determine both a valid options and path', function (done) { cy.on('fail', (err) => { const { lastLog } = this From a67569444c6482d5754d1dc6a8f351cb85d57bd6 Mon Sep 17 00:00:00 2001 From: BlueWinds Date: Wed, 12 Oct 2022 12:49:29 -0700 Subject: [PATCH 29/54] Unbreak cypress-testing-library via withinState backwards compatibility --- .../e2e/commands/querying/within.cy.js | 10 +++---- packages/driver/src/cy/aliases.ts | 3 +-- .../src/cy/commands/actions/selectFile.ts | 3 +-- .../src/cy/commands/querying/querying.ts | 10 +++---- .../driver/src/cy/commands/querying/root.ts | 6 ++--- .../driver/src/cy/commands/querying/within.ts | 8 +++--- packages/driver/src/cy/commands/screenshot.ts | 2 +- packages/driver/src/cy/commands/xhr.ts | 2 +- packages/driver/src/cypress.ts | 14 +++++++--- packages/driver/src/cypress/cy.ts | 27 ++++++++++++++++++- packages/driver/src/cypress/error_messages.ts | 3 +++ 11 files changed, 60 insertions(+), 28 deletions(-) diff --git a/packages/driver/cypress/e2e/commands/querying/within.cy.js b/packages/driver/cypress/e2e/commands/querying/within.cy.js index 1bcc3d0356de..96cd8825ccd3 100644 --- a/packages/driver/cypress/e2e/commands/querying/within.cy.js +++ b/packages/driver/cypress/e2e/commands/querying/within.cy.js @@ -104,7 +104,7 @@ describe('src/cy/commands/querying/within', () => { }) }) - it('clears withinSubject after within is over', () => { + it('clears withinSubjectChain after within is over', () => { const input = cy.$$('input:first') const span = cy.$$('#button-text button span') @@ -133,17 +133,17 @@ describe('src/cy/commands/querying/within', () => { }) }) - it('clears withinSubject even if next is null', (done) => { + it('clears withinSubjectChain even if next is null', (done) => { const span = cy.$$('#button-text button span') // should be defined here because next would have been - // null and withinSubject would not have been cleared + // null and withinSubjectChain would not have been cleared cy.once('command:queue:before:end', () => { - expect(cy.state('withinSubject')).not.to.be.undefined + expect(cy.state('withinSubjectChain')).not.to.be.undefined }) cy.once('command:queue:end', () => { - expect(cy.state('withinSubject')).to.be.null + expect(cy.state('withinSubjectChain')).to.be.null done() }) diff --git a/packages/driver/src/cy/aliases.ts b/packages/driver/src/cy/aliases.ts index f842efd95059..140042ec02ff 100644 --- a/packages/driver/src/cy/aliases.ts +++ b/packages/driver/src/cy/aliases.ts @@ -1,7 +1,6 @@ import _ from 'lodash' import type { $Cy } from '../cypress/cy' -import $utils from '../cypress/utils' import $errUtils from '../cypress/error_utils' export const aliasRe = /^@.+/ @@ -27,7 +26,7 @@ export const create = (cy: $Cy) => ({ aliases[alias] = aliasObj cy.state('aliases', aliases) - ctx[alias] = $utils.getSubjectFromChain(aliasObj.subjectChain, cy) + ctx[alias] = cy.getSubjectFromChain(aliasObj.subjectChain) }, getAlias (name, cmd, log) { diff --git a/packages/driver/src/cy/commands/actions/selectFile.ts b/packages/driver/src/cy/commands/actions/selectFile.ts index e680977c5ffa..89b89354c1ff 100644 --- a/packages/driver/src/cy/commands/actions/selectFile.ts +++ b/packages/driver/src/cy/commands/actions/selectFile.ts @@ -4,7 +4,6 @@ import mime from 'mime-types' import $dom from '../../../dom' import $errUtils from '../../../cypress/error_utils' -import $utils from '../../../cypress/utils' import $actionability from '../../actionability' import { addEventCoords, dispatch } from './trigger' @@ -128,7 +127,7 @@ export default (Commands, Cypress, cy, state, config) => { return } - const contents = $utils.getSubjectFromChain(aliasObj.subjectChain, cy) + const contents = cy.getSubjectFromChain(aliasObj.subjectChain) if (contents == null) { $errUtils.throwErrByPath('selectFile.invalid_alias', { diff --git a/packages/driver/src/cy/commands/querying/querying.ts b/packages/driver/src/cy/commands/querying/querying.ts index af17cff7ff6e..b2d382bdf7bf 100644 --- a/packages/driver/src/cy/commands/querying/querying.ts +++ b/packages/driver/src/cy/commands/querying/querying.ts @@ -123,7 +123,7 @@ function getAlias (selector, log, cy) { // which the 'should exist' assertion can read to determine if the *current* command is followed by a 'should not // exist' assertion. cy.state('aliasCurrentCommand', this) - const subject = $utils.getSubjectFromChain(aliasObj.subjectChain, cy) + const subject = cy.getSubjectFromChain(aliasObj.subjectChain) cy.state('aliasCurrentCommand', undefined) @@ -176,7 +176,7 @@ export default (Commands, Cypress, cy, state) => { return getAlias.call(this, selector, log, cy) } - const withinSubject = cy.state('withinSubject') || [] + const withinSubject = cy.state('withinSubjectChain') || [] const includeShadowDom = resolveShadowDomInclusion(Cypress, userOptions.includeShadowDom) @@ -186,7 +186,7 @@ export default (Commands, Cypress, cy, state) => { let $el try { - let scope = userOptions.withinSubject || $utils.getSubjectFromChain(withinSubject, cy) + let scope = userOptions.withinSubject || cy.getSubjectFromChain(withinSubject) if (scope && scope[0]) { scope = scope[0] @@ -329,13 +329,13 @@ export default (Commands, Cypress, cy, state) => { } }) - const withinSubject = cy.state('withinSubject') + const withinSubject = cy.state('withinSubjectChain') return (subject) => { cy.ensureSubjectByType(subject, ['optional', 'element', 'window', 'document'], this) if (!subject || (!$dom.isElement(subject) && !$elements.isShadowRoot(subject[0]))) { - subject = $utils.getSubjectFromChain(withinSubject || [cy.$$('body')], cy) + subject = cy.getSubjectFromChain(withinSubject || [cy.$$('body')]) } getOptions.withinSubject = subject[0] ?? subject diff --git a/packages/driver/src/cy/commands/querying/root.ts b/packages/driver/src/cy/commands/querying/root.ts index fc8951a99185..6f97635be008 100644 --- a/packages/driver/src/cy/commands/querying/root.ts +++ b/packages/driver/src/cy/commands/querying/root.ts @@ -1,5 +1,3 @@ -import $utils from '../../../cypress/utils' - export default (Commands, Cypress, cy, state) => { Commands._addQuery('root', function root (options: Partial = {}) { const log = options.log !== false && Cypress.log({ @@ -8,12 +6,12 @@ export default (Commands, Cypress, cy, state) => { this.set('timeout', options.timeout) - const withinSubject = cy.state('withinSubject') + const withinSubject = cy.state('withinSubjectChain') return () => { cy.ensureCommandCanCommunicateWithAUT() - const $el = $utils.getSubjectFromChain(withinSubject || [cy.$$('html')], cy) + const $el = cy.getSubjectFromChain(withinSubject || [cy.$$('html')]) log && log.set({ $el, diff --git a/packages/driver/src/cy/commands/querying/within.ts b/packages/driver/src/cy/commands/querying/within.ts index 4ae760903a68..6153cdf9c278 100644 --- a/packages/driver/src/cy/commands/querying/within.ts +++ b/packages/driver/src/cy/commands/querying/within.ts @@ -17,9 +17,9 @@ export default (Commands, Cypress, cy, state) => { // withinSubject when there are nested .withins() // we want the inner within to restore the outer // once its done - const prevWithinSubject = state('withinSubject') + const prevWithinSubject = state('withinSubjectChain') - state('withinSubject', state('subjects')[current.get('chainerId')]) + state('withinSubjectChain', state('subjects')[current.get('chainerId')]) // https://github.com/cypress-io/cypress/pull/8699 // An internal command is inserted to create a divider between @@ -58,7 +58,7 @@ export default (Commands, Cypress, cy, state) => { // resetting withinSubject more than once. If they point // to different 'next's then its okay if (next !== state('nextWithinSubject')) { - state('withinSubject', prevWithinSubject || null) + state('withinSubjectChain', prevWithinSubject || null) state('nextWithinSubject', next) } @@ -75,7 +75,7 @@ export default (Commands, Cypress, cy, state) => { // event which will finalize cleanup if there was no next obj cy.once('command:queue:before:end', () => { cleanup() - state('withinSubject', null) + state('withinSubjectChain', null) }) } diff --git a/packages/driver/src/cy/commands/screenshot.ts b/packages/driver/src/cy/commands/screenshot.ts index a31ad2534795..69d747ed8550 100644 --- a/packages/driver/src/cy/commands/screenshot.ts +++ b/packages/driver/src/cy/commands/screenshot.ts @@ -528,7 +528,7 @@ export default function (Commands, Cypress, cy, state, config) { // we are not limited to "within" subject // https://github.com/cypress-io/cypress/issues/14253 if (userOptions.capture !== 'runner') { - const withinSubject = $utils.getSubjectFromChain(cy.state('withinSubject') || [], cy) + const withinSubject = cy.getSubjectFromChain(cy.state('withinSubjectChain') || []) if ($dom.isElement(withinSubject)) { subject = withinSubject diff --git a/packages/driver/src/cy/commands/xhr.ts b/packages/driver/src/cy/commands/xhr.ts index fbde1b49b868..e60fa1d46d85 100644 --- a/packages/driver/src/cy/commands/xhr.ts +++ b/packages/driver/src/cy/commands/xhr.ts @@ -540,7 +540,7 @@ export default (Commands, Cypress, cy, state, config) => { if (_.isString(o.response) && aliasObj) { // reset the route's response to be the // aliases subject - options.response = $utils.getSubjectFromChain(aliasObj.subjectChain, cy) + options.response = cy.getSubjectFromChain(aliasObj.subjectChain) } const url = getUrl(options) diff --git a/packages/driver/src/cypress.ts b/packages/driver/src/cypress.ts index 6ad2a6279112..e8775539f2e8 100644 --- a/packages/driver/src/cypress.ts +++ b/packages/driver/src/cypress.ts @@ -228,11 +228,11 @@ class $Cypress { /* * As part of the Detached DOM effort, we're changing the way subjects are determined in Cypress. - * While we usually consider cy.state() to be internal, in the case of cy.state('subject'), - * cypress-testing-library, one of our most popular plugins, relies on it. + * While we usually consider cy.state() to be internal, in the case of cy.state('subject') and cy.state('withinSubject'), + * cypress-testing-library, one of our most popular plugins, relies on them. * https://github.com/testing-library/cypress-testing-library/blob/1af9f2f28b2ca62936da8a8acca81fc87e2192f7/src/utils.js#L9 * - * Therefore, we've added this shim to continue to support them. The library is actively maintained, so this + * Therefore, we've added these shims to continue to support them. The library is actively maintained, so this * shouldn't need to stick around too long (written 07/22). */ Object.defineProperty(this.state(), 'subject', { @@ -243,6 +243,14 @@ class $Cypress { }, }) + Object.defineProperty(this.state(), 'withinSubject', { + get: () => { + $errUtils.warnByPath('subject.state_withinsubject_deprecated') + + return this.cy.getSubjectFromChain(this.cy.state('withinSubjectChain')) + }, + }) + this.originalConfig = _.cloneDeep(config) this.config = $SetterGetter.create(config, (config) => { const skipConfigOverrideValidation = this.isCrossOriginSpecBridge ? window.__cySkipValidateConfig : window.top!.__cySkipValidateConfig diff --git a/packages/driver/src/cypress/cy.ts b/packages/driver/src/cypress/cy.ts index e4552ea47c7e..e8fc53e4ae33 100644 --- a/packages/driver/src/cypress/cy.ts +++ b/packages/driver/src/cypress/cy.ts @@ -253,6 +253,7 @@ export class $Cy extends EventEmitter2 implements ITimeouts, IStability, IAssert this.cleanup = this.cleanup.bind(this) this.setSubjectForChainer = this.setSubjectForChainer.bind(this) this.currentSubject = this.currentSubject.bind(this) + this.getSubjectFromChain = this.getSubjectFromChain.bind(this) // init traits @@ -1294,12 +1295,36 @@ export class $Cy extends EventEmitter2 implements ITimeouts, IStability, IAssert const subjectChain: SubjectChain | undefined = (this.state('subjects') || {})[chainerId] if (subjectChain) { - return $utils.getSubjectFromChain(subjectChain, this) + return this.getSubjectFromChain(subjectChain) } return undefined } + /* Given a chain of functions, return the actual subject. `subjectChain` might look like any of: + * [] + * ['foobar', f()] + * [undefined, f(), f()] + */ + getSubjectFromChain (subjectChain: SubjectChain) { + // If we're getting the subject of a previous command, then any log messages have already + // been added to the command log; We don't want to re-add them every time we query + // the current subject. + cy.state('onBeforeLog', () => false) + + let subject = subjectChain[0] + + try { + for (let i = 1; i < subjectChain.length; i++) { + subject = subjectChain[i](subject) + } + } finally { + cy.state('onBeforeLog', null) + } + + return subject + } + /* * Cypress executes commands asynchronously, and those commands can contain other commands - this means that there * are times when an outer chainer might have as its subject the (as of yet unresolved) return value of the inner diff --git a/packages/driver/src/cypress/error_messages.ts b/packages/driver/src/cypress/error_messages.ts index fc976bc96af1..6449651af18c 100644 --- a/packages/driver/src/cypress/error_messages.ts +++ b/packages/driver/src/cypress/error_messages.ts @@ -1947,6 +1947,9 @@ export default { state_subject_deprecated: { message: `${cmd('state', '\'subject\'')} has been deprecated and will be removed in a future release. Consider migrating to ${cmd('currentSubject')} instead.`, }, + state_withinsubject_deprecated: { + message: `${cmd('state', '\'withinSubject\'')} has been deprecated and will be removed in a future release. You should read ${cmd('state', '\'withinSubjectChain\'')} once at the top of your command / query, and resolve it into a value with ${cmd('getSubjectFromChain', 'withinSubjectChain')} as needed.`, + }, }, submit: { From e1e7ecabf646deb7b58ecefd872b0be5f2855b94 Mon Sep 17 00:00:00 2001 From: BlueWinds Date: Wed, 12 Oct 2022 13:08:11 -0700 Subject: [PATCH 30/54] Typo in last commit --- packages/driver/src/cypress.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/driver/src/cypress.ts b/packages/driver/src/cypress.ts index e8775539f2e8..b6827586519d 100644 --- a/packages/driver/src/cypress.ts +++ b/packages/driver/src/cypress.ts @@ -247,7 +247,7 @@ class $Cypress { get: () => { $errUtils.warnByPath('subject.state_withinsubject_deprecated') - return this.cy.getSubjectFromChain(this.cy.state('withinSubjectChain')) + return this.cy.getSubjectFromChain(this.cy.state('withinSubjectChain') || []) }, }) From 652fd061486806fbc24d6690bf1d0086085c8d98 Mon Sep 17 00:00:00 2001 From: BlueWinds Date: Mon, 17 Oct 2022 09:13:18 -0700 Subject: [PATCH 31/54] Improvement for assertion following failed traversal --- .../driver/cypress/e2e/commands/assertions.cy.js | 14 ++++++++++++++ packages/driver/src/cypress/chai_jquery.ts | 2 +- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/driver/cypress/e2e/commands/assertions.cy.js b/packages/driver/cypress/e2e/commands/assertions.cy.js index 98d2965271ad..849c2e3f31d0 100644 --- a/packages/driver/cypress/e2e/commands/assertions.cy.js +++ b/packages/driver/cypress/e2e/commands/assertions.cy.js @@ -2072,6 +2072,20 @@ describe('src/cy/commands/assertions', () => { cy.wrap(undefined).should('have.value', 'somevalue') }) + + it('shows subject instead of undefined when a previous traversal errors', (done) => { + cy.on('log:added', (attrs, log) => { + if (attrs.name === 'assert') { + cy.removeAllListeners('log:added') + expect(log.get('message')).to.eq('expected **subject** to have class **updated**') + done() + } + }) + + cy.get('body') + .contains('Does not exist') + .should('have.class', 'updated') + }) }) context('descendants', () => { diff --git a/packages/driver/src/cypress/chai_jquery.ts b/packages/driver/src/cypress/chai_jquery.ts index 23a13ada9562..8c139087d2eb 100644 --- a/packages/driver/src/cypress/chai_jquery.ts +++ b/packages/driver/src/cypress/chai_jquery.ts @@ -67,7 +67,7 @@ export const $chaiJquery = (chai, chaiUtils, callbacks: Callbacks) => { // From jQuery 3.x .selector API is deprecated. (https://api.jquery.com/selector/) // Because of that, wrap() above removes selector property. // That's why we're caching the value of selector above and using it here. - ctx._obj = selector + ctx._obj = selector ?? 'subject' // if no element found, fail the existence check // depends on the negate flag ctx.assert(!!ctx.__flags.negate, ...args) From e982e05eff9d2b70cd6fd3d66258056ba0a65bfb Mon Sep 17 00:00:00 2001 From: BlueWinds Date: Tue, 25 Oct 2022 10:53:20 -0700 Subject: [PATCH 32/54] WIP adding query support to --- .../cypress/e2e/commands/actions/check.cy.js | 24 +++- .../cypress/e2e/commands/actions/clear.cy.js | 22 +++- .../cypress/e2e/commands/actions/click.cy.js | 108 ++++++++++++------ .../cypress/e2e/commands/connectors.cy.js | 2 +- packages/driver/cypress/e2e/cypress/cy.cy.js | 22 +++- packages/driver/src/cross-origin/origin_fn.ts | 2 +- packages/driver/src/cy/actionability.ts | 17 ++- .../driver/src/cy/commands/actions/check.ts | 102 ++++++++--------- .../driver/src/cy/commands/actions/click.ts | 15 ++- .../driver/src/cy/commands/actions/type.ts | 5 + packages/driver/src/cy/commands/aliasing.ts | 3 +- packages/driver/src/cy/commands/connectors.ts | 4 +- .../src/cy/commands/querying/focused.ts | 2 +- .../src/cy/commands/querying/querying.ts | 18 +-- .../driver/src/cy/commands/querying/root.ts | 2 +- .../driver/src/cy/commands/querying/within.ts | 2 +- packages/driver/src/cy/commands/screenshot.ts | 2 +- packages/driver/src/cy/commands/traversals.ts | 5 +- packages/driver/src/cy/ensures.ts | 21 ++-- packages/driver/src/cypress.ts | 4 +- packages/driver/src/cypress/command.ts | 20 +--- packages/driver/src/cypress/command_queue.ts | 15 +-- packages/driver/src/cypress/cy.ts | 43 ++++--- packages/driver/src/cypress/error_messages.ts | 43 +++++-- packages/driver/src/cypress/log.ts | 2 +- packages/driver/src/cypress/utils.ts | 24 ---- 26 files changed, 315 insertions(+), 214 deletions(-) diff --git a/packages/driver/cypress/e2e/commands/actions/check.cy.js b/packages/driver/cypress/e2e/commands/actions/check.cy.js index 49a37886a623..edd81eab408f 100644 --- a/packages/driver/cypress/e2e/commands/actions/check.cy.js +++ b/packages/driver/cypress/e2e/commands/actions/check.cy.js @@ -98,6 +98,26 @@ describe('src/cy/commands/actions/check', () => { cy.get(checkbox).check() }) + it('requeries if the DOM rerenders during actionability', () => { + cy.$$('[name=colors]').first().prop('disabled', true) + + const listener = _.after(3, () => { + cy.$$('[name=colors]').first().prop('disabled', false) + + const parent = cy.$$('[name=colors]').parent() + parent.replaceWith(parent[0].outerHTML) + cy.off('command:retry', listener) + }) + + cy.on('command:retry', listener) + + cy.get('[name=colors]').check().then(($inputs) => { + $inputs.each((i, el) => { + expect($(el)).to.be.checked + }) + }) + }) + // readonly should only be limited to inputs, not checkboxes it('can check readonly checkboxes', () => { cy.get('#readonly-checkbox').check().then(($checkbox) => { @@ -437,7 +457,7 @@ describe('src/cy/commands/actions/check', () => { cy.on('fail', (err) => { expect(checked).to.eq(1) - expect(err.message).to.include('`cy.check()` failed because this element') + expect(err.message).to.include('`cy.check()` failed because the DOM updated') done() }) @@ -1079,7 +1099,7 @@ describe('src/cy/commands/actions/check', () => { cy.on('fail', (err) => { expect(unchecked).to.eq(1) - expect(err.message).to.include('`cy.uncheck()` failed because this element') + expect(err.message).to.include('`cy.uncheck()` failed because the DOM updated') done() }) diff --git a/packages/driver/cypress/e2e/commands/actions/clear.cy.js b/packages/driver/cypress/e2e/commands/actions/clear.cy.js index e751a6469a4a..c2dcf40342d8 100644 --- a/packages/driver/cypress/e2e/commands/actions/clear.cy.js +++ b/packages/driver/cypress/e2e/commands/actions/clear.cy.js @@ -52,6 +52,26 @@ describe('src/cy/commands/actions/type - #clear', () => { }) }) + it('requeries if the DOM rerenders during actionability', () => { + const clicked = cy.stub() + const retried = cy.stub() + + const textarea = cy.$$('#comments').val('foo bar').prop('disabled', true) + + cy.on('command:retry', _.after(3, () => { + if (!retried.callCount) { + textarea.replaceWith(textarea[0].outerHTML) + cy.$$('#comments').prop('disabled', false).on('click', clicked) + retried() + } + })) + + cy.get('#comments').clear().then(() => { + expect(clicked).to.be.calledOnce + expect(retried).to.be.called + }) + }) + it('can force clear even when being covered by another element', () => { const $input = $('') .attr('id', 'input-covered-in-span') @@ -275,7 +295,7 @@ describe('src/cy/commands/actions/type - #clear', () => { cy.on('fail', (err) => { expect(cleared).to.be.calledOnce - expect(err.message).to.include('`cy.clear()` failed because this element') + expect(err.message).to.include('`cy.clear()` failed because the DOM updated') done() }) diff --git a/packages/driver/cypress/e2e/commands/actions/click.cy.js b/packages/driver/cypress/e2e/commands/actions/click.cy.js index 3fa9e8bf358f..db1d262f6138 100644 --- a/packages/driver/cypress/e2e/commands/actions/click.cy.js +++ b/packages/driver/cypress/e2e/commands/actions/click.cy.js @@ -47,7 +47,7 @@ const getMidPoint = (el) => { const isFirefox = Cypress.isBrowser('firefox') const isWebKit = Cypress.isBrowser('webkit') -describe('src/cy/commands/actions/click', () => { +describe.skip('src/cy/commands/actions/click', () => { beforeEach(() => { cy.visit('/fixtures/dom.html') }) @@ -738,6 +738,26 @@ describe('src/cy/commands/actions/click', () => { }) }) + it('requeries if the DOM rerenders during actionability', () => { + cy.$$('[name=colors]').first().prop('disabled', true) + + const listener = _.after(3, () => { + cy.$$('[name=colors]').first().prop('disabled', false) + + const parent = cy.$$('[name=colors]').parent() + parent.replaceWith(parent[0].outerHTML) + cy.off('command:retry', listener) + }) + + cy.on('command:retry', listener) + + cy.get('[name=colors]').first().click().then(($inputs) => { + $inputs.each((i, el) => { + expect($(el)).to.be.checked + }) + }) + }) + it('increases the timeout delta after each click', () => { const count = cy.$$('#three-buttons button').length @@ -813,22 +833,20 @@ describe('src/cy/commands/actions/click', () => { }) it('places cursor at the end of [contenteditable]', () => { - cy.get('[contenteditable]:first') - .invoke('html', '

    ').click() - .then(expectCaret(0)) + cy.get('[contenteditable]:first').as('edit') - cy.get('[contenteditable]:first') - .invoke('html', 'foo').click() - .then(expectCaret(3)) + cy.get('@edit').invoke('html', '

    ') + cy.get('@edit').click().then(expectCaret(0)) - cy.get('[contenteditable]:first') - .invoke('html', '
    foo
    ').click() - .then(expectCaret(3)) + cy.get('@edit').invoke('html', 'foo') + cy.get('@edit').click().then(expectCaret(3)) + + cy.get('@edit').invoke('html', '
    foo
    ') + cy.get('@edit').click().then(expectCaret(3)) - cy.get('[contenteditable]:first') // firefox headless: prevent contenteditable from disappearing (dont set to empty) - .invoke('html', '
    ').click() - .then(expectCaret(0)) + cy.get('@edit').invoke('html', '
    ') + cy.get('@edit').click().then(expectCaret(0)) }) it('can click SVG elements', () => { @@ -1595,6 +1613,17 @@ describe('src/cy/commands/actions/click', () => { }) }) + it('succeeds when DOM rerenders and returns new subject', () => { + const $btn = cy.$$('#button').prop('disabled', true) + + cy.on('command:retry', _.after(3, () => { + $btn.replaceWith('') + retried = true + })) + + cy.get('#button').click().should('contain', 'New Button') + }) + it('waits until element stops animating', () => { let retries = 0 @@ -2120,38 +2149,20 @@ describe('src/cy/commands/actions/click', () => { cy.get('.badge-multi').click() }) - it('throws when subject is not in the document', (done) => { - let clicked = 0 - - const $checkbox = cy.$$(':checkbox:first').click(() => { - clicked += 1 - $checkbox.remove() - - return false - }) - - cy.on('fail', (err) => { - expect(clicked).to.eq(1) - expect(err.message).to.include('`cy.click()` failed because this element is detached from the DOM') - - done() - }) - - cy.get(':checkbox:first').click().click() - }) + // This is an instance of an unfixable detached DOM error: .then() is a command, so it sets the subject to a + // *specific element*, which then gets detached. + // The error message tells the user exactly how to fix this case. it('throws when subject is detached during actionability', (done) => { cy.on('fail', (err) => { - expect(err.message).to.include('`cy.click()` failed because this element is detached from the DOM') + expect(err.message).to.include('`cy.click()` failed because the DOM updated while this command was executing.') + expect(err.message).to.include('You can typically solve this by breaking up a chain.') done() }) cy.get('input:first') .then(($el) => { - // This represents an asynchronous re-render - // since we fire the 'scrolled' event during actionability - // if we use el.on('scroll'), headless electron is flaky cy.on('scrolled', () => { $el.remove() }) @@ -3281,7 +3292,7 @@ describe('src/cy/commands/actions/click', () => { cy.on('fail', (err) => { expect(dblclicked).to.eq(1) - expect(err.message).to.include('`cy.dblclick()` failed because this element') + expect(err.message).to.include('`cy.dblclick()` failed because the DOM updated as a result of this command') done() }) @@ -3720,7 +3731,7 @@ describe('src/cy/commands/actions/click', () => { cy.on('fail', (err) => { expect(rightclicked).to.eq(1) - expect(err.message).to.include('`cy.rightclick()` failed because this element') + expect(err.message).to.include('`cy.rightclick()` failed because the DOM updated as a result of this command') done() }) @@ -4421,6 +4432,27 @@ describe('mouse state', () => { expect(pointerenter).to.be.calledOnce }) }) + + it.only('can move mouse from a div to another div', () => { + let coords = { + clientX: 494, + clientY: 10, + // layerX: 492, + // layerY: 215, + pageX: 494, + pageY: 226, + screenX: 494, + screenY: 10, + x: 494, + y: 10, + } + + cy.$$('div.item').eq(1).one('mouseover', cy.stub().callsFake((e) => { + expect(e.clientX).closeTo(coords.clientX, 1) + })) + + cy.get('div.item').eq(1).click() + }) }) }) diff --git a/packages/driver/cypress/e2e/commands/connectors.cy.js b/packages/driver/cypress/e2e/commands/connectors.cy.js index f0a91c71aff3..924068aefb59 100644 --- a/packages/driver/cypress/e2e/commands/connectors.cy.js +++ b/packages/driver/cypress/e2e/commands/connectors.cy.js @@ -310,7 +310,7 @@ describe('src/cy/commands/connectors', () => { return $div }) .then(function () { - expect(cy.currentSubject()).not.to.be.instanceof(this.remoteWindow.$) + expect(cy.subject()).not.to.be.instanceof(this.remoteWindow.$) }) }) }) diff --git a/packages/driver/cypress/e2e/cypress/cy.cy.js b/packages/driver/cypress/e2e/cypress/cy.cy.js index 5da36ca6fe40..9da5e38700bc 100644 --- a/packages/driver/cypress/e2e/cypress/cy.cy.js +++ b/packages/driver/cypress/e2e/cypress/cy.cy.js @@ -142,7 +142,7 @@ describe('driver/src/cypress/cy', () => { cy.wrap(subject).then(() => { expect(cy.state('subject')).to.equal(subject) - expect(Cypress.utils.warning).to.be.calledWith('`cy.state(\'subject\')` has been deprecated and will be removed in a future release. Consider migrating to `cy.currentSubject()` instead.') + expect(Cypress.utils.warning).to.be.calledWith('`cy.state(\'subject\')` has been deprecated and will be removed in a future release. Consider migrating to `cy.subject()` instead.') }) }) }) @@ -306,9 +306,8 @@ describe('driver/src/cypress/cy', () => { }) cy.on('fail', (err) => { - expect(err.message).to.include('`cy.parent()` failed because this element is detached from the DOM.') - expect(err.message).to.include('') - expect(err.message).to.include('> `cy.click()`') + expect(err.message).to.include('`cy.parent()` failed because the DOM updated as a result of this command, but you tried to continue the command chain.') + expect(err.message).to.include('You can typically solve this by breaking up a chain.') expect(err.docsUrl).to.eq('https://on.cypress.io/element-has-detached-from-dom') done() @@ -553,6 +552,21 @@ describe('driver/src/cypress/cy', () => { }) }) + it("closes each log as the query completes", (done) => { + let getLog + + cy.on('log:added', (attrs, log) => { + if (attrs.name === 'get') { + getLog = log + } else if (attrs.name === 'find') { + expect(getLog.get('state')).to.eq('passed') + done() + } + }) + + cy.get('body').find('#wrapper') + }) + it('ends all messages when query chain fails', (done) => { const logs = [] diff --git a/packages/driver/src/cross-origin/origin_fn.ts b/packages/driver/src/cross-origin/origin_fn.ts index 0ffb4e18c133..8893d9325540 100644 --- a/packages/driver/src/cross-origin/origin_fn.ts +++ b/packages/driver/src/cross-origin/origin_fn.ts @@ -118,7 +118,7 @@ export const handleOriginFn = (Cypress: Cypress.Cypress, cy: $Cy) => { queueFinished = true setRunnableStateToPassed() Cypress.specBridgeCommunicator.toPrimary('queue:finished', { - subject: cy.currentSubject(), + subject: cy.subject(), }, { syncGlobals: true, }) diff --git a/packages/driver/src/cy/actionability.ts b/packages/driver/src/cy/actionability.ts index e59c22f33ea8..74a72db4b8dd 100644 --- a/packages/driver/src/cy/actionability.ts +++ b/packages/driver/src/cy/actionability.ts @@ -293,6 +293,7 @@ const ensureNotAnimating = function (cy, $el, coordsHistory, animationDistanceTh interface VerifyCallbacks { onReady?: ($el: any, coords: ElementPositioning) => any onScroll?: ($el: any, type: 'element' | 'window' | 'container') => any + subjectFn?: () => any } const verify = function (cy, $el, config, options, callbacks: VerifyCallbacks) { @@ -366,6 +367,7 @@ const verify = function (cy, $el, config, options, callbacks: VerifyCallbacks) { if (force !== true) { // ensure it's attached + cy.ensureElement($el, null, _log) cy.ensureAttached($el, null, _log) // ensure its 'receivable' @@ -444,7 +446,7 @@ const verify = function (cy, $el, config, options, callbacks: VerifyCallbacks) { finalEl = $elAtCoords != null ? $elAtCoords : $el } - return onReady(finalEl, finalCoords) + return onReady(finalEl, finalCoords, $el) } // we cannot enforce async promises here because if our @@ -453,6 +455,19 @@ const verify = function (cy, $el, config, options, callbacks: VerifyCallbacks) { // the checks and firing the event! const retryActionability = () => { try { + if (callbacks.subjectFn) { + $el = callbacks.subjectFn() + + if ($el.length === 0 || $dom.isDetached($el)) { + const current = cy.state('current') + const subjectChain = cy.subjectChain(current.get('chainerId')) + + $errUtils.throwErrByPath('subject.detached_during_actionability', { + args: { name: current.get('name'), subjectChain }, + }) + } + } + return runAllChecks() } catch (err) { options.error = err diff --git a/packages/driver/src/cy/commands/actions/check.ts b/packages/driver/src/cy/commands/actions/check.ts index d882ed080851..0c928e68d5b9 100644 --- a/packages/driver/src/cy/commands/actions/check.ts +++ b/packages/driver/src/cy/commands/actions/check.ts @@ -52,9 +52,11 @@ const checkOrUncheck = (Cypress, cy, type, subject, values: any[] = [], userOpti return (values.length === 0) || values.includes(value) } + const subjectChain = cy.subjectChain() + // blow up if any member of the subject // isnt a checkbox or radio - const checkOrUncheckEl = (el) => { + const checkOrUncheckEl = (el, index) => { const $el = $dom.wrap(el) const node = $dom.stringify($el) @@ -68,10 +70,8 @@ const checkOrUncheck = (Cypress, cy, type, subject, values: any[] = [], userOpti }) } - const isElActionable = elHasMatchingValue($el) - - if (isElActionable) { - matchingElements.push(el) + if(!elHasMatchingValue($el)) { + return } const consoleProps: Record = { @@ -79,7 +79,7 @@ const checkOrUncheck = (Cypress, cy, type, subject, values: any[] = [], userOpti 'Elements': $el.length, } - if (options.log && isElActionable) { + if (options.log) { // figure out the userOptions which actually change the behavior of clicks const deltaOptions = $utils.filterOutOptions(options) @@ -103,56 +103,56 @@ const checkOrUncheck = (Cypress, cy, type, subject, values: any[] = [], userOpti args: { node, cmd: type }, }) } + } + + // if the checkbox was already checked + // then notify the user of this note + // and bail + if (isNoop($el)) { + if (!options.force) { + // still ensure visibility even if the command is noop + cy.ensureVisibility($el, options._log) + } + + // if the checkbox is in an indeterminate state, checking or unchecking should set the + // prop to false to move it into a "determinate" state + // https://github.com/cypress-io/cypress/issues/19098 + if ($el.prop('indeterminate')) { + $el.prop('indeterminate', false) + } + + if (options._log) { + const inputType = $el.is(':radio') ? 'radio' : 'checkbox' - // if the checkbox was already checked - // then notify the user of this note - // and bail - if (isNoop($el)) { - if (!options.force) { - // still ensure visibility even if the command is noop - cy.ensureVisibility($el, options._log) - } - - // if the checkbox is in an indeterminate state, checking or unchecking should set the - // prop to false to move it into a "determinate" state - // https://github.com/cypress-io/cypress/issues/19098 - if ($el.prop('indeterminate')) { - $el.prop('indeterminate', false) - } - - if (options._log) { - const inputType = $el.is(':radio') ? 'radio' : 'checkbox' - - consoleProps.Note = `This ${inputType} was already ${type}ed. No operation took place.` - options._log.snapshot().end() - } - - return null + consoleProps.Note = `This ${inputType} was already ${type}ed. No operation took place.` + options._log.snapshot().end() } + + matchingElements.push($el[0]) + return null } // if we didnt pass in any values or our // el's value is in the array then check it - if (isElActionable) { - return cy.now('click', $el, { - $el, - log: false, - verify: false, - _log: options._log, - force: options.force, - timeout: options.timeout, - interval: options.interval, - waitForAnimations: options.waitForAnimations, - animationDistanceThreshold: options.animationDistanceThreshold, - scrollBehavior: options.scrollBehavior, - }).then(() => { - if (options._log) { - options._log.snapshot().end() - } - - return null - }) - } + return cy.now('click', $el, { + $el, + subjectFn: () => cy.getSubjectFromChain(subjectChain).eq(index), + log: false, + verify: false, + _log: options._log, + force: options.force, + timeout: options.timeout, + interval: options.interval, + waitForAnimations: options.waitForAnimations, + animationDistanceThreshold: options.animationDistanceThreshold, + scrollBehavior: options.scrollBehavior, + }).then(($el) => { + if (options._log) { + options._log.snapshot().end() + } + + matchingElements.push($el[0]) + }) } // return our original subject when our promise resolves @@ -162,7 +162,7 @@ const checkOrUncheck = (Cypress, cy, type, subject, values: any[] = [], userOpti .then(() => { // filter down our $el to the // matching elements - options.$el = options.$el.filter(matchingElements) + options.$el = cy.$$(matchingElements) const verifyAssertions = () => { return cy.verifyUpcomingAssertions(options.$el, options, { diff --git a/packages/driver/src/cy/commands/actions/click.ts b/packages/driver/src/cy/commands/actions/click.ts index a14d43773ed9..c4cbfaedecb0 100644 --- a/packages/driver/src/cy/commands/actions/click.ts +++ b/packages/driver/src/cy/commands/actions/click.ts @@ -39,6 +39,7 @@ const formatMouseEvents = (events) => { // TODO: remove any, Function, Record type MouseActionOptions = { subject: any + subjectFn: () => any positionOrX: string | number y: number userOptions: Record @@ -105,7 +106,10 @@ export default (Commands, Cypress, cy: $Cy, state, config) => { } } - const perform = (el) => { + const subjectChain = cy.subjectChain() + const clickedElements = [] + + const perform = (el, index) => { let deltaOptions const $el = $dom.wrap(el) @@ -150,7 +154,7 @@ export default (Commands, Cypress, cy: $Cy, state, config) => { 'Options': deltaOptions, }) - if (options.$el.get(0) !== elClicked) { + if (options.$el.get(index) !== elClicked) { // only do this if $elToClick isnt $el consoleObj['Actual Element Clicked'] = $dom.getElements($(elClicked)) } @@ -194,16 +198,19 @@ export default (Commands, Cypress, cy: $Cy, state, config) => { // once we establish the coordinates and the element // passes all of the internal checks return $actionability.verify(cy, $el, config, individualOptions, { + subjectFn: options.subjectFn || (() => cy.getSubjectFromChain(subjectChain).eq(index)), + onScroll ($el, type) { return Cypress.action('cy:scrolled', $el, type) }, - onReady ($elToClick, coords) { + onReady ($elToClick, coords, $el) { const { fromElViewport, fromElWindow, fromAutWindow } = coords const forceEl = options.force && $elToClick.get(0) const moveEvents = mouse.move(fromElViewport, forceEl) + clickedElements.push($el[0]) flagModifiers(true) @@ -236,6 +243,8 @@ export default (Commands, Cypress, cy: $Cy, state, config) => { return Promise .each(options.$el.toArray(), perform) .then(() => { +// options.$el = cy.$$(clickedElements) + if (options.verify === false) { return options.$el } diff --git a/packages/driver/src/cy/commands/actions/type.ts b/packages/driver/src/cy/commands/actions/type.ts index e42400c62eb4..73f9393af06d 100644 --- a/packages/driver/src/cy/commands/actions/type.ts +++ b/packages/driver/src/cy/commands/actions/type.ts @@ -476,6 +476,8 @@ export default function (Commands, Cypress, cy, state, config) { }) } + const subjectChain = cy.subjectChain() + const handleFocused = function () { // if it's the body, don't need to worry about focus // (unless it can be modified i.e we're in designMode or contenteditable) @@ -509,6 +511,8 @@ export default function (Commands, Cypress, cy, state, config) { } return $actionability.verify(cy, options.$el, config, options, { + subjectFn: () => cy.getSubjectFromChain(subjectChain), + onScroll ($el, type) { return Cypress.action('cy:scrolled', $el, type) }, @@ -579,6 +583,7 @@ export default function (Commands, Cypress, cy, state, config) { const verifyAssertions = () => { return cy.verifyUpcomingAssertions(options.$el, options, { + subjectFn: () => cy.getSubjectFromChain(subjectChain), onRetry: verifyAssertions, }) } diff --git a/packages/driver/src/cy/commands/aliasing.ts b/packages/driver/src/cy/commands/aliasing.ts index 5cc8fc86bdaa..2376ea15f2f0 100644 --- a/packages/driver/src/cy/commands/aliasing.ts +++ b/packages/driver/src/cy/commands/aliasing.ts @@ -12,8 +12,7 @@ export default function (Commands, Cypress, cy) { // Shallow clone of the existing subject chain, so that future commands running on the same chainer // don't apply here as well. - const chainerId = cy.state('chainerId') - const subjectChain = [...cy.state('subjects')[chainerId]] + const subjectChain = [...cy.subjectChain()] const fileName = prevCommand.get('fileName') diff --git a/packages/driver/src/cy/commands/connectors.ts b/packages/driver/src/cy/commands/connectors.ts index bc9c43305a5d..5b1adc267b33 100644 --- a/packages/driver/src/cy/commands/connectors.ts +++ b/packages/driver/src/cy/commands/connectors.ts @@ -189,7 +189,7 @@ export default function (Commands, Cypress, cy, state) { const value = _.get(subject, path) - log && log.set({ + log && cy.state('current') === this && log.set({ $el: $dom.isElement(subject) ? subject : null, consoleProps () { const obj = { @@ -276,7 +276,7 @@ export default function (Commands, Cypress, cy, state) { let value = parent[last](...args) - log && log.set({ + log && cy.state('current') === this && log.set({ $el: $dom.isElement(subject) ? subject : null, consoleProps: () => { return { diff --git a/packages/driver/src/cy/commands/querying/focused.ts b/packages/driver/src/cy/commands/querying/focused.ts index e7059062ed0b..34f7969aa334 100644 --- a/packages/driver/src/cy/commands/querying/focused.ts +++ b/packages/driver/src/cy/commands/querying/focused.ts @@ -7,7 +7,7 @@ export default (Commands, Cypress, cy, state) => { return () => { let $el = cy.getFocused() - log && log.set({ + log && cy.state('current') === this && log.set({ $el, consoleProps: () => { return { diff --git a/packages/driver/src/cy/commands/querying/querying.ts b/packages/driver/src/cy/commands/querying/querying.ts index b2d382bdf7bf..726d0efe3aa6 100644 --- a/packages/driver/src/cy/commands/querying/querying.ts +++ b/packages/driver/src/cy/commands/querying/querying.ts @@ -57,7 +57,7 @@ function getAlias (selector, log, cy) { const { command } = aliasObj - log && log.set('referencesAlias', { name: alias }) + log && cy.state('current') === this && log.set('referencesAlias', { name: alias }) /* * There are three cases for aliases, each explained in more detail below: @@ -71,7 +71,7 @@ function getAlias (selector, log, cy) { // and returns one or more requests. const requests = cy.getRequestsByAlias(alias) || null - log && log.set({ + log && cy.state('current') === this && log.set({ aliasType: 'route', consoleProps: () => { return { @@ -97,7 +97,7 @@ function getAlias (selector, log, cy) { const returnValue = index === 'all' ? requests : (requests[parseInt(index, 10)] || null) - log && log.set({ + log && cy.state('current') === this && log.set({ aliasType: 'intercept', consoleProps: () => { return { @@ -128,7 +128,7 @@ function getAlias (selector, log, cy) { cy.state('aliasCurrentCommand', undefined) if ($dom.isElement(subject)) { - log && log.set({ + log && cy.state('current') === this && log.set({ aliasType: 'dom', consoleProps: () => { return { @@ -139,7 +139,7 @@ function getAlias (selector, log, cy) { }, }) } else { - log && log.set({ + log && cy.state('current') === this && log.set({ aliasType: 'primitive', consoleProps: () => { return { @@ -176,7 +176,7 @@ export default (Commands, Cypress, cy, state) => { return getAlias.call(this, selector, log, cy) } - const withinSubject = cy.state('withinSubjectChain') || [] + const withinSubject = cy.state('withinSubjectChain') const includeShadowDom = resolveShadowDomInclusion(Cypress, userOptions.includeShadowDom) @@ -221,7 +221,7 @@ export default (Commands, Cypress, cy, state) => { throw err } - log && log.set({ + log && cy.state('current') === this && log.set({ $el, consoleProps: () => { return { @@ -351,7 +351,7 @@ export default (Commands, Cypress, cy, state) => { $el = $dom.getFirstDeepestElement($el) } - log && log.set({ + log && cy.state('current') === this && log.set({ $el, consoleProps: () => { return { @@ -396,7 +396,7 @@ export default (Commands, Cypress, cy, state) => { .map((i, node) => node.shadowRoot) .filter((i, node) => node !== undefined && node !== null) - log && log.set({ + log && cy.state('current') === this && log.set({ $el, consoleProps: () => { return { diff --git a/packages/driver/src/cy/commands/querying/root.ts b/packages/driver/src/cy/commands/querying/root.ts index 6f97635be008..1cd49e45de06 100644 --- a/packages/driver/src/cy/commands/querying/root.ts +++ b/packages/driver/src/cy/commands/querying/root.ts @@ -13,7 +13,7 @@ export default (Commands, Cypress, cy, state) => { const $el = cy.getSubjectFromChain(withinSubject || [cy.$$('html')]) - log && log.set({ + log && cy.state('current') === this && log.set({ $el, consoleProps: () => { return { diff --git a/packages/driver/src/cy/commands/querying/within.ts b/packages/driver/src/cy/commands/querying/within.ts index 6153cdf9c278..e8a61f4b8506 100644 --- a/packages/driver/src/cy/commands/querying/within.ts +++ b/packages/driver/src/cy/commands/querying/within.ts @@ -19,7 +19,7 @@ export default (Commands, Cypress, cy, state) => { // once its done const prevWithinSubject = state('withinSubjectChain') - state('withinSubjectChain', state('subjects')[current.get('chainerId')]) + state('withinSubjectChain', cy.subjectChain()) // https://github.com/cypress-io/cypress/pull/8699 // An internal command is inserted to create a divider between diff --git a/packages/driver/src/cy/commands/screenshot.ts b/packages/driver/src/cy/commands/screenshot.ts index 69d747ed8550..4153d85fba5b 100644 --- a/packages/driver/src/cy/commands/screenshot.ts +++ b/packages/driver/src/cy/commands/screenshot.ts @@ -528,7 +528,7 @@ export default function (Commands, Cypress, cy, state, config) { // we are not limited to "within" subject // https://github.com/cypress-io/cypress/issues/14253 if (userOptions.capture !== 'runner') { - const withinSubject = cy.getSubjectFromChain(cy.state('withinSubjectChain') || []) + const withinSubject = cy.getSubjectFromChain(cy.state('withinSubjectChain')) if ($dom.isElement(withinSubject)) { subject = withinSubject diff --git a/packages/driver/src/cy/commands/traversals.ts b/packages/driver/src/cy/commands/traversals.ts index 8c654cf3d559..1c3ecae3fd91 100644 --- a/packages/driver/src/cy/commands/traversals.ts +++ b/packages/driver/src/cy/commands/traversals.ts @@ -102,8 +102,7 @@ export default (Commands, Cypress, cy) => { this.set('onFail', (err) => { switch (err.type) { case 'existence': { - const chainerId = this.get('chainerId') - const subjectChain = (cy.state('subjects') || {})[chainerId] + const subjectChain = cy.subjectChain(this.get('chainerId')) err.message += ` Queried from: @@ -127,7 +126,7 @@ export default (Commands, Cypress, cy) => { // or completely borks it $el.selector = selector || traversal - log && log.set({ + log && cy.state('current') === this && log.set({ $el, consoleProps: () => { return { diff --git a/packages/driver/src/cy/ensures.ts b/packages/driver/src/cy/ensures.ts index 80be1b5e8f9e..21c426c61095 100644 --- a/packages/driver/src/cy/ensures.ts +++ b/packages/driver/src/cy/ensures.ts @@ -110,9 +110,7 @@ export const create = (state: StateFunc, expect: $Cy['expect']) => { } const ensureChildCommand = (command, args) => { - const subjects = cy.state('subjects') - - if (subjects[command.get('chainerId')] === undefined) { + if (cy.subjectChain(command.get('chainerId')) === undefined) { const stringifiedArg = $utils.stringifyActual(args[0]) $errUtils.throwErrByPath('miscellaneous.invoking_child_without_parent', { @@ -208,14 +206,12 @@ export const create = (state: StateFunc, expect: $Cy['expect']) => { if ($dom.isDetached(subject)) { const current = state('current') - const cmd = name ?? current.get('name') - - const prev = current.get('prev') ? current.get('prev').get('name') : current.get('name') - const node = $dom.stringify(subject) + name = name ?? current.get('name') + const subjectChain = cy.subjectChain(current.get('chainerId')) - $errUtils.throwErrByPath('subject.not_attached', { + $errUtils.throwErrByPath('subject.detached_after_command', { onFail, - args: { cmd, prev, node }, + args: { name, subjectChain }, }) } } @@ -225,9 +221,12 @@ export const create = (state: StateFunc, expect: $Cy['expect']) => { const current = state('current') if ($dom.isJquery(subject) && subject.length === 0) { - const subjectChain = (cy.state('subjects') || {})[current.get('chainerId')] + const subjectChain = cy.subjectChain(current.get('chainerId')) + + const prevCommandWasQuery = current.get('prev').get('query') + const errMsg = prevCommandWasQuery ? 'subject.not_element_empty_subject' : 'subject.detached_after_command' - $errUtils.throwErrByPath('subject.not_element_empty_subject', { + $errUtils.throwErrByPath(errMsg, { onFail, args: { name: current.get('name'), diff --git a/packages/driver/src/cypress.ts b/packages/driver/src/cypress.ts index 15ac4bb4c001..b65734358e2f 100644 --- a/packages/driver/src/cypress.ts +++ b/packages/driver/src/cypress.ts @@ -239,7 +239,7 @@ class $Cypress { get: () => { $errUtils.warnByPath('subject.state_subject_deprecated') - return this.cy.currentSubject() + return this.cy.subject() }, }) @@ -247,7 +247,7 @@ class $Cypress { get: () => { $errUtils.warnByPath('subject.state_withinsubject_deprecated') - return this.cy.getSubjectFromChain(this.cy.state('withinSubjectChain') || []) + return this.cy.getSubjectFromChain(this.cy.state('withinSubjectChain')) }, }) diff --git a/packages/driver/src/cypress/command.ts b/packages/driver/src/cypress/command.ts index 9abb27d41886..6bda5caee2f2 100644 --- a/packages/driver/src/cypress/command.ts +++ b/packages/driver/src/cypress/command.ts @@ -35,14 +35,6 @@ export class $Command { return this } - snapshotLogs () { - this.get('logs').forEach((log) => { - if (!log.get('snapshots')) { - log.snapshot() - } - }) - } - finishLogs () { // TODO: Investigate whether or not we can reuse snapshots between logs // that snapshot at the same time @@ -53,6 +45,10 @@ export class $Command { log.snapshot() } + if (!log.get('snapshots')) { + log.snapshot() + } + if (log.get('_error')) { log.error(log.get('_error')) } else { @@ -60,14 +56,6 @@ export class $Command { log.finish() } }) - - // If the previous command is a query belonging to the same chainer, - // we also ask it to end its own logs (and so on, up the chain). - const prev = this.get('prev') - - if (prev && prev.get('query') && prev.get('chainerId') === this.get('chainerId')) { - prev.finishLogs() - } } log (log) { diff --git a/packages/driver/src/cypress/command_queue.ts b/packages/driver/src/cypress/command_queue.ts index cb9637b7df4b..9611321ea54d 100644 --- a/packages/driver/src/cypress/command_queue.ts +++ b/packages/driver/src/cypress/command_queue.ts @@ -92,7 +92,7 @@ function retryQuery (command: $Command, ret: any, cy: $Cy) { onFail: command.get('onFail'), ensureExistenceFor: command.get('ensureExistenceFor'), subjectFn: () => { - const subject = cy.currentSubject(command.get('chainerId')) + const subject = cy.subject(command.get('chainerId')) cy.ensureSubjectByType(subject, command.get('prevSubject'), command) @@ -305,11 +305,7 @@ export class CommandQueue extends Queue<$Command> { } command.set({ subject }) - - // When a command - query or normal - first passes, we verify that we have any snapshots needed. - // The logs may still be pending, but we want to know the state of the DOM now, before any subsequent commands - // run to alter it. - command.snapshotLogs() + command.finishLogs() if (isQuery) { subject = command.get('queryFn') @@ -325,17 +321,10 @@ export class CommandQueue extends Queue<$Command> { // This is done so that any query's logs remain in the 'pending' state until the subject chain is finished. this.cy.addQueryToChainer(command.get('chainerId'), subject) - - const next = command.get('next') - - if (!next || next.get('chainerId') !== command.get('chainerId') || !next.get('query')) { - command.finishLogs() - } } else { // For commands, the "subject" here is the command's return value, which replaces // the current subject chain. We cannot re-invoke commands - the return value here is final. this.cy.setSubjectForChainer(command.get('chainerId'), subject) - command.finishLogs() } // reset the nestedIndex back to null diff --git a/packages/driver/src/cypress/cy.ts b/packages/driver/src/cypress/cy.ts index 6247cd1a53d3..cd2507fc9bde 100644 --- a/packages/driver/src/cypress/cy.ts +++ b/packages/driver/src/cypress/cy.ts @@ -252,7 +252,8 @@ export class $Cy extends EventEmitter2 implements ITimeouts, IStability, IAssert this.setRunnable = this.setRunnable.bind(this) this.cleanup = this.cleanup.bind(this) this.setSubjectForChainer = this.setSubjectForChainer.bind(this) - this.currentSubject = this.currentSubject.bind(this) + this.subject = this.subject.bind(this) + this.subjectChain = this.subjectChain.bind(this) this.getSubjectFromChain = this.getSubjectFromChain.bind(this) // init traits @@ -1240,7 +1241,7 @@ export class $Cy extends EventEmitter2 implements ITimeouts, IStability, IAssert // TODO: make string[] more private pushSubject (name, args, prevSubject: string[], chainerId) { - const subject = this.currentSubject(chainerId) + const subject = this.subject(chainerId) if (prevSubject !== undefined) { // make sure our current subject is valid for @@ -1254,9 +1255,23 @@ export class $Cy extends EventEmitter2 implements ITimeouts, IStability, IAssert } /* - * Use `currentSubject()` to get the subject. It reads from cy.state('subjects'), but the format and details of + * Use `subject()` to get the current subject. It reads from cy.state('subjects'), but the format and details of * determining this should be considered an internal implementation detail of Cypress, subject to change at any time. * + * See subjectChain() for more details on state('subjects'). + */ + subject (chainerId?: string) { + const subjectChain: SubjectChain | undefined = this.subjectChain(chainerId) + + return this.getSubjectFromChain(subjectChain) + } + + /* + * Use subjectChain() to get a subjectChain, which you can later pass into getSubjectFromChain() to resolve + * the array into a specific DOM element or other value. It reads from cy.state('subjects'), but the format and + * details of determining this should be considered an internal implementation detail of Cypress, subject to change + * at any time. + * * Currently, state('subjects') is an object, mapping chainerIds to the current subject and queries for that * chainer. For example, it might look like: * @@ -1266,25 +1281,23 @@ export class $Cy extends EventEmitter2 implements ITimeouts, IStability, IAssert * 'ch-http://localhost:3500-4': [undefined, f(), f()], * } * - * Do not read cy.state('subjects') directly; This is what currentSubject() is for, turning this structure into a - * usable subject. + * A subject chain - the return value of this function - is one of these entries: a primitive value, followed by + * 0 or more functions operating on this value. + * + * Do not read cy.state('subjects') directly; This is what subject() or subjectChain() are for, turning this + * structure into a usable subject. */ - currentSubject (chainerId: string = this.state('chainerId')) { - const subjectChain: SubjectChain | undefined = (this.state('subjects') || {})[chainerId] - - if (subjectChain) { - return this.getSubjectFromChain(subjectChain) - } - - return undefined + subjectChain (chainerId: string = this.state('chainerId')) { + return (this.state('subjects') || {})[chainerId] } /* Given a chain of functions, return the actual subject. `subjectChain` might look like any of: + * [] * [] * ['foobar', f()] * [undefined, f(), f()] */ - getSubjectFromChain (subjectChain: SubjectChain) { + getSubjectFromChain (subjectChain: SubjectChain = []) { // If we're getting the subject of a previous command, then any log messages have already // been added to the command log; We don't want to re-add them every time we query // the current subject. @@ -1383,7 +1396,7 @@ export class $Cy extends EventEmitter2 implements ITimeouts, IStability, IAssert * * The command_queue calls addQueryToChainer after a query returns a function. This function is * is appended to the subject chain (which begins with 'undefined' if no previous subject exists), and used - * to resolve cy.currentSubject() as needed. + * to resolve cy.subject() as needed. */ addQueryToChainer (chainerId: string, queryFn: (subject: any) => any) { const cySubjects = this.state('subjects') || {} diff --git a/packages/driver/src/cypress/error_messages.ts b/packages/driver/src/cypress/error_messages.ts index 7bba08d2f573..e4b9a9767555 100644 --- a/packages/driver/src/cypress/error_messages.ts +++ b/packages/driver/src/cypress/error_messages.ts @@ -59,7 +59,7 @@ const queryFnToString = (queryFn) => `.${queryFn.commandName}(${queryFn.args.map export const subjectChainToString = (subjectChain) => { const [initial, ...queryFns] = subjectChain - const prefix = initial == null ? 'cy' : `${$utils.stringifyActual(initial)} -> ` + const prefix = initial == null ? 'cy' : `${$utils.stringifyActual(initial)}${queryFns.length ? ' -> ' : ''}` return prefix + queryFns.map(queryFnToString).join('') } @@ -1880,26 +1880,49 @@ export default { Cypress only considers the \`window\`, \`document\`, or any \`element\` to be valid DOM objects.` }, - not_attached (obj) { + detached_during_actionability (obj) { return { message: stripIndent`\ - ${cmd(obj.cmd)} failed because this element is detached from the DOM. + ${cmd(obj.name)} failed because the DOM updated while this command was executing. Cypress tried to locate elements based on this query: - \`${obj.node}\` + > ${subjectChainToString(obj.subjectChain)} - Cypress requires elements be attached in the DOM to interact with them. + We initially found matching element(s), but they disappeared from the DOM while ${cmd(obj.name)} waited for them to become actionable. Common situations why this happens: + - Your JS framework re-rendered asynchronously + - Your app code reacted to an event firing and removed the element - The previous command that ran was: + You can typically solve this by breaking up a chain. For example, rewrite: - > ${cmd(obj.prev)} + > \`cy.get('button').click().click()\` - This DOM element likely became detached somewhere between the previous and current command. + to + + > \`cy.get('button').click()\` + > \`cy.get('button').click()\` + + `, + docsUrl: 'https://on.cypress.io/element-has-detached-from-dom', + } + }, + detached_after_command (obj) { + return { + message: stripIndent`\ + ${cmd(obj.name)} failed because the DOM updated as a result of this command, but you tried to continue the command chain. Common situations why this happens: - Your JS framework re-rendered asynchronously - Your app code reacted to an event firing and removed the element - You typically need to re-query for the element or add 'guards' which delay Cypress from running new commands.`, + You can typically solve this by breaking up a chain. For example, rewrite: + + > \`cy.get('button').click().should('have.class', 'active')\` + + to + + > \`cy.get('button').click()\` + > \`cy.get('button').should('have.class', 'active')\` + + `, docsUrl: 'https://on.cypress.io/element-has-detached-from-dom', } }, @@ -1936,7 +1959,7 @@ export default { > ${subjectChainToString(obj.subjectChain)}` }, state_subject_deprecated: { - message: `${cmd('state', '\'subject\'')} has been deprecated and will be removed in a future release. Consider migrating to ${cmd('currentSubject')} instead.`, + message: `${cmd('state', '\'subject\'')} has been deprecated and will be removed in a future release. Consider migrating to ${cmd('subject')} instead.`, }, state_withinsubject_deprecated: { message: `${cmd('state', '\'withinSubject\'')} has been deprecated and will be removed in a future release. You should read ${cmd('state', '\'withinSubjectChain\'')} once at the top of your command / query, and resolve it into a value with ${cmd('getSubjectFromChain', 'withinSubjectChain')} as needed.`, diff --git a/packages/driver/src/cypress/log.ts b/packages/driver/src/cypress/log.ts index 4a66aab3b287..1c04a61b1857 100644 --- a/packages/driver/src/cypress/log.ts +++ b/packages/driver/src/cypress/log.ts @@ -168,7 +168,7 @@ const defaults = function (state: StateFunc, config, obj) { if (_.isFunction(obj.type)) { const chainerId = current && current.get('chainerId') - obj.type = obj.type(current, (state('subjects') || {})[chainerId]) + obj.type = obj.type(current, cy.subjectChain()) } } diff --git a/packages/driver/src/cypress/utils.ts b/packages/driver/src/cypress/utils.ts index 38d4d186940b..670adeded26b 100644 --- a/packages/driver/src/cypress/utils.ts +++ b/packages/driver/src/cypress/utils.ts @@ -403,28 +403,4 @@ export default { isPromiseLike (ret) { return ret && _.isFunction(ret.then) }, - - /* Given a chain of functions, return the actual subject. `subjectChain` might look like any of: - * [] - * ['foobar', f()] - * [undefined, f(), f()] - */ - getSubjectFromChain (subjectChain: SubjectChain, cy) { - // If we're getting the subject of a previous command, then any log messages have already - // been added to the command log; We don't want to re-add them every time we query - // the current subject. - cy.state('onBeforeLog', () => false) - - let subject = subjectChain[0] - - try { - for (let i = 1; i < subjectChain.length; i++) { - subject = subjectChain[i](subject) - } - } finally { - cy.state('onBeforeLog', null) - } - - return subject - }, } From 66262d4c696fb57f40966ef83c1db1df4f9f901b Mon Sep 17 00:00:00 2001 From: BlueWinds Date: Tue, 25 Oct 2022 15:23:21 -0700 Subject: [PATCH 33/54] More work on actionability + detached dom --- .../cypress/e2e/commands/actions/check.cy.js | 378 +----------------- .../cypress/e2e/commands/actions/clear.cy.js | 2 +- .../cypress/e2e/commands/actions/click.cy.js | 27 +- .../cypress/e2e/commands/actions/focus.cy.js | 4 +- .../cypress/e2e/commands/actions/scroll.cy.js | 19 +- .../driver/src/cy/commands/actions/click.ts | 5 +- .../driver/src/cy/commands/actions/scroll.ts | 76 ++-- packages/driver/src/cypress/error_messages.ts | 6 +- 8 files changed, 75 insertions(+), 442 deletions(-) diff --git a/packages/driver/cypress/e2e/commands/actions/check.cy.js b/packages/driver/cypress/e2e/commands/actions/check.cy.js index edd81eab408f..e9e8debcb05c 100644 --- a/packages/driver/cypress/e2e/commands/actions/check.cy.js +++ b/packages/driver/cypress/e2e/commands/actions/check.cy.js @@ -101,10 +101,11 @@ describe('src/cy/commands/actions/check', () => { it('requeries if the DOM rerenders during actionability', () => { cy.$$('[name=colors]').first().prop('disabled', true) - const listener = _.after(3, () => { + const listener = _.after(3, () => { cy.$$('[name=colors]').first().prop('disabled', false) const parent = cy.$$('[name=colors]').parent() + parent.replaceWith(parent[0].outerHTML) cy.off('command:retry', listener) }) @@ -457,7 +458,7 @@ describe('src/cy/commands/actions/check', () => { cy.on('fail', (err) => { expect(checked).to.eq(1) - expect(err.message).to.include('`cy.check()` failed because the DOM updated') + expect(err.message).to.include('`cy.check()` failed because the page updated') done() }) @@ -823,58 +824,6 @@ describe('src/cy/commands/actions/check', () => { }) context('#uncheck', () => { - it('does not change the subject', () => { - const inputs = $('[name=birds]') - - cy.get('[name=birds]').uncheck().then(($inputs) => { - expect($inputs.length).to.eq(2) - expect($inputs.toArray()).to.deep.eq(inputs.toArray()) - }) - }) - - it('changes the subject if specific value passed to check', () => { - const checkboxes = $('[name=birds]') - - cy.get('[name=birds]').check(['cockatoo', 'amazon']).then(($chk) => { - expect($chk.length).to.eq(2) - - const cockatoo = checkboxes.filter('[value=cockatoo]') - const amazon = checkboxes.filter('[value=amazon]') - - expect($chk.get(0)).to.eq(cockatoo.get(0)) - expect($chk.get(1)).to.eq(amazon.get(0)) - }) - }) - - it('filters out values which were not found', () => { - const checkboxes = $('[name=birds]') - - cy.get('[name=birds]').check(['cockatoo', 'parrot']).then(($chk) => { - expect($chk.length).to.eq(1) - - const cockatoo = checkboxes.filter('[value=cockatoo]') - - expect($chk.get(0)).to.eq(cockatoo.get(0)) - }) - }) - - it('changes the subject when matching values even if noop', () => { - const checked = $('') - - $('[name=birds]').parent().append(checked) - - const checkboxes = $('[name=birds]') - - cy.get('[name=birds]').check('cockatoo').then(($chk) => { - expect($chk.length).to.eq(2) - - const cockatoo = checkboxes.filter('[value=cockatoo]') - - expect($chk.get(0)).to.eq(cockatoo.get(0)) - expect($chk.get(1)).to.eq(cockatoo.get(1)) - }) - }) - // https://github.com/cypress-io/cypress/issues/19098 it('removes indeterminate prop when checkbox is unchecked', () => { const indeterminateCheckbox = $(``) @@ -923,103 +872,6 @@ describe('src/cy/commands/actions/check', () => { }) }) - it('can forcibly click even when being covered by another element', () => { - let clicked = false - const checkbox = $('').attr('id', 'checkbox-covered-in-span').prop('checked', true).prependTo($('body')) - - $('span on checkbox').css({ position: 'absolute', left: checkbox.offset().left, top: checkbox.offset().top, padding: 5, display: 'inline-block', backgroundColor: 'yellow' }).prependTo($('body')) - - checkbox.on('click', () => { - clicked = true - }) - - cy.get('#checkbox-covered-in-span').uncheck({ force: true }).then(() => { - expect(clicked).to.be.true - }) - }) - - it('passes timeout and interval down to click', (done) => { - const checkbox = $('').attr('id', 'checkbox-covered-in-span').prop('checked', true).prependTo($('body')) - - $('span on checkbox').css({ position: 'absolute', left: checkbox.offset().left, top: checkbox.offset().top, padding: 5, display: 'inline-block', backgroundColor: 'yellow' }).prependTo($('body')) - - cy.on('command:retry', (options) => { - expect(options.timeout).to.eq(1000) - expect(options.interval).to.eq(60) - - done() - }) - - cy.get('#checkbox-covered-in-span').uncheck({ timeout: 1000, interval: 60 }) - }) - - it('waits until element is no longer disabled', () => { - const chk = $(':checkbox:first').prop('checked', true).prop('disabled', true) - - let retried = false - let clicks = 0 - - chk.on('click', () => { - clicks += 1 - }) - - cy.on('command:retry', _.after(3, () => { - chk.prop('disabled', false) - retried = true - })) - - cy.get(':checkbox:first').uncheck().then(() => { - expect(clicks).to.eq(1) - expect(retried).to.be.true - }) - }) - - describe('assertion verification', () => { - beforeEach(function () { - cy.on('log:added', (attrs, log) => { - if (log.get('name') === 'assert') { - this.lastLog = log - } - }) - - return null - }) - - it('eventually passes the assertion', () => { - $(':checkbox:first').prop('checked', true).click(function () { - _.delay(() => { - $(this).addClass('unchecked') - }, 100) - }) - - cy.get(':checkbox:first').uncheck().should('have.class', 'unchecked').then(function () { - const { lastLog } = this - - expect(lastLog.get('name')).to.eq('assert') - expect(lastLog.get('state')).to.eq('passed') - expect(lastLog.get('ended')).to.be.true - }) - }) - }) - - describe('events', () => { - it('emits click event', (done) => { - $('[name=colors][value=blue]').prop('checked', true).click(() => { - done() - }) - - cy.get('[name=colors]').uncheck('blue') - }) - - it('emits change event', (done) => { - $('[name=colors][value=blue]').prop('checked', true).change(() => { - done() - }) - - cy.get('[name=colors]').uncheck('blue') - }) - }) - describe('errors', { defaultCommandTimeout: 100, }, () => { @@ -1052,140 +904,6 @@ describe('src/cy/commands/actions/check', () => { done() }) }) - - it('throws when any member of the subject isnt visible', function (done) { - // grab the first 3 checkboxes. - const chk = $(':checkbox').slice(0, 3).show() - - cy.on('fail', (err) => { - const { lastLog } = this - const len = (chk.length * 2) + 6 - - assertLogLength(this.logs, len) - expect(lastLog.get('error')).to.eq(err) - expect(err.message).to.include('`cy.uncheck()` failed because this element is not visible') - - done() - }) - - cy - .get(':checkbox').invoke('slice', 0, 3).check().last().invoke('hide') - .get(':checkbox').invoke('slice', 0, 3).uncheck() - }) - - it('logs once when not dom subject', function (done) { - cy.on('fail', (err) => { - const { lastLog } = this - - assertLogLength(this.logs, 1) - expect(lastLog.get('error')).to.eq(err) - - done() - }) - - cy.uncheck() - }) - - it('throws when subject is not in the document', (done) => { - let unchecked = 0 - - const checkbox = $(':checkbox:first').prop('checked', true).click((e) => { - unchecked += 1 - checkbox.prop('checked', true) - checkbox.remove() - - return false - }) - - cy.on('fail', (err) => { - expect(unchecked).to.eq(1) - expect(err.message).to.include('`cy.uncheck()` failed because the DOM updated') - - done() - }) - - cy.get(':checkbox:first').uncheck().uncheck() - }) - - it('throws when input cannot be clicked', function (done) { - const checkbox = $('').attr('id', 'checkbox-covered-in-span').prop('checked', true).prependTo($('body')) - - $('span on button').css({ position: 'absolute', left: checkbox.offset().left, top: checkbox.offset().top, padding: 5, display: 'inline-block', backgroundColor: 'yellow' }).prependTo($('body')) - - cy.on('fail', (err) => { - assertLogLength(this.logs, 2) - expect(err.message).to.include('`cy.uncheck()` failed because this element') - expect(err.message).to.include('is being covered by another element') - - done() - }) - - cy.get('#checkbox-covered-in-span').uncheck() - }) - - it('throws when subject is disabled', function (done) { - $(':checkbox:first').prop('checked', true).prop('disabled', true) - - cy.on('fail', (err) => { - // get + type logs - expect(this.logs.length).eq(2) - expect(err.message).to.include('`cy.uncheck()` failed because this element is `disabled`:\n') - - done() - }) - - cy.get(':checkbox:first').uncheck() - }) - - it('eventually passes the assertion on multiple :checkboxs', () => { - $(':checkbox').prop('checked', true).click(function () { - _.delay(() => { - $(this).addClass('unchecked') - }, 100) - }) - - cy.get(':checkbox').invoke('slice', 0, 2).uncheck().should('have.class', 'unchecked') - }) - - it('eventually fails the assertion', function (done) { - $(':checkbox:first').prop('checked', true) - - cy.on('fail', (err) => { - const { lastLog } = this - - expect(err.message).to.include(lastLog.get('error').message) - expect(err.message).not.to.include('undefined') - expect(lastLog.get('name')).to.eq('assert') - expect(lastLog.get('state')).to.eq('failed') - expect(lastLog.get('error')).to.be.an.instanceof(chai.AssertionError) - - done() - }) - - cy.get(':checkbox:first').uncheck().should('have.class', 'unchecked') - }) - - it('does not log an additional log on failure', function (done) { - cy.on('fail', () => { - assertLogLength(this.logs, 3) - - done() - }) - - cy.get(':checkbox:first').uncheck().should('have.class', 'unchecked') - }) - - it('throws when cmd recieves values but subject has no value attribute', function (done) { - cy.get('[name=dogs]').uncheck(['husky', 'poodle', 'on']).then(($chk) => { - expect($chk.length).to.eq(4) - }) - - cy.on('fail', (err) => { - expect(err.message).to.include(' cannot be checked/unchecked because it has no \`value\` attribute') - - done() - }) - }) }) describe('.log', () => { @@ -1199,87 +917,6 @@ describe('src/cy/commands/actions/check', () => { return null }) - it('logs immediately before resolving', (done) => { - const chk = $(':checkbox:first') - - cy.on('log:added', (attrs, log) => { - if (log.get('name') === 'uncheck') { - expect(log.get('state')).to.eq('pending') - expect(log.get('$el').get(0)).to.eq(chk.get(0)) - - done() - } - }) - - cy.get(':checkbox:first').check().uncheck() - }) - - it('snapshots before unchecking', function (done) { - $(':checkbox:first').change(() => { - const { lastLog } = this - - expect(lastLog.get('snapshots').length).to.eq(1) - expect(lastLog.get('snapshots')[0].name).to.eq('before') - expect(lastLog.get('snapshots')[0].body).to.be.an('object') - - done() - }) - - cy.get(':checkbox:first').invoke('prop', 'checked', true).uncheck() - }) - - it('snapshots after unchecking', () => { - cy.get(':checkbox:first').invoke('prop', 'checked', true).uncheck().then(function () { - const { lastLog } = this - - expect(lastLog.get('snapshots').length).to.eq(2) - expect(lastLog.get('snapshots')[1].name).to.eq('after') - expect(lastLog.get('snapshots')[1].body).to.be.an('object') - }) - }) - - it('logs only 1 uncheck event', () => { - const logs = [] - const unchecks = [] - - cy.on('log:added', (attrs, log) => { - logs.push(log) - if (log.get('name') === 'uncheck') { - unchecks.push(log) - } - }) - - cy.get('[name=colors][value=blue]').uncheck().then(() => { - expect(logs.length).to.eq(2) - expect(unchecks).to.have.length(1) - }) - }) - - it('logs only 1 uncheck event on uncheck with 1 matching value arg', () => { - const logs = [] - const unchecks = [] - - cy.on('log:added', (attrs, log) => { - logs.push(log) - if (log.get('name') === 'uncheck') { - unchecks.push(log) - } - }) - - cy.get('[name=colors]').uncheck('blue').then(() => { - expect(logs.length).to.eq(2) - expect(unchecks).to.have.length(1) - }) - }) - - it('passes in $el', () => { - cy.get('[name=colors][value=blue]').uncheck().then(function ($input) { - const { lastLog } = this - - expect(lastLog.get('$el').get(0)).to.eq($input.get(0)) - }) - }) - it('ends command when checkbox is already unchecked', () => { cy.get('[name=colors][value=blue]').invoke('prop', 'checked', false).uncheck().then(function () { const { lastLog } = this @@ -1333,15 +970,6 @@ describe('src/cy/commands/actions/check', () => { }) }) }) - - it('logs deltaOptions', () => { - cy.get('[name=colors][value=blue]').check().uncheck({ force: true, timeout: 1000 }).then(function () { - const { lastLog } = this - - expect(lastLog.get('message')).to.eq('{force: true, timeout: 1000}') - expect(lastLog.invoke('consoleProps').Options).to.deep.eq({ force: true, timeout: 1000 }) - }) - }) }) }) }) diff --git a/packages/driver/cypress/e2e/commands/actions/clear.cy.js b/packages/driver/cypress/e2e/commands/actions/clear.cy.js index c2dcf40342d8..09a0b6059d89 100644 --- a/packages/driver/cypress/e2e/commands/actions/clear.cy.js +++ b/packages/driver/cypress/e2e/commands/actions/clear.cy.js @@ -295,7 +295,7 @@ describe('src/cy/commands/actions/type - #clear', () => { cy.on('fail', (err) => { expect(cleared).to.be.calledOnce - expect(err.message).to.include('`cy.clear()` failed because the DOM updated') + expect(err.message).to.include('`cy.clear()` failed because the page updated') done() }) diff --git a/packages/driver/cypress/e2e/commands/actions/click.cy.js b/packages/driver/cypress/e2e/commands/actions/click.cy.js index db1d262f6138..d720bacd45d8 100644 --- a/packages/driver/cypress/e2e/commands/actions/click.cy.js +++ b/packages/driver/cypress/e2e/commands/actions/click.cy.js @@ -47,7 +47,7 @@ const getMidPoint = (el) => { const isFirefox = Cypress.isBrowser('firefox') const isWebKit = Cypress.isBrowser('webkit') -describe.skip('src/cy/commands/actions/click', () => { +describe('src/cy/commands/actions/click', () => { beforeEach(() => { cy.visit('/fixtures/dom.html') }) @@ -741,10 +741,11 @@ describe.skip('src/cy/commands/actions/click', () => { it('requeries if the DOM rerenders during actionability', () => { cy.$$('[name=colors]').first().prop('disabled', true) - const listener = _.after(3, () => { + const listener = _.after(3, () => { cy.$$('[name=colors]').first().prop('disabled', false) const parent = cy.$$('[name=colors]').parent() + parent.replaceWith(parent[0].outerHTML) cy.off('command:retry', listener) }) @@ -1618,7 +1619,6 @@ describe.skip('src/cy/commands/actions/click', () => { cy.on('command:retry', _.after(3, () => { $btn.replaceWith('') - retried = true })) cy.get('#button').click().should('contain', 'New Button') @@ -4432,27 +4432,6 @@ describe('mouse state', () => { expect(pointerenter).to.be.calledOnce }) }) - - it.only('can move mouse from a div to another div', () => { - let coords = { - clientX: 494, - clientY: 10, - // layerX: 492, - // layerY: 215, - pageX: 494, - pageY: 226, - screenX: 494, - screenY: 10, - x: 494, - y: 10, - } - - cy.$$('div.item').eq(1).one('mouseover', cy.stub().callsFake((e) => { - expect(e.clientX).closeTo(coords.clientX, 1) - })) - - cy.get('div.item').eq(1).click() - }) }) }) diff --git a/packages/driver/cypress/e2e/commands/actions/focus.cy.js b/packages/driver/cypress/e2e/commands/actions/focus.cy.js index 1a86f1048c45..b2a48f488cbc 100644 --- a/packages/driver/cypress/e2e/commands/actions/focus.cy.js +++ b/packages/driver/cypress/e2e/commands/actions/focus.cy.js @@ -336,7 +336,7 @@ describe('src/cy/commands/actions/focus', () => { cy.on('fail', (err) => { expect(focused).to.eq(1) - expect(err.message).to.include('`cy.focus()` failed because this element') + expect(err.message).to.include('`cy.focus()` failed because the page updated') done() }) @@ -791,7 +791,7 @@ describe('src/cy/commands/actions/focus', () => { cy.on('fail', (err) => { expect(blurred).to.eq(1) - expect(err.message).to.include('`cy.blur()` failed because this element') + expect(err.message).to.include('`cy.blur()` failed because the page') expect(err.docsUrl).to.include('https://on.cypress.io/element-has-detached-from-dom') done() diff --git a/packages/driver/cypress/e2e/commands/actions/scroll.cy.js b/packages/driver/cypress/e2e/commands/actions/scroll.cy.js index 3f2b01b2db6e..9271af775f12 100644 --- a/packages/driver/cypress/e2e/commands/actions/scroll.cy.js +++ b/packages/driver/cypress/e2e/commands/actions/scroll.cy.js @@ -323,7 +323,7 @@ describe('src/cy/commands/actions/scroll', () => { }) it('retries until element is scrollable', () => { - const $container = cy.$$('#nonscroll-becomes-scrollable') + let $container = cy.$$('#nonscroll-becomes-scrollable') expect($container.get(0).scrollTop).to.eq(0) expect($container.get(0).scrollLeft).to.eq(0) @@ -331,6 +331,12 @@ describe('src/cy/commands/actions/scroll', () => { let retried = false cy.on('command:retry', _.after(2, () => { + // Replacing the element with itself to ensure that .scrollTo() is requerying the DOM + // as necessary + $container.replaceWith($container[0].outerHTML) + $container.remove() + $container = cy.$$('#nonscroll-becomes-scrollable') + $container.css('overflow', 'scroll') retried = true })) @@ -450,6 +456,17 @@ describe('src/cy/commands/actions/scroll', () => { cy.get('button').scrollTo('500px') }) + + it('throws if subject disappears while waiting for scrollability', (done) => { + cy.on('command:retry', _.after(2, () => cy.$$('#nonscroll-becomes-scrollable').remove())) + + cy.on('fail', (err) => { + expect(err.message).to.include('`cy.scrollTo()` failed because the page updated') + done() + }) + + cy.get('#nonscroll-becomes-scrollable').scrollTo(500, 300) + }) }) context('argument errors', () => { diff --git a/packages/driver/src/cy/commands/actions/click.ts b/packages/driver/src/cy/commands/actions/click.ts index c4cbfaedecb0..0c272873f872 100644 --- a/packages/driver/src/cy/commands/actions/click.ts +++ b/packages/driver/src/cy/commands/actions/click.ts @@ -198,7 +198,7 @@ export default (Commands, Cypress, cy: $Cy, state, config) => { // once we establish the coordinates and the element // passes all of the internal checks return $actionability.verify(cy, $el, config, individualOptions, { - subjectFn: options.subjectFn || (() => cy.getSubjectFromChain(subjectChain).eq(index)), + subjectFn: options.subjectFn || (() => cy.getSubjectFromChain(subjectChain).eq(index)), onScroll ($el, type) { return Cypress.action('cy:scrolled', $el, type) @@ -210,6 +210,7 @@ export default (Commands, Cypress, cy: $Cy, state, config) => { const forceEl = options.force && $elToClick.get(0) const moveEvents = mouse.move(fromElViewport, forceEl) + clickedElements.push($el[0]) flagModifiers(true) @@ -243,7 +244,7 @@ export default (Commands, Cypress, cy: $Cy, state, config) => { return Promise .each(options.$el.toArray(), perform) .then(() => { -// options.$el = cy.$$(clickedElements) + options.$el = cy.$$(clickedElements) if (options.verify === false) { return options.$el diff --git a/packages/driver/src/cy/commands/actions/scroll.ts b/packages/driver/src/cy/commands/actions/scroll.ts index f918f0f459a4..3215aa7d9f5f 100644 --- a/packages/driver/src/cy/commands/actions/scroll.ts +++ b/packages/driver/src/cy/commands/actions/scroll.ts @@ -248,31 +248,9 @@ export default (Commands, Cypress, cy, state) => { x = 0 } - let $container let isWin - // if our subject is window let it fall through - if (subject && !$dom.isWindow(subject)) { - // if they passed something here, its a DOM element - $container = subject - } else { - isWin = true - // if we don't have a subject, then we are a parent command - // assume they want to scroll the entire window. - $container = state('window') - - // jQuery scrollTo looks for the prop contentWindow - // otherwise it'll use the wrong window to scroll :( - $container.contentWindow = $container - } - - // throw if we're trying to scroll multiple containers - if (!isWin && $container.length > 1) { - $errUtils.throwErrByPath('scrollTo.multiple_containers', { args: { num: $container.length } }) - } - const options: InternalScrollToOptions = _.defaults({}, userOptions, { - $el: $container, log: true, duration: 0, easing: 'swing', @@ -345,23 +323,53 @@ export default (Commands, Cypress, cy, state) => { }, } - if (!isWin) { - log.$el = options.$el - } - options._log = Cypress.log(log) } - const ensureScrollability = () => { - // Some elements are not scrollable, user may opt out of error checking - // https://github.com/cypress-io/cypress/issues/1924 - if (!options.ensureScrollable) { - return - } + const subjectChain = cy.subjectChain() + const ensureScrollability = () => { try { - // make sure our container can even be scrolled - return cy.ensureScrollability($container, 'scrollTo') + subject = cy.getSubjectFromChain(subjectChain) + + if (!subject || $dom.isWindow(subject)) { + isWin = true + // if we don't have a subject, then we are a parent command + // assume they want to scroll the entire window. + options.$el = state('window') + + // jQuery scrollTo looks for the prop contentWindow + // otherwise it'll use the wrong window to scroll :( + options.$el.contentWindow = options.$el + } else { + // if they passed something here, its a DOM element + options.$el = subject + + // scrollTo does not use the normal $actionability check, because that check contains, itself, scrolling + // logic. But we still want to throw the same error if our subject disappears while retrying. + if (options.$el.length === 0 || $dom.isDetached(options.$el)) { + const current = cy.state('current') + + $errUtils.throwErrByPath('subject.detached_during_actionability', { + args: { name: current.get('name'), subjectChain }, + }) + } + + cy.ensureElement(options.$el, 'scrollTo') + } + + // throw if we're trying to scroll multiple containers + if (!isWin && options.$el.length > 1) { + $errUtils.throwErrByPath('scrollTo.multiple_containers', { args: { num: options.$el.length } }) + } + + options._log.set('$el', options.$el) + + // Some elements are not scrollable, user may opt out of error checking + // https://github.com/cypress-io/cypress/issues/1924 + if (options.ensureScrollable) { + cy.ensureScrollability(options.$el, 'scrollTo') + } } catch (err) { options.error = err diff --git a/packages/driver/src/cypress/error_messages.ts b/packages/driver/src/cypress/error_messages.ts index e4b9a9767555..8f6bb85feb60 100644 --- a/packages/driver/src/cypress/error_messages.ts +++ b/packages/driver/src/cypress/error_messages.ts @@ -1883,11 +1883,11 @@ export default { detached_during_actionability (obj) { return { message: stripIndent`\ - ${cmd(obj.name)} failed because the DOM updated while this command was executing. Cypress tried to locate elements based on this query: + ${cmd(obj.name)} failed because the page updated while this command was executing. Cypress tried to locate elements based on this query: > ${subjectChainToString(obj.subjectChain)} - We initially found matching element(s), but they disappeared from the DOM while ${cmd(obj.name)} waited for them to become actionable. Common situations why this happens: + We initially found matching element(s), but while waiting for them to become actionable, they disappeared from the page. Common situations why this happens: - Your JS framework re-rendered asynchronously - Your app code reacted to an event firing and removed the element @@ -1907,7 +1907,7 @@ export default { detached_after_command (obj) { return { message: stripIndent`\ - ${cmd(obj.name)} failed because the DOM updated as a result of this command, but you tried to continue the command chain. + ${cmd(obj.name)} failed because the page updated as a result of this command, but you tried to continue the command chain. The subject is no longer attached to the DOM, and Cypress cannot requery the page after commands such as ${cmd(obj.name)}. Common situations why this happens: - Your JS framework re-rendered asynchronously From 7a29d467b1a91f12f4f7b5692a0bbb5d5adf8c9a Mon Sep 17 00:00:00 2001 From: BlueWinds Date: Wed, 26 Oct 2022 14:26:56 -0700 Subject: [PATCH 34/54] Fix TS, rename _addQuery to addQuery --- cli/types/cypress.d.ts | 3 ++ .../cypress/e2e/commands/actions/click.cy.js | 46 +--------------- .../cypress/e2e/commands/actions/scroll.cy.js | 1 - .../cypress/e2e/commands/actions/select.cy.js | 14 ++--- .../e2e/commands/actions/selectFile.cy.js | 31 ++++++----- .../cypress/e2e/commands/actions/submit.cy.js | 2 +- .../e2e/commands/actions/trigger.cy.js | 14 ++++- .../cypress/e2e/commands/actions/type.cy.js | 48 ++++++++++------- .../e2e/commands/actions/type_errors.cy.js | 4 +- .../cypress/e2e/commands/assertions.cy.js | 30 ++--------- .../cypress/e2e/commands/commands.cy.js | 8 +-- .../e2e/commands/querying/focused.cy.js | 2 +- .../e2e/commands/querying/querying.cy.js | 4 +- packages/driver/cypress/e2e/cypress/cy.cy.js | 28 +++------- packages/driver/cypress/support/utils.js | 2 +- packages/driver/src/cy/actionability.ts | 2 +- .../driver/src/cy/commands/actions/click.ts | 4 +- .../driver/src/cy/commands/actions/scroll.ts | 3 +- .../driver/src/cy/commands/actions/select.ts | 22 ++++---- .../src/cy/commands/actions/selectFile.ts | 49 ++++++++++------- .../driver/src/cy/commands/actions/trigger.ts | 4 ++ .../driver/src/cy/commands/actions/type.ts | 3 +- packages/driver/src/cy/commands/aliasing.ts | 4 +- packages/driver/src/cy/commands/connectors.ts | 4 +- packages/driver/src/cy/commands/debugging.ts | 4 +- packages/driver/src/cy/commands/location.ts | 6 +-- packages/driver/src/cy/commands/misc.ts | 4 +- .../src/cy/commands/querying/focused.ts | 2 +- .../src/cy/commands/querying/querying.ts | 6 +-- .../driver/src/cy/commands/querying/root.ts | 2 +- packages/driver/src/cy/commands/traversals.ts | 2 +- packages/driver/src/cy/commands/window.ts | 6 +-- packages/driver/src/cypress/commands.ts | 8 +-- packages/driver/src/cypress/cy.ts | 10 ++-- packages/driver/src/cypress/error_messages.ts | 54 +++++++++---------- .../driver/types/internal-types-lite.d.ts | 1 + 36 files changed, 205 insertions(+), 232 deletions(-) diff --git a/cli/types/cypress.d.ts b/cli/types/cypress.d.ts index a6ed3a74d577..977d717ab214 100644 --- a/cli/types/cypress.d.ts +++ b/cli/types/cypress.d.ts @@ -486,6 +486,9 @@ declare namespace Cypress { ): void overwrite(name: T, fn: CommandFnWithOriginalFn): void overwrite(name: T, fn: CommandFnWithOriginalFnAndSubject): void + + addQuery(name: T, fn: QueryFn): void + overwriteQuery(name: T, fn: QueryFn): void } /** diff --git a/packages/driver/cypress/e2e/commands/actions/click.cy.js b/packages/driver/cypress/e2e/commands/actions/click.cy.js index d720bacd45d8..5a63fcbdc173 100644 --- a/packages/driver/cypress/e2e/commands/actions/click.cy.js +++ b/packages/driver/cypress/e2e/commands/actions/click.cy.js @@ -2155,7 +2155,7 @@ describe('src/cy/commands/actions/click', () => { // The error message tells the user exactly how to fix this case. it('throws when subject is detached during actionability', (done) => { cy.on('fail', (err) => { - expect(err.message).to.include('`cy.click()` failed because the DOM updated while this command was executing.') + expect(err.message).to.include('`cy.click()` failed because the page updated while this command was executing.') expect(err.message).to.include('You can typically solve this by breaking up a chain.') done() @@ -2163,9 +2163,7 @@ describe('src/cy/commands/actions/click', () => { cy.get('input:first') .then(($el) => { - cy.on('scrolled', () => { - $el.remove() - }) + cy.on('scrolled', () => $el.remove()) }) .click() }) @@ -3280,26 +3278,6 @@ describe('src/cy/commands/actions/click', () => { cy.dblclick() }) - it('throws when subject is not in the document', (done) => { - let dblclicked = 0 - - const $button = cy.$$('button:first').dblclick(() => { - dblclicked += 1 - $button.remove() - - return false - }) - - cy.on('fail', (err) => { - expect(dblclicked).to.eq(1) - expect(err.message).to.include('`cy.dblclick()` failed because the DOM updated as a result of this command') - - done() - }) - - cy.get('button:first').dblclick().dblclick() - }) - it('logs once when not dom subject', function (done) { cy.on('fail', (err) => { const { lastLog } = this @@ -3719,26 +3697,6 @@ describe('src/cy/commands/actions/click', () => { cy.rightclick() }) - it('throws when subject is not in the document', (done) => { - let rightclicked = 0 - - const $button = cy.$$('button:first').on('contextmenu', () => { - rightclicked += 1 - $button.remove() - - return false - }) - - cy.on('fail', (err) => { - expect(rightclicked).to.eq(1) - expect(err.message).to.include('`cy.rightclick()` failed because the DOM updated as a result of this command') - - done() - }) - - cy.get('button:first').rightclick().rightclick() - }) - it('logs once when not dom subject', function (done) { cy.on('fail', (err) => { const { lastLog } = this diff --git a/packages/driver/cypress/e2e/commands/actions/scroll.cy.js b/packages/driver/cypress/e2e/commands/actions/scroll.cy.js index 9271af775f12..c5c38fe99c15 100644 --- a/packages/driver/cypress/e2e/commands/actions/scroll.cy.js +++ b/packages/driver/cypress/e2e/commands/actions/scroll.cy.js @@ -334,7 +334,6 @@ describe('src/cy/commands/actions/scroll', () => { // Replacing the element with itself to ensure that .scrollTo() is requerying the DOM // as necessary $container.replaceWith($container[0].outerHTML) - $container.remove() $container = cy.$$('#nonscroll-becomes-scrollable') $container.css('overflow', 'scroll') diff --git a/packages/driver/cypress/e2e/commands/actions/select.cy.js b/packages/driver/cypress/e2e/commands/actions/select.cy.js index f0ed01b70247..8dcecb9285fd 100644 --- a/packages/driver/cypress/e2e/commands/actions/select.cy.js +++ b/packages/driver/cypress/e2e/commands/actions/select.cy.js @@ -214,11 +214,13 @@ describe('src/cy/commands/actions/select', () => { const select = cy.$$('select[name=disabled]') cy.on('command:retry', _.once(() => { - select.prop('disabled', false) + // Replace the element with a copy of itself, to ensure .select() is requerying the DOM + select.replaceWith(select[0].outerHTML) + cy.$$('select[name=disabled]').prop('disabled', false) })) cy.get('select[name=disabled]').select('foo') - .invoke('val').should('eq', 'foo') + cy.get('select[name=disabled]').invoke('val').should('eq', 'foo') }) it('retries until is no longer disabled', () => { @@ -376,7 +378,7 @@ describe('src/cy/commands/actions/select', () => { cy.on('fail', (err) => { expect(selected).to.eq(1) - expect(err.message).to.include('`cy.select()` failed because this element') + expect(err.message).to.include('`cy.select()` failed because the page updated') done() }) @@ -543,8 +545,7 @@ describe('src/cy/commands/actions/select', () => { it('throws when the is disabled by a disabled
    ', (done) => { cy.on('fail', (err) => { - expect(err.message).to.include('`cy.select()` failed because this element is currently disabled:') - expect(err.docsUrl).to.eq('https://on.cypress.io/select') + expect(err.message).to.include('`cy.select()` failed because this element is `disabled`:') done() }) diff --git a/packages/driver/cypress/e2e/commands/actions/selectFile.cy.js b/packages/driver/cypress/e2e/commands/actions/selectFile.cy.js index e75eb986615f..c32423ae87ec 100644 --- a/packages/driver/cypress/e2e/commands/actions/selectFile.cy.js +++ b/packages/driver/cypress/e2e/commands/actions/selectFile.cy.js @@ -628,23 +628,30 @@ is being covered by another element: }) }) - it('waits until input stops animating', { - defaultCommandTimeout: 1000, - }, () => { - let retries = 0 + it('retries until label is not disabled', () => { + cy.on('command:retry', () => { + // Replace the label with a copy of itself, to ensure selectFile is requerying the DOM + const hidden = cy.$$('#hidden-basic-label') - cy.on('command:retry', (obj) => { - retries += 1 + hidden.replaceWith(hidden[0].outerHTML) + + cy.$$('#hidden-basic-label').show() }) - cy.stub(cy, 'ensureElementIsNotAnimating') - .throws(new Error('animating!')) - .onThirdCall().returns() + cy.get('#hidden-basic-label').selectFile({ contents: '@foo' }, { timeout: 1000 }) + }) + + it('retries until input is not disabled', () => { + cy.on('command:retry', () => { + // Replace the input with a copy of itself, to ensure selectFile is requerying the DOM + const disabled = cy.$$('#disabled') - cy.get('#basic').selectFile({ contents: '@foo' }).then(() => { - expect(retries).to.eq(3) - expect(cy.ensureElementIsNotAnimating).to.be.calledThrice + disabled.replaceWith(disabled[0].outerHTML) + + cy.$$('#disabled').attr('disabled', false) }) + + cy.get('#disabled-label').selectFile({ contents: '@foo' }) }) // TODO(webkit): fix+unskip for experimental webkit diff --git a/packages/driver/cypress/e2e/commands/actions/submit.cy.js b/packages/driver/cypress/e2e/commands/actions/submit.cy.js index ce3ecda8f4d8..b7752d2d4fa7 100644 --- a/packages/driver/cypress/e2e/commands/actions/submit.cy.js +++ b/packages/driver/cypress/e2e/commands/actions/submit.cy.js @@ -255,7 +255,7 @@ describe('src/cy/commands/actions/submit', () => { cy.on('fail', (err) => { expect(submitted).to.eq(1) - expect(err.message).to.include('`cy.submit()` failed because this element') + expect(err.message).to.include('`cy.submit()` failed because the page') done() }) diff --git a/packages/driver/cypress/e2e/commands/actions/trigger.cy.js b/packages/driver/cypress/e2e/commands/actions/trigger.cy.js index 0e2e3c7f4b2d..90269317192f 100644 --- a/packages/driver/cypress/e2e/commands/actions/trigger.cy.js +++ b/packages/driver/cypress/e2e/commands/actions/trigger.cy.js @@ -149,6 +149,18 @@ describe('src/cy/commands/actions/trigger', () => { }) }) + it('requeries the dom while waiting for actionability', () => { + const $input = cy.$$('input:first').attr('disabled', true) + + cy.on('command:retry', () => { + // Replace the input with a copy of itself, to ensure trigger is requerying the DOM + $input.replaceWith($input[0].outerHTML) + cy.$$('input:first').attr('disabled', false) + }) + + cy.get('input:first').trigger('keydown') + }) + it('can trigger events on the window', () => { let expected = false @@ -1057,7 +1069,7 @@ describe('src/cy/commands/actions/trigger', () => { cy.on('fail', (err) => { expect(mouseover).to.eq(1) - expect(err.message).to.include('`cy.trigger()` failed because this element') + expect(err.message).to.include('`cy.trigger()` failed because the page') done() }) diff --git a/packages/driver/cypress/e2e/commands/actions/type.cy.js b/packages/driver/cypress/e2e/commands/actions/type.cy.js index 301f660b098e..d0077401c4e0 100644 --- a/packages/driver/cypress/e2e/commands/actions/type.cy.js +++ b/packages/driver/cypress/e2e/commands/actions/type.cy.js @@ -264,7 +264,10 @@ describe('src/cy/commands/actions/type - #type', () => { const retried = cy.stub() cy.on('command:retry', _.after(3, () => { - $txt.show() + // Replace the element with a copy of itself, to ensure that .type() requeries the DOM + // while retrying actionability + $txt.replaceWith($txt[0].innerHTML) + cy.$$(':text:first').show() retried() })) @@ -924,7 +927,8 @@ describe('src/cy/commands/actions/type - #type', () => { }) it('inserts text after existing text input by invoking val', () => { - cy.get('#input-without-value').invoke('val', 'foo').type(' bar').then(($text) => { + cy.get('#input-without-value').invoke('val', 'foo') + cy.get('#input-without-value').type(' bar').then(($text) => { expect($text).to.have.value('foo bar') }) }) @@ -1224,7 +1228,8 @@ describe('src/cy/commands/actions/type - #type', () => { }) it('inserts text after existing text input by invoking val', () => { - cy.get('#number-without-value').invoke('val', '12').type('34').then(($text) => { + cy.get('#number-without-value').invoke('val', '12') + cy.get('#number-without-value').type('34').then(($text) => { expect($text).to.have.value('1234') }) }) @@ -1344,7 +1349,8 @@ describe('src/cy/commands/actions/type - #type', () => { }) it('inserts text after existing text input by invoking val', () => { - cy.get('#email-without-value').invoke('val', 'brian@foo.c').type('om').then(($text) => { + cy.get('#email-without-value').invoke('val', 'brian@foo.c') + cy.get('#email-without-value').type('om').then(($text) => { expect($text).to.have.value('brian@foo.com') }) }) @@ -1384,7 +1390,8 @@ describe('src/cy/commands/actions/type - #type', () => { }) it('inserts text after existing text input by invoking val', () => { - cy.get('#password-without-value').invoke('val', 'secr').type('et').then(($text) => { + cy.get('#password-without-value').invoke('val', 'secr') + cy.get('#password-without-value').type('et').then(($text) => { expect($text).to.have.value('secret') }) }) @@ -1441,7 +1448,8 @@ describe('src/cy/commands/actions/type - #type', () => { }) it('overwrites existing value input by invoking val', () => { - cy.get('#date-without-value').invoke('val', '2016-01-01').type('1959-09-13').then(($text) => { + cy.get('#date-without-value').invoke('val', '2016-01-01') + cy.get('#date-without-value').type('1959-09-13').then(($text) => { expect($text).to.have.value('1959-09-13') }) }) @@ -1531,7 +1539,8 @@ describe('src/cy/commands/actions/type - #type', () => { }) it('overwrites existing value input by invoking val', () => { - cy.get('[type="datetime-local"]').invoke('val', '2016-01-01T05:05').type('1959-09-13T10:10').should('have.value', '1959-09-13T10:10') + cy.get('[type="datetime-local"]').invoke('val', '2016-01-01T05:05') + cy.get('[type="datetime-local"]').type('1959-09-13T10:10').should('have.value', '1959-09-13T10:10') }) it('errors when invalid datetime', (done) => { @@ -1559,7 +1568,8 @@ describe('src/cy/commands/actions/type - #type', () => { }) it('overwrites existing value input by invoking val', () => { - cy.get('#month-without-value').invoke('val', '2016-01').type('1959-09').then(($text) => { + cy.get('#month-without-value').invoke('val', '2016-01') + cy.get('#month-without-value').type('1959-09').then(($text) => { expect($text).to.have.value('1959-09') }) }) @@ -1579,7 +1589,8 @@ describe('src/cy/commands/actions/type - #type', () => { }) it('overwrites existing value input by invoking val', () => { - cy.get('#week-without-value').invoke('val', '2016-W01').type('1959-W09').then(($text) => { + cy.get('#week-without-value').invoke('val', '2016-W01') + cy.get('#week-without-value').type('1959-W09').then(($text) => { expect($text).to.have.value('1959-W09') }) }) @@ -1599,7 +1610,8 @@ describe('src/cy/commands/actions/type - #type', () => { }) it('overwrites existing value input by invoking val', () => { - cy.get('#time-without-value').invoke('val', '01:23:45').type('12:34:56').then(($text) => { + cy.get('#time-without-value').invoke('val', '01:23:45') + cy.get('#time-without-value').type('12:34:56').then(($text) => { expect($text).to.have.value('12:34:56') }) }) @@ -1643,7 +1655,8 @@ describe('src/cy/commands/actions/type - #type', () => { }) it('inserts text after existing text', () => { - cy.get('#input-types [contenteditable]').invoke('text', 'foo').type(' bar').then(($text) => { + cy.get('#input-types [contenteditable]').invoke('text', 'foo') + cy.get('#input-types [contenteditable]').type(' bar').then(($text) => { expect($text).to.have.text('foo bar') }) }) @@ -1668,9 +1681,8 @@ describe('src/cy/commands/actions/type - #type', () => { attachKeyListeners({ ce }) - cy.get('#input-types [contenteditable]') - .invoke('text', 'foo') - .type('{enter}') + cy.get('#input-types [contenteditable]').invoke('text', 'foo') + cy.get('#input-types [contenteditable]').type('{enter}') .should(($text) => { expect(trimInnerText($text)).eq('foo') }) @@ -2488,7 +2500,8 @@ describe('src/cy/commands/actions/type - #type', () => { describe('case-insensitivity', () => { it('special chars are case-insensitive', () => { - cy.get(':text:first').invoke('val', 'bar').type('{leftarrow}{DeL}').then(($input) => { + cy.get(':text:first').invoke('val', 'bar') + cy.get(':text:first').type('{leftarrow}{DeL}').then(($input) => { expect($input).to.have.value('ba') }) }) @@ -2787,9 +2800,8 @@ describe('src/cy/commands/actions/type - #type', () => { // even if actual and expected appear the same. const expected = '{\n foo: 1\n bar: 2\n baz: 3\n}' - cy.get('[contenteditable]:first') - .invoke('html', '

    ') - .type('{{}{enter} foo: 1{enter} bar: 2{enter} baz: 3{enter}}') + cy.get('[contenteditable]:first').invoke('html', '

    ') + cy.get('[contenteditable]:first').type('{{}{enter} foo: 1{enter} bar: 2{enter} baz: 3{enter}}') .should(($el) => { expectMatchInnerText($el, expected) }) 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 b5c864ee7a4e..8cfcb4481ec2 100644 --- a/packages/driver/cypress/e2e/commands/actions/type_errors.cy.js +++ b/packages/driver/cypress/e2e/commands/actions/type_errors.cy.js @@ -40,7 +40,7 @@ describe('src/cy/commands/actions/type - #type errors', () => { cy.on('fail', (err) => { expect(typed).to.be.calledOnce - expect(err.message).to.include('`cy.type()` failed because this element') + expect(err.message).to.include('`cy.type()` failed because the page') done() }) @@ -249,6 +249,8 @@ If you want to skip parsing special character sequences and type the text exactl it('can type into input with invalid type attribute', () => { cy.get(':text:first') .invoke('attr', 'type', 'asdf') + + cy.get(':text:first') .type('foobar') .should('have.value', 'foobar') }) diff --git a/packages/driver/cypress/e2e/commands/assertions.cy.js b/packages/driver/cypress/e2e/commands/assertions.cy.js index bf0b4d00c338..ee44c9d44a62 100644 --- a/packages/driver/cypress/e2e/commands/assertions.cy.js +++ b/packages/driver/cypress/e2e/commands/assertions.cy.js @@ -646,28 +646,6 @@ describe('src/cy/commands/assertions', () => { cy.readFile('does-not-exist.json').should('exist') }) - it('throws when the subject isnt in the DOM', function (done) { - cy.$$('button:first').click(function () { - $(this).addClass('foo').remove() - }) - - cy.on('fail', (err) => { - const names = _.invokeMap(this.logs, 'get', 'name') - - // the 'should' is not here because based on - // when we check for the element to be detached - // it never actually runs the assertion - expect(names).to.deep.eq(['get', 'click']) - expect(err.message).to.include('`cy.should()` failed because this element is detached') - - done() - }) - - cy.get('button:first').click().should('have.class', 'foo').then(() => { - done('cy.should was supposed to fail') - }) - }) - it('throws when the subject eventually isnt in the DOM', function (done) { cy.timeout(200) @@ -682,14 +660,12 @@ describe('src/cy/commands/assertions', () => { // should is present here due to the retry expect(names).to.deep.eq(['get', 'click', 'assert']) - expect(err.message).to.include('`cy.should()` failed because this element is detached') + expect(err.message).to.include('`cy.should()` failed because the page updated') done() }) - cy.get('button:first').click().should('have.class', 'foo').then(() => { - done('cy.should was supposed to fail') - }) + cy.get('button:first').click().should('have.class', 'foo') }) it('throws when should(\'have.length\') isnt a number', function (done) { @@ -1496,7 +1472,7 @@ describe('src/cy/commands/assertions', () => { it('fails not.visible for detached DOM', function (done) { cy.on('fail', (err) => { - expect(err.message).include('detached') + expect(err.message).include('`cy.should()` failed because the page updated') done() }) diff --git a/packages/driver/cypress/e2e/commands/commands.cy.js b/packages/driver/cypress/e2e/commands/commands.cy.js index b9eb9ea7c0e6..81b0940ab8c7 100644 --- a/packages/driver/cypress/e2e/commands/commands.cy.js +++ b/packages/driver/cypress/e2e/commands/commands.cy.js @@ -90,13 +90,13 @@ describe('src/cy/commands/commands', () => { it('throws when attempting to add an existing query', (done) => { cy.on('fail', (err) => { - expect(err.message).to.eq('`Cypress.Commands._addQuery()` is used to create new queries, but `get` is an existing Cypress command or query, or is reserved internally by Cypress.\n\n If you want to override an existing command or query, use `Cypress.Commands.overrideQuery()` instead.') + expect(err.message).to.eq('`Cypress.Commands.addQuery()` is used to create new queries, but `get` is an existing Cypress command or query, or is reserved internally by Cypress.\n\n If you want to override an existing command or query, use `Cypress.Commands.overrideQuery()` instead.') expect(err.docsUrl).to.eq('https://on.cypress.io/custom-commands') done() }) - Cypress.Commands._addQuery('get', () => { + Cypress.Commands.addQuery('get', () => { cy .get('[contenteditable]') .first() @@ -138,13 +138,13 @@ describe('src/cy/commands/commands', () => { it('throws when attempting to add a query with the same name as an internal function', (done) => { cy.on('fail', (err) => { - expect(err.message).to.eq('`Cypress.Commands._addQuery()` cannot create a new query named `addCommand` because that name is reserved internally by Cypress.') + expect(err.message).to.eq('`Cypress.Commands.addQuery()` cannot create a new query named `addCommand` because that name is reserved internally by Cypress.') expect(err.docsUrl).to.eq('https://on.cypress.io/custom-commands') done() }) - Cypress.Commands._addQuery('addCommand', () => { + Cypress.Commands.addQuery('addCommand', () => { cy .get('[contenteditable]') .first() diff --git a/packages/driver/cypress/e2e/commands/querying/focused.cy.js b/packages/driver/cypress/e2e/commands/querying/focused.cy.js index 1619091130c7..9ade66e6e1c4 100644 --- a/packages/driver/cypress/e2e/commands/querying/focused.cy.js +++ b/packages/driver/cypress/e2e/commands/querying/focused.cy.js @@ -123,7 +123,7 @@ describe('src/cy/commands/querying', () => { cy.get('input:first').focused().then(function ($input) { const { lastLog } = this - expect(lastLog.get('$el')).to.eq($input) + expect(lastLog.get('$el')).to.eql($input) }) }) diff --git a/packages/driver/cypress/e2e/commands/querying/querying.cy.js b/packages/driver/cypress/e2e/commands/querying/querying.cy.js index 69bbe497b10f..76880c27456d 100644 --- a/packages/driver/cypress/e2e/commands/querying/querying.cy.js +++ b/packages/driver/cypress/e2e/commands/querying/querying.cy.js @@ -419,10 +419,10 @@ describe('src/cy/commands/querying', () => { referencesAlias: undefined, } - expect(this.lastLog.get('$el')).to.eq($body) + expect(this.lastLog.get('$el')).to.eql($body) _.each(obj, (value, key) => { - expect(this.lastLog.get(key)).deep.eq(value, `expected key: ${key} to eq value: ${value}`) + expect(this.lastLog.get(key)).to.eq(value, `expected key: ${key} to eq value: ${value}`) }) }) }) diff --git a/packages/driver/cypress/e2e/cypress/cy.cy.js b/packages/driver/cypress/e2e/cypress/cy.cy.js index 9da5e38700bc..a5f5b83fa293 100644 --- a/packages/driver/cypress/e2e/cypress/cy.cy.js +++ b/packages/driver/cypress/e2e/cypress/cy.cy.js @@ -300,22 +300,6 @@ describe('driver/src/cypress/cy', () => { cy.c('bar') }) - it('fails when previous subject becomes detached', (done) => { - cy.$$('#button').click(function () { - return $(this).remove() - }) - - cy.on('fail', (err) => { - expect(err.message).to.include('`cy.parent()` failed because the DOM updated as a result of this command, but you tried to continue the command chain.') - expect(err.message).to.include('You can typically solve this by breaking up a chain.') - expect(err.docsUrl).to.eq('https://on.cypress.io/element-has-detached-from-dom') - - done() - }) - - cy.get('#button').click().parent({ timeout: 50 }) - }) - it('fails when previous subject isnt window', (done) => { cy.on('fail', (err) => { expect(err.message).to.include('`cy.winOnly()` failed because it requires the subject be a global `window` object.') @@ -501,7 +485,7 @@ describe('driver/src/cypress/cy', () => { done() }) - Cypress.Commands._overwriteQuery('aQuery', () => Promise.resolve()) + Cypress.Commands.overwriteQuery('aQuery', () => Promise.resolve()) cy.aQuery() }) @@ -511,7 +495,7 @@ describe('driver/src/cypress/cy', () => { done() }) - Cypress.Commands._overwriteQuery('aQuery', () => 1) + Cypress.Commands.overwriteQuery('aQuery', () => 1) cy.aQuery() }) @@ -521,7 +505,7 @@ describe('driver/src/cypress/cy', () => { done() }) - Cypress.Commands._overwriteQuery('aQuery', () => cy.visit('/')) + Cypress.Commands.overwriteQuery('aQuery', () => cy.visit('/')) cy.aQuery() }) @@ -537,13 +521,13 @@ describe('driver/src/cypress/cy', () => { cy.on('log:added', (attrs, log) => logs.push(log)) - Cypress.Commands._overwriteQuery('aQuery', () => { + Cypress.Commands.overwriteQuery('aQuery', () => { cy.now('get', 'body') return cy.now('get', 'button') }) - Cypress.Commands._overwriteQuery('bQuery', () => cy.now('aQuery')) + Cypress.Commands.overwriteQuery('bQuery', () => cy.now('aQuery')) cy.aQuery().should('have.length', 24) cy.then(() => { @@ -552,7 +536,7 @@ describe('driver/src/cypress/cy', () => { }) }) - it("closes each log as the query completes", (done) => { + it('closes each log as the query completes', (done) => { let getLog cy.on('log:added', (attrs, log) => { diff --git a/packages/driver/cypress/support/utils.js b/packages/driver/cypress/support/utils.js index c59634fccf28..357f75db65fc 100644 --- a/packages/driver/cypress/support/utils.js +++ b/packages/driver/cypress/support/utils.js @@ -139,7 +139,7 @@ export const expectCaret = (start, end) => { } } -Cypress.Commands._addQuery('getAll', getAllFn) +Cypress.Commands.addQuery('getAll', getAllFn) Cypress.Commands.add('shouldWithTimeout', shouldWithTimeout) diff --git a/packages/driver/src/cy/actionability.ts b/packages/driver/src/cy/actionability.ts index 74a72db4b8dd..7809af9f616e 100644 --- a/packages/driver/src/cy/actionability.ts +++ b/packages/driver/src/cy/actionability.ts @@ -291,7 +291,7 @@ const ensureNotAnimating = function (cy, $el, coordsHistory, animationDistanceTh } interface VerifyCallbacks { - onReady?: ($el: any, coords: ElementPositioning) => any + onReady?: (finalEl: any, coords: ElementPositioning, $el: any) => any onScroll?: ($el: any, type: 'element' | 'window' | 'container') => any subjectFn?: () => any } diff --git a/packages/driver/src/cy/commands/actions/click.ts b/packages/driver/src/cy/commands/actions/click.ts index 0c272873f872..40377fe76fff 100644 --- a/packages/driver/src/cy/commands/actions/click.ts +++ b/packages/driver/src/cy/commands/actions/click.ts @@ -39,7 +39,7 @@ const formatMouseEvents = (events) => { // TODO: remove any, Function, Record type MouseActionOptions = { subject: any - subjectFn: () => any + subjectFn?: () => any positionOrX: string | number y: number userOptions: Record @@ -107,7 +107,7 @@ export default (Commands, Cypress, cy: $Cy, state, config) => { } const subjectChain = cy.subjectChain() - const clickedElements = [] + const clickedElements: any[] = [] const perform = (el, index) => { let deltaOptions diff --git a/packages/driver/src/cy/commands/actions/scroll.ts b/packages/driver/src/cy/commands/actions/scroll.ts index 3215aa7d9f5f..c8d88ae9f4a6 100644 --- a/packages/driver/src/cy/commands/actions/scroll.ts +++ b/packages/driver/src/cy/commands/actions/scroll.ts @@ -251,6 +251,7 @@ export default (Commands, Cypress, cy, state) => { let isWin const options: InternalScrollToOptions = _.defaults({}, userOptions, { + $el: subject, log: true, duration: 0, easing: 'swing', @@ -363,7 +364,7 @@ export default (Commands, Cypress, cy, state) => { $errUtils.throwErrByPath('scrollTo.multiple_containers', { args: { num: options.$el.length } }) } - options._log.set('$el', options.$el) + options._log?.set('$el', options.$el) // Some elements are not scrollable, user may opt out of error checking // https://github.com/cypress-io/cypress/issues/1924 diff --git a/packages/driver/src/cy/commands/actions/select.ts b/packages/driver/src/cy/commands/actions/select.ts index e21e2e31d806..7b5f5d2ff357 100644 --- a/packages/driver/src/cy/commands/actions/select.ts +++ b/packages/driver/src/cy/commands/actions/select.ts @@ -48,7 +48,6 @@ export default (Commands, Cypress, cy) => { options._log = Cypress.log({ message: deltaOptions, - $el: options.$el, timeout: options.timeout, consoleProps () { // merge into consoleProps without mutating it @@ -104,13 +103,16 @@ export default (Commands, Cypress, cy) => { $errUtils.throwErrByPath('select.invalid_multiple') } + const subjectChain = cy.subjectChain() + const getOptions = () => { + options.$el = cy.getSubjectFromChain(subjectChain) let notAllUniqueValues // throw if element - if (options.action === 'select') { - if (eventTarget.is('label')) { - eventTarget = $dom.getInputFromLabel(eventTarget) - } + if (options.action === 'select') { + if (eventTarget.is('label')) { + eventTarget = $dom.getInputFromLabel(eventTarget) + } - if (eventTarget.length < 1 || !$dom.isInputType(eventTarget, 'file')) { - const node = $dom.stringify(options.$el) + if (eventTarget.length < 1 || !$dom.isInputType(eventTarget, 'file')) { + const node = $dom.stringify(options.$el) - $errUtils.throwErrByPath('selectFile.not_file_input', { - onFail: options._log, - args: { node }, - }) + $errUtils.throwErrByPath('selectFile.not_file_input', { + onFail: options._log, + args: { node }, + }) + } } - } - if (!options.force) { - cy.ensureNotDisabled(eventTarget, options._log) + if (!options.force) { + cy.ensureNotDisabled(eventTarget, options._log) + } + + return eventTarget } // Make sure files is an array even if the user only passed in one const filesArray = await Promise.all(([] as Cypress.FileReference[]).concat(files).map(parseFile(options))) + const subjectChain = cy.subjectChain() + // We verify actionability on the subject, rather than the eventTarget, // in order to allow for a hidden with a visible