diff --git a/.circleci/config.yml b/.circleci/config.yml index 81f6beaae83c..6ad7d0879d40 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -27,8 +27,7 @@ mainBuildFilters: &mainBuildFilters branches: only: - develop - - fix-ci-deps - - issue-23843_electron_21_upgrade + - issue-7306 # usually we don't build Mac app - it takes a long time # but sometimes we want to really confirm we are doing the right thing diff --git a/cli/types/cypress.d.ts b/cli/types/cypress.d.ts index 14a837d82cc9..d1899b48b84c 100644 --- a/cli/types/cypress.d.ts +++ b/cli/types/cypress.d.ts @@ -49,6 +49,9 @@ declare namespace Cypress { interface CommandFnWithOriginalFnAndSubject { (this: Mocha.Context, originalFn: CommandOriginalFnWithSubject, prevSubject: S, ...args: Parameters): ReturnType | void } + interface QueryFn { + (...args: Parameters): (subject: any) => any + } interface ObjectLike { [key: string]: any } @@ -462,30 +465,92 @@ declare namespace Cypress { */ log(options: Partial): Log - /** - * @see https://on.cypress.io/api/commands - */ Commands: { + /** + * Add a custom command + * @see https://on.cypress.io/api/commands + */ add(name: T, fn: CommandFn): void + + /** + * Add a custom parent command + * @see https://on.cypress.io/api/commands#Parent-Commands + */ add(name: T, options: CommandOptions & {prevSubject: false}, fn: CommandFn): void + + /** + * Add a custom child command + * @see https://on.cypress.io/api/commands#Child-Commands + */ add(name: T, options: CommandOptions & {prevSubject: true}, fn: CommandFnWithSubject): void + + /** + * Add a custom child or dual command + * @see https://on.cypress.io/api/commands#Validations + */ add( name: T, options: CommandOptions & { prevSubject: S | ['optional'] }, fn: CommandFnWithSubject, ): void + + /** + * Add a custom command that allows multiple types as the prevSubject + * @see https://on.cypress.io/api/commands#Validations#Allow-Multiple-Types + */ add( name: T, options: CommandOptions & { prevSubject: S[] }, fn: CommandFnWithSubject[S]>, ): void + + /** + * Add one or more custom commands + * @see https://on.cypress.io/api/commands + */ addAll(fns: CommandFns): void + + /** + * Add one or more custom parent commands + * @see https://on.cypress.io/api/commands#Parent-Commands + */ addAll(options: CommandOptions & {prevSubject: false}, fns: CommandFns): void + + /** + * Add one or more custom child commands + * @see https://on.cypress.io/api/commands#Child-Commands + */ addAll(options: CommandOptions & { prevSubject: true }, fns: CommandFnsWithSubject): void + + /** + * Add one or more custom commands that validate their prevSubject + * @see https://on.cypress.io/api/commands#Validations + */ addAll( options: CommandOptions & { prevSubject: S | ['optional'] }, fns: CommandFnsWithSubject, ): void + + /** + * Add one or more custom commands that allow multiple types as their prevSubject + * @see https://on.cypress.io/api/commands#Allow-Multiple-Types + */ addAll( options: CommandOptions & { prevSubject: S[] }, fns: CommandFnsWithSubject[S]>, ): void + + /** + * Overwrite an existing Cypress command with a new implementation + * @see https://on.cypress.io/api/commands#Overwrite-Existing-Commands + */ overwrite(name: T, fn: CommandFnWithOriginalFn): void + + /** + * Overwrite an existing Cypress command with a new implementation + * @see https://on.cypress.io/api/commands#Overwrite-Existing-Commands + */ overwrite(name: T, fn: CommandFnWithOriginalFnAndSubject): void + + /** + * Add a custom query + * @see https://on.cypress.io/api/commands#Queries + */ + addQuery(name: T, fn: QueryFn): void } /** diff --git a/packages/app/cypress/e2e/specs_list_e2e.cy.ts b/packages/app/cypress/e2e/specs_list_e2e.cy.ts index f66467d8fb2c..ba8279d2460e 100644 --- a/packages/app/cypress/e2e/specs_list_e2e.cy.ts +++ b/packages/app/cypress/e2e/specs_list_e2e.cy.ts @@ -245,8 +245,7 @@ describe('App: Spec List (E2E)', () => { cy.findByText('No specs matched your search:').should('not.be.visible') }) - // TODO: fix flaky test https://github.com/cypress-io/cypress/issues/23305 - it.skip('saves the filter when navigating to a spec and back', function () { + it('saves the filter when navigating to a spec and back', function () { const targetSpecFile = 'accounts_list.spec.js' clearSearchAndType(targetSpecFile) diff --git a/packages/app/cypress/e2e/top-nav.cy.ts b/packages/app/cypress/e2e/top-nav.cy.ts index e65a10d484f7..ebb3bc16f31f 100644 --- a/packages/app/cypress/e2e/top-nav.cy.ts +++ b/packages/app/cypress/e2e/top-nav.cy.ts @@ -195,7 +195,7 @@ describe('App Top Nav Workflows', () => { }) it('hides dropdown when version in header is clicked', () => { - cy.findByTestId('cypress-update-popover').findByRole('button', { expanded: false }).as('topNavVersionButton').click() + cy.findByTestId('cypress-update-popover').findAllByRole('button').first().as('topNavVersionButton').click() cy.get('@topNavVersionButton').should('have.attr', 'aria-expanded', 'true') @@ -541,7 +541,7 @@ describe('App Top Nav Workflows', () => { cy.findByRole('button', { name: 'Log in' }).click() }) - cy.findByRole('dialog', { name: 'Log in to Cypress' }).within(() => { + cy.findByRole('dialog').within(() => { cy.findByRole('button', { name: 'Log in' }).click() cy.contains('http://127.0.0.1:0000/redirect-to-auth').should('be.visible') @@ -573,7 +573,7 @@ describe('App Top Nav Workflows', () => { cy.findByRole('button', { name: 'Log in' }).click() }) - cy.findByRole('dialog', { name: 'Log in to Cypress' }).within(() => { + cy.findByRole('dialog').within(() => { cy.findByRole('button', { name: 'Log in' }).click() cy.contains(loginText.titleFailed).should('be.visible') @@ -623,7 +623,7 @@ describe('App Top Nav Workflows', () => { cy.findByRole('button', { name: 'Log in' }).as('loginButton').click() }) - cy.findByRole('dialog', { name: 'Log in to Cypress' }).within(() => { + cy.findByRole('dialog').within(() => { cy.findByRole('button', { name: 'Log in' }).click() cy.contains(loginText.titleFailed).should('be.visible') @@ -660,7 +660,7 @@ describe('App Top Nav Workflows', () => { cy.findByRole('button', { name: 'Log in' }).as('loginButton').click() }) - cy.findByRole('dialog', { name: 'Log in to Cypress' }).within(() => { + cy.findByRole('dialog').within(() => { cy.findByRole('button', { name: 'Log in' }).click() cy.contains(loginText.titleFailed).should('be.visible') cy.contains(loginText.bodyError).should('be.visible') diff --git a/packages/app/package.json b/packages/app/package.json index 31f139565be4..ae42c2329b63 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -28,7 +28,7 @@ "@intlify/vite-plugin-vue-i18n": "2.4.0", "@packages/frontend-shared": "0.0.0-development", "@percy/cypress": "^3.1.0", - "@testing-library/cypress": "8.0.0", + "@testing-library/cypress": "BlueWinds/cypress-testing-library#119054b5963b0d2e064b13c5cc6fc9db32c8b7b5", "@types/faker": "5.5.8", "@urql/core": "2.4.4", "@urql/vue": "0.6.2", diff --git a/packages/driver/cypress/e2e/commands/actions/check.cy.js b/packages/driver/cypress/e2e/commands/actions/check.cy.js index 49a37886a623..8d0885a8e4eb 100644 --- a/packages/driver/cypress/e2e/commands/actions/check.cy.js +++ b/packages/driver/cypress/e2e/commands/actions/check.cy.js @@ -98,6 +98,27 @@ 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 +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 this element') + expect(err.message).to.include('`cy.check()` failed because the page updated') done() }) @@ -1079,7 +1100,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 page 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..09a0b6059d89 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 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 3fa9e8bf358f..5a63fcbdc173 100644 --- a/packages/driver/cypress/e2e/commands/actions/click.cy.js +++ b/packages/driver/cypress/e2e/commands/actions/click.cy.js @@ -738,6 +738,27 @@ 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 +834,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('@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('[contenteditable]:first') - .invoke('html', '
foo
').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 +1614,16 @@ 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('') + })) + + cy.get('#button').click().should('contain', 'New Button') + }) + it('waits until element stops animating', () => { let retries = 0 @@ -2120,41 +2149,21 @@ 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 page 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() - }) + cy.on('scrolled', () => $el.remove()) }) .click() }) @@ -3269,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 this element') - - done() - }) - - cy.get('button:first').dblclick().dblclick() - }) - it('logs once when not dom subject', function (done) { cy.on('fail', (err) => { const { lastLog } = this @@ -3708,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 this element') - - 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/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..dd96c4308e65 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,11 @@ 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 = cy.$$('#nonscroll-becomes-scrollable') + $container.css('overflow', 'scroll') retried = true })) @@ -450,6 +455,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', () => 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/cypress/e2e/commands/actions/select.cy.js b/packages/driver/cypress/e2e/commands/actions/select.cy.js index f0ed01b70247..e62a3ffdc871 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() }) @@ -648,7 +648,7 @@ describe('src/cy/commands/actions/select', () => { cy.get('#select-maps').select('de_dust2').then(function ($select) { const { lastLog } = this - expect(lastLog.get('$el')).to.eq($select) + expect(lastLog.get('$el')).to.eql($select) }) }) diff --git a/packages/driver/cypress/e2e/commands/actions/selectFile.cy.js b/packages/driver/cypress/e2e/commands/actions/selectFile.cy.js index e75eb986615f..aeff3963e635 100644 --- a/packages/driver/cypress/e2e/commands/actions/selectFile.cy.js +++ b/packages/driver/cypress/e2e/commands/actions/selectFile.cy.js @@ -336,7 +336,7 @@ describe('src/cy/commands/actions/selectFile', () => { }) describe('errors', { - defaultCommandTimeout: 500, + defaultCommandTimeout: 100, }, () => { it('is a child command', (done) => { cy.on('fail', (err) => { @@ -358,17 +358,17 @@ describe('src/cy/commands/actions/selectFile', () => { it('throws when non-input subject', function (done) { cy.on('fail', (err) => { - expect(err.message).to.include('`cy.selectFile()` can only be called on an `` or a `