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) + }) }) }) 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/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 a66e6f8498b1..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.') @@ -960,7 +958,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) => { @@ -1103,23 +1101,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) }) }) @@ -1187,6 +1171,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) => { @@ -1293,6 +1291,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', () => { @@ -1551,22 +1550,16 @@ space }) }) - it('sets type to parent when subject isnt element', () => { - cy.window().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('parent') + expect(this.lastLog.get('type')).to.eq('child') }) }) }) - it('sets type to child when used as a child command', () => { - cy.get('body').contains('foo').then(function () { - expect(this.lastLog.get('type')).to.eq('child') - }) - }) - it('logs when not exists', () => { cy.contains('does-not-exist').should('not.exist').then(function () { expect(this.lastLog.get('message')).to.eq('does-not-exist') @@ -1744,7 +1737,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() @@ -1752,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/cypress/cy.cy.js b/packages/driver/cypress/e2e/cypress/cy.cy.js index d84572ad8a7a..909a16939e50 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/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/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') 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! - -
    - - - - - - - - - - - - - - - - - - - -
    - - diff --git a/packages/driver/cypress/support/utils.js b/packages/driver/cypress/support/utils.js index e24aee8c3d1b..c59634fccf28 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/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/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..a00aee8c3ae4 100644 --- a/packages/driver/src/cy/commands/querying/querying.ts +++ b/packages/driver/src/cy/commands/querying/querying.ts @@ -1,15 +1,21 @@ 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 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 ContainsOptions = Partial +type ShadowOptions = Partial + function getAlias (selector, log, cy) { const alias = selector.slice(1) @@ -148,395 +154,28 @@ 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: GetOptions = {}) { if ((userOptions === null) || _.isArray(userOptions) || !_.isPlainObject(userOptions)) { $errUtils.throwErrByPath('get.invalid_options', { args: { options: userOptions }, }) } - const log = userOptions.log !== false && Cypress.log({ + const log = userOptions.log !== false && (userOptions._log || Cypress.log({ message: selector, + type: 'parent', timeout: userOptions.timeout, 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) } - const withinSubject = cy.state('withinSubject') const includeShadowDom = resolveShadowDomInclusion(Cypress, userOptions.includeShadowDom) return () => { @@ -545,10 +184,14 @@ export default (Commands, Cypress, cy, state) => { let $el try { - let scope: (typeof withinSubject) | Node[] = withinSubject + let scope = userOptions.withinSubject || cy.state('withinSubject') + + if (scope && scope[0]) { + scope = scope[0] + } if (includeShadowDom) { - const root = withinSubject ? withinSubject[0] : cy.state('document') + const root = scope || cy.state('document') const elementsWithShadow = $dom.findAllShadowRoots(root) scope = elementsWithShadow.concat(root) @@ -591,216 +234,176 @@ 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 - } + Commands._addQuery('contains', function contains (filter, text, userOptions: ContainsOptions = {}) { + 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 = '' + } - 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') - } + if (!(_.isString(text) || _.isFinite(text) || _.isRegExp(text))) { + $errUtils.throwErrByPath('contains.invalid_argument') + } - const options: InternalContainsOptions = _.defaults({}, userOptions, { log: true, matchCase: true }) + if (_.isBlank(text)) { + $errUtils.throwErrByPath('contains.empty_string') + } - if (!(_.isString(text) || _.isFinite(text) || _.isRegExp(text))) { - $errUtils.throwErrByPath('contains.invalid_argument') - } + // find elements by the :cy-contains psuedo selector + // and any submit inputs with the attributeContainsWord selector + const selector = $dom.getContainsSelector(text, filter, { matchCase: true, ...userOptions }) - if (_.isBlank(text)) { - $errUtils.throwErrByPath('contains.empty_string') - } + const log = userOptions.log !== false && Cypress.log({ + message: $utils.stringify(_.compact([filter, text])), + type: this.hasPreviouslyLinkedCommand ? 'child' : 'parent', + timeout: userOptions.timeout, + consoleProps: () => ({}), + }) - const getPhrase = () => { - if (filter && subject) { - const node = $dom.stringify(subject, 'short') + const getOptions = _.extend({ _log: log }, userOptions) as GetOptions + const getFn = cy.now('get', selector, getOptions) - return `within the element: ${node} and with the selector: '${filter}' ` - } + const getPhrase = () => { + if (filter && !(cy.$$(getOptions.withinSubject) as JQuery).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 (!(cy.$$(getOptions.withinSubject) as JQuery).is('body')) { + const node = $dom.stringify(getOptions.withinSubject, 'short') - return '' + return `within the element: ${node} ` } - const getErr = (err) => { - const { type, negated } = err + return '' + } + + this.set('timeout', userOptions.timeout) + this.set('onFail', (err) => { + switch (err.type) { + case 'length': + if (err.expected > 1) { + const { message, docsUrl } = $errUtils.cypressErrByPath('contains.length_option') - if (type === 'existence') { - if (negated) { - return `Expected not to find content: '${text}' ${getPhrase()}but continuously found it.` + 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 - - if (options.log !== false) { - consoleProps = { - Content: text, - 'Applied To': $dom.getElements(subject || state('withinSubject')), - } + return (subject) => { + cy.ensureSubjectByType(subject, ['optional', 'window', 'document', 'element'], this) - 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.state('withinSubject') || cy.$$('body') } - const setEl = ($el) => { - if (options.log === false) { - return - } + getOptions.withinSubject = subject[0] ?? 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({ + $el, + 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: ShadowOptions = {}) { + 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 - }, - }) - } + this.set('timeout', userOptions.timeout) + this.set('onFail', (err) => { + switch (err.type) { + case 'existence': { + const { message, docsUrl } = $errUtils.cypressErrByPath('shadow.no_shadow_root') - const setEl = ($el) => { - if (options.log === false) { - return + err.message = message + err.docsUrl = docsUrl + break + } + default: + break } + }) - consoleProps.Yielded = $dom.getElements($el) - consoleProps.Elements = $el?.length - - return options._log.set({ $el }) - } + return (subject) => { + cy.ensureSubjectByType(subject, 'element', this) - 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/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/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/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/cy/retries.ts b/packages/driver/src/cy/retries.ts index 088b0b2766c3..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 = 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..714f8f9da461 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 ret(subject) }, diff --git a/packages/driver/src/cypress/commands.ts b/packages/driver/src/cypress/commands.ts index 74d62e1bfa50..1b057554d133 100644 --- a/packages/driver/src/cypress/commands.ts +++ b/packages/driver/src/cypress/commands.ts @@ -161,6 +161,10 @@ export default { internalError('miscellaneous.invalid_new_query', name) } + if (addingBuiltIns) { + builtInCommandNames[name] = true + } + cy._addQuery({ name, fn }) }, diff --git a/packages/driver/src/cypress/cy.ts b/packages/driver/src/cypress/cy.ts index c6990885c033..e4552ea47c7e 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 @@ -851,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) { @@ -896,6 +897,10 @@ export class $Cy extends EventEmitter2 implements ITimeouts, IStability, IAssert } now (name, ...args) { + if (this.queryFns[name]) { + return this.queryFns[name].apply(this.state('current'), args) + } + return Promise.resolve( this.commandFns[name].apply(this, args), ) @@ -1261,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) 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: { diff --git a/packages/driver/src/dom/elements/find.ts b/packages/driver/src/dom/elements/find.ts index 61c7edd47289..61119bae0e32 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,91 @@ 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. + * 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. + */ - const escapedText = escapeQuotes( - escapeBackslashes(text), - ) +// Example: +// button:cy-contains("Login") +$.expr[':']['cy-contains'] = $.expr.createPseudo((text) => { + text = JSON.parse(`"${ text }"`) - // 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(',') + return function (elem) { + let testText = normalizeWhitespaces(elem) - let cyContainsSelector + return testText.includes(text) + } +}) - if (_.isRegExp(text)) { - if (options.matchCase === false && !text.flags.includes('i')) { - text = new RegExp(text.source, text.flags + 'i') // eslint-disable-line prefer-template - } +// Example: +// .login-button:cy-contains-insensitive("login") +$.expr[':']['cy-contains-insensitive'] = $.expr.createPseudo((text) => { + text = JSON.parse(`"${ text }"`) - // taken from jquery's normal contains method - cyContainsSelector = function (elem) { - if (elem.type === 'submit' && elem.tagName === 'INPUT') { - return text.test(elem.value) - } + return function (elem) { + let testText = normalizeWhitespaces(elem) - const testText = normalizeWhitespaces(elem) + testText = testText.toLowerCase() + text = text.toLowerCase() - return text.test(testText) - } - } else if (_.isString(text)) { - cyContainsSelector = function (elem) { - let testText = normalizeWhitespaces(elem) + return testText.includes(text) + } +}) + +function isSubmit (elem: Element): elem is HTMLInputElement { + return elem.tagName === 'INPUT' && (elem as HTMLInputElement).type === 'submit' +} - if (!options.matchCase) { - testText = testText.toLowerCase() - text = text.toLowerCase() - } +// 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)) - return testText.includes(text) + // taken from jquery's normal contains method + return function (elem) { + if (isSubmit(elem)) { + return regex.test(elem.value) } - } else { - cyContainsSelector = $expr.contains + + const testText = normalizeWhitespaces(elem) + + 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 +316,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() 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"